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 | |||
| 574 | 574 | ||
| 575 | 575 | Each wall is pinned at the corner it is built from and runs its whole length | |
| 576 | 576 | 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. | |
| 585 | 588 | ||
| 586 | 589 | The CSS only places what it is told: `.wall-side` is one shape — a row of stacks | |
| 587 | 590 | 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 | |||
| 118 | 118 | places: WallPlace[]; | |
| 119 | 119 | /** The whole thing's footprint, walls included — what has to fit the middle. */ | |
| 120 | 120 | box: { w: number; h: number }; | |
| 121 | + | /** The daylight actually left at a corner, along the edge. Under a tile. */ | |
| 122 | + | gap: number; | |
| 121 | 123 | /** The opening left in the middle of it, as a box round the four corners. */ | |
| 122 | 124 | open: { x: number; y: number; w: number; h: number }; | |
| 123 | 125 | } | |
| ⋯ 9 unchanged lines | |||
| 133 | 135 | * far corner, over the outside of the next wall along. That overhang is the | |
| 134 | 136 | * pinwheel, and it is the reason the corners are covered rather than mitred. | |
| 135 | 137 | * | |
| 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. | |
| 145 | 153 | * | |
| 146 | 154 | * Everything is linear in `len` and `thick`, so the caller can solve for the | |
| 147 | 155 | * tile by asking once at a size of one and scaling the answer. | |
| ⋯ 3 unchanged lines | |||
| 151 | 159 | thick: number, | |
| 152 | 160 | angle: number, | |
| 153 | 161 | turn = 0, | |
| 154 | - | lapping = 1, | |
| 162 | + | gap = 0, | |
| 155 | 163 | ): Rhombus { | |
| 156 | 164 | // Interior angles alternate round a rhombus and come to a straight line. | |
| 157 | 165 | const a = Math.min(179, Math.max(1, angle)); | |
| 158 | 166 | 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); | |
| 165 | 172 | // Corner to corner. The wall is longer than this by the overrun. | |
| 166 | 173 | const edge = Math.max(thick, len - lap); | |
| 167 | 174 | ||
| ⋯ 37 unchanged lines | |||
| 205 | 212 | const cy = corners.map((c) => c.y); | |
| 206 | 213 | return { | |
| 207 | 214 | places, | |
| 215 | + | gap: shut - lap, | |
| 208 | 216 | box: { w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) }, | |
| 209 | 217 | open: { | |
| 210 | 218 | x: Math.min(...cx), | |
| ⋯ 59 unchanged lines | |||
modifiedsrc/styles.css+4 −4
| ⋯ 684 unchanged lines | |||
| 685 | 685 | places what it is told. */ | |
| 686 | 686 | --wall-len: 0px; | |
| 687 | 687 | --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; | |
| 692 | 692 | position: absolute; | |
| 693 | 693 | top: 50%; | |
| 694 | 694 | left: 50%; | |
| ⋯ 1603 unchanged lines | |||
modifiedsrc/table/physics.test.ts+25 −17
| ⋯ 603 unchanged lines | |||
| 604 | 604 | expect(2 * (RING.h + RING.v) * 2).toBe(144); | |
| 605 | 605 | }); | |
| 606 | 606 | ||
| 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', () => { | |
| 608 | 608 | 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); | |
| 625 | 633 | } | |
| 626 | 634 | } | |
| 627 | 635 | }); | |
| ⋯ 89 unchanged lines | |||
modifiedsrc/tuneWall.tsx+5 −5
| ⋯ 31 unchanged lines | |||
| 32 | 32 | interface Placing { | |
| 33 | 33 | turn: number; | |
| 34 | 34 | angle: number; | |
| 35 | - | lap: number; | |
| 35 | + | gap: number; | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | - | const START: Placing = { turn: 0, angle: 90, lap: 1 }; | |
| 38 | + | const START: Placing = { turn: 0, angle: 90, gap: 0 }; | |
| 39 | 39 | ||
| 40 | 40 | const KNOBS: Knob[] = [ | |
| 41 | 41 | { key: 'angle', label: 'Corner', min: 20, max: 160, step: 1, note: '° — 90 is the square', unit: 'deg' }, | |
| 42 | 42 | { 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: '' }, | |
| 44 | 44 | ]; | |
| 45 | 45 | ||
| 46 | 46 | /** Where the hand has got to, so the square can be seen whole and eaten away. */ | |
| ⋯ 23 unchanged lines | |||
| 70 | 70 | if (!ring) return; | |
| 71 | 71 | ring.style.setProperty('--wall-turn', `${place.turn}`); | |
| 72 | 72 | ring.style.setProperty('--wall-angle', `${place.angle}`); | |
| 73 | - | ring.style.setProperty('--wall-lap', `${place.lap}`); | |
| 73 | + | ring.style.setProperty('--wall-gap', `${place.gap}`); | |
| 74 | 74 | }, [place]); | |
| 75 | 75 | ||
| 76 | 76 | // Then measure what came out and draw it, which is the whole point. | |
| ⋯ 64 unchanged lines | |||
| 141 | 141 | }; | |
| 142 | 142 | }, [place, drawn, showColliders, state]); | |
| 143 | 143 | ||
| 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}`; | |
| 145 | 145 | ||
| 146 | 146 | return ( | |
| 147 | 147 | <div className="tune"> | |
| ⋯ 84 unchanged lines | |||
modifiedsrc/ui/WallRing.tsx+6 −6
| ⋯ 130 unchanged lines | |||
| 131 | 131 | // Scale-free, so this is the same number however big the square came out. | |
| 132 | 132 | const ratio = box.height / box.width; | |
| 133 | 133 | 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. | |
| 136 | 136 | const angle = parseFloat(style.getPropertyValue('--wall-angle')) || 90; | |
| 137 | 137 | 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; | |
| 139 | 139 | ||
| 140 | - | const from = `${middle.clientWidth}x${middle.clientHeight}/${ratio}/${angle}/${turn}/${lap}`; | |
| 140 | + | const from = `${middle.clientWidth}x${middle.clientHeight}/${ratio}/${angle}/${turn}/${gap}`; | |
| 141 | 141 | if (from === last) return; | |
| 142 | 142 | last = from; | |
| 143 | 143 | ||
| 144 | 144 | // Everything about the shape is linear in the stack, so it is asked for | |
| 145 | 145 | // 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); | |
| 147 | 147 | const s = Math.max( | |
| 148 | 148 | 1, | |
| 149 | 149 | Math.min(middle.clientWidth / unit.box.w, middle.clientHeight / unit.box.h), | |
| 150 | 150 | ); | |
| 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); | |
| 152 | 152 | setSquare({ opening: { w: shape.open.w, h: shape.open.h }, cell: s, shape }); | |
| 153 | 153 | }; | |
| 154 | 154 | ||
| ⋯ 21 unchanged lines | |||