anvilsign in

collin/mahjong · 8bf33e61

A corner is daylight, not a mitre

Collin Richards · 2026-08-21 21:45 UTC · 8bf33e619942d0579062eeff32643323efd3e9f2 · parent eb4977f3 · browse files

modifiedREADME.md+11 −8
⋯ 573 unchanged lines
574574
575575 Each wall is pinned at the corner it is built from and runs its whole length
576576 from there, which carries it past the far corner and over the outside of the
577-next wall along — that overhang is the pinwheel, and it is why the corners are
578-covered rather than mitred. How far a wall has to overrun depends on the corner
579-it is turning: two bands of thickness `t` meeting at an interior angle ψ overlap
580-by `t / tan(ψ/2)`. A rhombus has two different corner angles and its four walls
581-are all the same eighteen stacks long, so they cannot each have their own
582-overrun — all four take the *sharper* corner's, which means the shallow corners
583-overlap by more than they strictly need. That is a thicker join and never a gap,
584-at any angle.
577+next wall along — that overhang is the pinwheel. How far it has to overrun to
578+*close* the corner depends on the corner: two bands of thickness `t` meeting at
579+an interior angle ψ overlap by `t / tan(ψ/2)`, which at ninety degrees is
580+exactly one wall-depth. A rhombus has two different corner angles and its four
581+walls are all the same eighteen stacks long, so they cannot each have their own
582+overrun — all four take the sharper corner's.
583+
584+A real corner is not closed, though. Walls get pushed up against each other by
585+hand and there is always a little daylight where they meet, so `--wall-gap`
586+takes some of that overrun back, measured in tiles and held under one of them —
587+a whole tile of daylight is a wall that has stopped meeting the next.
585588
586589 The CSS only places what it is told: `.wall-side` is one shape — a row of stacks
587590 going right, standing `--wd` deep — and a transform per side. There are no
⋯ 82 unchanged lines
modifiedsrc/game/wall.ts+24 −16
⋯ 117 unchanged lines
118118 places: WallPlace[];
119119 /** The whole thing's footprint, walls included — what has to fit the middle. */
120120 box: { w: number; h: number };
121+ /** The daylight actually left at a corner, along the edge. Under a tile. */
122+ gap: number;
121123 /** The opening left in the middle of it, as a box round the four corners. */
122124 open: { x: number; y: number; w: number; h: number };
123125 }
⋯ 9 unchanged lines
133135 * far corner, over the outside of the next wall along. That overhang is the
134136 * pinwheel, and it is the reason the corners are covered rather than mitred.
135137 *
136- * How far a wall has to overrun to cover the next one depends on the corner it
137- * is turning: two bands of thickness `t` meeting at an interior angle ψ overlap
138- * by `t / tan(ψ/2)` along the edge. A rhombus has two different corner angles,
139- * so the four walls would each want a different overrun — and they cannot have
140- * one, because they are all the same eighteen stacks long. So all four take the
141- * *sharper* corner's overrun: the shallow corners then overlap by more than they
142- * strictly need, which is a thicker join and never a gap. At ninety degrees the
143- * two are equal and it comes out as exactly one wall-depth, which is where the
144- * square's own arithmetic came from.
138+ * How far a wall has to overrun to *close* the corner it is turning depends on
139+ * that corner: two bands of thickness `t` meeting at an interior angle ψ overlap
140+ * by `t / tan(ψ/2)` along the edge. At ninety degrees that is exactly one
141+ * wall-depth, which is where the square's own arithmetic came from. A rhombus
142+ * has two different corner angles, so the four walls would each want a different
143+ * overrun and cannot have one, being all the same eighteen stacks long — so all
144+ * four take the *sharper* corner's, and the shallow corners are the ones that
145+ * lap further than they strictly need.
146+ *
147+ * That is the *closed* corner, and a real one is not closed. Walls get pushed up
148+ * against each other by hand and there is always a little daylight left where
149+ * they meet, so `gap` takes some of the overrun back — measured in tiles, since
150+ * that is what anybody looking at it would measure it in, and held to less than
151+ * one of them. A whole tile of daylight is a wall that has stopped meeting the
152+ * next; anything under that is a corner.
145153 *
146154 * Everything is linear in `len` and `thick`, so the caller can solve for the
147155 * tile by asking once at a size of one and scaling the answer.
⋯ 3 unchanged lines
151159 thick: number,
152160 angle: number,
153161 turn = 0,
154- lapping = 1,
162+ gap = 0,
155163 ): Rhombus {
156164 // Interior angles alternate round a rhombus and come to a straight line.
157165 const a = Math.min(179, Math.max(1, angle));
158166 const sharp = Math.min(a, 180 - a);
159- // What the corner needs, and then as much more of it as is asked for. Never
160- // less: below one the walls stop reaching each other, which is the gap. Above
161- // it they simply lie over each other further, and the corners close either
162- // way — the wall keeps its whole length whatever this is, so a longer overrun
163- // pulls the corners in rather than leaving anything behind.
164- const lap = (thick / Math.tan(rad(sharp) / 2)) * Math.max(1, lapping);
167+ // What it takes to close the corner, less the daylight asked for. Under a
168+ // tile of it, always: a whole one and the walls have stopped meeting.
169+ const tile = len / STACKS_PER_SIDE;
170+ const shut = thick / Math.tan(rad(sharp) / 2);
171+ const lap = Math.max(0, shut - Math.min(0.99, Math.max(0, gap)) * tile);
165172 // Corner to corner. The wall is longer than this by the overrun.
166173 const edge = Math.max(thick, len - lap);
167174
⋯ 37 unchanged lines
205212 const cy = corners.map((c) => c.y);
206213 return {
207214 places,
215+ gap: shut - lap,
208216 box: { w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) },
209217 open: {
210218 x: Math.min(...cx),
⋯ 59 unchanged lines
modifiedsrc/styles.css+4 −4
⋯ 684 unchanged lines
685685 places what it is told. */
686686 --wall-len: 0px;
687687 --wall-angle: 90;
688- /* How far each wall runs past its corner, as a multiple of what that corner
689- needs to be covered. One is the tightest join there can be; more is a
690- longer overlap. There is no less — under one is a gap, so it is clamped. */
691- --wall-lap: 1;
688+ /* The daylight left where two walls meet, in tiles. Nought is the corner shut
689+ tight; a whole tile is the walls no longer meeting at all, so it is held
690+ under one. */
691+ --wall-gap: 0;
692692 position: absolute;
693693 top: 50%;
694694 left: 50%;
⋯ 1603 unchanged lines
modifiedsrc/table/physics.test.ts+25 −17
⋯ 603 unchanged lines
604604 expect(2 * (RING.h + RING.v) * 2).toBe(144);
605605 });
606606
607- it('closes at every corner, at every angle, however much it laps', () => {
607+ it('shuts every corner at every angle when no daylight is asked for', () => {
608608 for (const angle of [90, 60, 120, 45, 135, 30, 150]) {
609- for (const lapping of [1, 1.5, 3]) {
610- const r = rhombus(SIDE, RATIO, angle, 0, lapping);
611- const quads = walls(r, SIDE, RATIO);
612- // Each wall's far end has to reach past where the next one starts,
613- // measured along the next wall's own direction: that overlap is the
614- // corner being covered, and it must never be negative.
615- for (let i = 0; i < 4; i++) {
616- const next = r.places[(i + 1) % 4];
617- const rad = (next.dir * Math.PI) / 180;
618- const along = (p: { x: number; y: number }) =>
619- (p.x - next.x) * Math.cos(rad) + (p.y - next.y) * Math.sin(rad);
620- // Zero is the tightest join there can be — the two exactly meeting,
621- // which is what the corner's own mitre comes to. Below zero is a gap.
622- const reach = Math.max(...quads[i].map(along));
623- expect(reach).toBeGreaterThanOrEqual(-1e-6);
624- }
609+ const r = rhombus(SIDE, RATIO, angle, 0, 0);
610+ const quads = walls(r, SIDE, RATIO);
611+ // Each wall's far end has to reach where the next one starts, measured
612+ // along the next wall's own direction. Below zero is daylight.
613+ for (let i = 0; i < 4; i++) {
614+ const next = r.places[(i + 1) % 4];
615+ const rad = (next.dir * Math.PI) / 180;
616+ const along = (p: { x: number; y: number }) =>
617+ (p.x - next.x) * Math.cos(rad) + (p.y - next.y) * Math.sin(rad);
618+ expect(Math.max(...quads[i].map(along))).toBeGreaterThanOrEqual(-1e-6);
619+ }
620+ }
621+ });
622+
623+ it('leaves the daylight it is asked for, and never a whole tile of it', () => {
624+ const tile = 1;
625+ for (const angle of [90, 60, 120, 40]) {
626+ for (const asked of [0, 0.25, 0.5, 0.95, 4]) {
627+ const r = rhombus(SIDE, RATIO, angle, 0, asked);
628+ // What was asked for, in tiles — and clamped under one however much is
629+ // asked, because a whole tile is the walls no longer meeting.
630+ expect(r.gap).toBeLessThan(tile);
631+ expect(r.gap).toBeGreaterThanOrEqual(-1e-9);
632+ if (asked <= 0.95) expect(r.gap).toBeCloseTo(Math.min(asked, 0.99) * tile, 6);
625633 }
626634 }
627635 });
⋯ 89 unchanged lines
modifiedsrc/tuneWall.tsx+5 −5
⋯ 31 unchanged lines
3232 interface Placing {
3333 turn: number;
3434 angle: number;
35- lap: number;
35+ gap: number;
3636 }
3737
38-const START: Placing = { turn: 0, angle: 90, lap: 1 };
38+const START: Placing = { turn: 0, angle: 90, gap: 0 };
3939
4040 const KNOBS: Knob[] = [
4141 { key: 'angle', label: 'Corner', min: 20, max: 160, step: 1, note: '° — 90 is the square', unit: 'deg' },
4242 { key: 'turn', label: 'Turn', min: 0, max: 360, step: 1, note: '° clockwise', unit: 'deg' },
43- { key: 'lap', label: 'Overlap', min: 1, max: 4, step: 0.05, note: '× what the corner needs', unit: '' },
43+ { key: 'gap', label: 'Corner gap', min: 0, max: 0.95, step: 0.05, note: 'tiles of daylight', unit: '' },
4444 ];
4545
4646 /** Where the hand has got to, so the square can be seen whole and eaten away. */
⋯ 23 unchanged lines
7070 if (!ring) return;
7171 ring.style.setProperty('--wall-turn', `${place.turn}`);
7272 ring.style.setProperty('--wall-angle', `${place.angle}`);
73- ring.style.setProperty('--wall-lap', `${place.lap}`);
73+ ring.style.setProperty('--wall-gap', `${place.gap}`);
7474 }, [place]);
7575
7676 // Then measure what came out and draw it, which is the whole point.
⋯ 64 unchanged lines
141141 };
142142 }, [place, drawn, showColliders, state]);
143143
144- const css = `.wall-ring {\n --wall-angle: ${place.angle};\n --wall-turn: ${place.turn};\n --wall-lap: ${place.lap};\n}`;
144+ const css = `.wall-ring {\n --wall-angle: ${place.angle};\n --wall-turn: ${place.turn};\n --wall-gap: ${place.gap};\n}`;
145145
146146 return (
147147 <div className="tune">
⋯ 84 unchanged lines
modifiedsrc/ui/WallRing.tsx+6 −6
⋯ 130 unchanged lines
131131 // Scale-free, so this is the same number however big the square came out.
132132 const ratio = box.height / box.width;
133133 const style = getComputedStyle(el);
134- // The corner it turns, how far round it is set, and how far each wall
135- // runs past its corner as a multiple of what that corner needs.
134+ // The corner it turns, how far round it is set, and how much daylight is
135+ // left where two walls meet — in tiles, and under one of them.
136136 const angle = parseFloat(style.getPropertyValue('--wall-angle')) || 90;
137137 const turn = parseFloat(style.getPropertyValue('--wall-turn')) || 0;
138- const lap = parseFloat(style.getPropertyValue('--wall-lap')) || 1;
138+ const gap = parseFloat(style.getPropertyValue('--wall-gap')) || 0;
139139
140- const from = `${middle.clientWidth}x${middle.clientHeight}/${ratio}/${angle}/${turn}/${lap}`;
140+ const from = `${middle.clientWidth}x${middle.clientHeight}/${ratio}/${angle}/${turn}/${gap}`;
141141 if (from === last) return;
142142 last = from;
143143
144144 // Everything about the shape is linear in the stack, so it is asked for
145145 // once at a stack of one and the answer scaled to the middle there is.
146- const unit = rhombus(STACKS_PER_SIDE, ratio, angle, turn, lap);
146+ const unit = rhombus(STACKS_PER_SIDE, ratio, angle, turn, gap);
147147 const s = Math.max(
148148 1,
149149 Math.min(middle.clientWidth / unit.box.w, middle.clientHeight / unit.box.h),
150150 );
151- const shape = rhombus(STACKS_PER_SIDE * s, ratio * s, angle, turn, lap);
151+ const shape = rhombus(STACKS_PER_SIDE * s, ratio * s, angle, turn, gap);
152152 setSquare({ opening: { w: shape.open.w, h: shape.open.h }, cell: s, shape });
153153 };
154154
⋯ 21 unchanged lines