tower / commit
refactor(views): drop the two disagreeing grids, and fix what they hid
decisions.md | 41 ++++++++++++++++++++++++ src/ui/app.ts | 7 +++- src/views/exterior.ts | 19 +++-------- src/views/map.ts | 43 +++++++++++++------------ src/world/schema.ts | 15 ++++++++- static/world.json | 88 +++++++-------------------------------------------- test/world.test.ts | 25 ++++++++++++++- tools/terrain.mjs | 9 ++---- 8 files changed, 128 insertions(+), 119 deletions(-) diff --git a/decisions.md b/decisions.md index 2506936..e0126d5 100644 --- a/decisions.md +++ b/decisions.md @@ -802,3 +802,44 @@ and reseeding after a local edit returns 23 to 22. over shipped content will, sooner or later, win with nothing in it. Precedence rules between a cache and its source need a defined answer for the empty case, and a manual override for every other case. + +--- + +## D-026 — Two grids that did not agree, both removed + +**Removed at the author's request.** The exterior had structural ribs every 20 km; +the map had a graticule every 50 km. Neither was wrong on its own, but side by side +they invited a comparison that does not hold — twenty rings against ten lines, +counting nothing in common. + +Both are gone. Neither was load-bearing: the map keeps its scale bar and the +exterior its spin readout, and with the terrain drawn the ground itself carries the +sense of distance far better than either grid did. A reference grid that has to be +mentally reconciled with another reference grid is worse than none. + +**Worth generalising:** two independent scales shown in two views of the same object +must agree or one must go. It is not enough for each to be individually correct. + +--- + +## D-027 — A band is its own geometry, not a polygon + +**Fixed.** Regions that ring the world — the desert, the forest, both cap seas — are +now `{ type: 'band', zFrom, zTo }` rather than four-cornered polygons. + +**Found while removing the graticule.** With the grid lines gone, a stray hairline +was left across the middle of the map. A band written as a polygon runs theta from +0 to 2pi, and both of those normalise to *the same map row* — so all four corners +land on one line and the region collapses into a one-pixel streak. It had been +hiding among the graticule lines the whole time. + +**Why a new type rather than a workaround.** On a cylinder, "the whole circumference +between two z" is a genuinely different shape from a polygon: it has no theta +extent to give, because it has all of it. Tower's geography is built out of these — +D-020 made the desert ring the world precisely because a band cannot be walked +around — so it deserves to be a first-class shape rather than a polygon that happens +to wrap. The map draws it as a full-height rectangle, hit-testing is a z range, and +the editor reports it as *rings the world, z 148.0 km to 218.0 km (70.0 km deep)*. + +Tests assert the four ringing regions survive as bands through parse and public +export, and that a band with unusable bounds is rejected like any other geometry. diff --git a/src/ui/app.ts b/src/ui/app.ts index a39e644..9a1bd39 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -219,7 +219,9 @@ export class App { const g = p.geometry const point = g.type === 'point' ? { theta: g.theta, z: g.z } - : { theta: g.points[0]![0], z: g.points[0]![1] } + : g.type === 'band' + ? { theta: 0, z: (g.zFrom + g.zTo) / 2 } + : { theta: g.points[0]![0], z: g.points[0]![1] } this.map.centreOn(point) if (this.view === 'exterior') this.exterior?.lookAt(point) } @@ -612,6 +614,9 @@ export class App { : `land strip ${landStripIndex(g.theta)} (${km(landStripWidth())} wide)` return `z ${km(g.z)} from the sun cap · ${where}` } + if (g.type === 'band') { + return `rings the world, z ${km(g.zFrom)} to ${km(g.zTo)} (${km(g.zTo - g.zFrom)} deep)` + } if (g.type === 'polyline') { return `${g.points.length} points, ${km(pathLength(g.points))} long` } diff --git a/src/views/exterior.ts b/src/views/exterior.ts index 6b63a1c..e9a7c2e 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -12,7 +12,7 @@ import { CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, Group, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, Points, PointsMaterial, Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, SphereGeometry, - TextureLoader, TorusGeometry, Vector2, Vector3, WebGLRenderer, + TextureLoader, Vector2, Vector3, WebGLRenderer, } from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import { @@ -45,7 +45,6 @@ export class ExteriorView { private markers = new Group() private hull: Mesh private hullMat: MeshStandardMaterial - private ribs: Mesh[] = [] private terrainMap: Texture | null = null private _showTerrain = true private raycaster = new Raycaster() @@ -97,16 +96,6 @@ export class ExteriorView { color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: DoubleSide, }) const hull = new Mesh(g, this.hullMat) - - // Structural ribs, to give the eye a sense of the 540 km. They read as - // gridlines once the terrain is on, which is useful rather than not. - const ribMat = new MeshStandardMaterial({ color: 0xaab3c2, roughness: 0.45, metalness: 0.35 }) - for (let z = 20; z < L; z += 20) { - const rib = new Mesh(new TorusGeometry(R * 1.004, 0.4, 6, 96), ribMat) - rib.position.z = z - hull.add(rib) - this.ribs.push(rib) - } this.loadTerrain() return hull } @@ -176,7 +165,6 @@ export class ExteriorView { this.hullMat.metalness = on ? 0.06 : 0.28 this.hullMat.roughness = on ? 0.9 : 0.62 this.hullMat.needsUpdate = true - for (const rib of this.ribs) (rib.material as MeshStandardMaterial).opacity = on ? 0.5 : 1 } set showTerrain(v: boolean) { @@ -277,7 +265,10 @@ export class ExteriorView { const g = p.geometry const at: SurfacePoint = g.type === 'point' ? { theta: g.theta, z: g.z } - : { theta: g.points[0]![0], z: g.points[0]![1] } + : g.type === 'band' + // A band rings the world, so any theta is as good as another. + ? { theta: 0, z: (g.zFrom + g.zTo) / 2 } + : { theta: g.points[0]![0], z: g.points[0]![1] } const selected = p.id === selectedId const r = selected ? 6.5 : 4.2 const dot = new Mesh( diff --git a/src/views/map.ts b/src/views/map.ts index 804e9e1..9215500 100644 --- a/src/views/map.ts +++ b/src/views/map.ts @@ -213,6 +213,13 @@ export class TowerMap { this.ctx.lineWidth = 10 if (this.ctx.isPointInStroke(px, py)) return p } + for (let i = this.places.length - 1; i >= 0; i--) { + const p = this.places[i]! + if (p.geometry.type !== 'band') continue + const [x0] = this.worldToScreen(p.geometry.zFrom, 0) + const [x1] = this.worldToScreen(p.geometry.zTo, 0) + if (px >= x0 && px <= x1) return p + } for (let i = this.places.length - 1; i >= 0; i--) { const p = this.places[i]! if (p.geometry.type !== 'polygon') continue @@ -323,25 +330,6 @@ export class TowerMap { } } - // 50 km graticule. - ctx.strokeStyle = 'rgba(255,255,255,0.055)' - ctx.lineWidth = 1 - ctx.beginPath() - for (let z = 0; z <= LENGTH_M; z += 50_000) { - const [sx] = this.worldToScreen(z, 0) - ctx.moveTo(sx, y0) - ctx.lineTo(sx, y0 + h) - } - for (let i = 0; i < landStripCount(); i++) { - const [, sy] = this.worldToScreen(0, toMap({ theta: landStripCentre(i), z: 0 }).y) - ctx.moveTo(x0, sy) - ctx.lineTo(x0 + w, sy) - } - ctx.stroke() - - // Naming each strip is what keeps the wrapped axis legible. - ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace' - ctx.fillStyle = 'rgba(160,200,170,0.5)' if (slatCount() === 0) { // The single strip is centred on the map edge, so label it in the middle // where it can actually be read. @@ -429,7 +417,22 @@ export class TowerMap { const selected = p.id === this.selectedId ctx.globalAlpha = p.public ? 1 : 0.55 - if (p.geometry.type === 'polyline') { + if (p.geometry.type === 'band') { + const [x0, y0] = this.worldToScreen(p.geometry.zFrom, 0) + const [x1] = this.worldToScreen(p.geometry.zTo, 0) + const h = CIRCUMFERENCE_M * this.scale + const overTerrain = this.showTerrain && this.terrain + if (!overTerrain || selected) { + ctx.fillStyle = color + (selected ? '2e' : '30') + ctx.fillRect(x0, y0, x1 - x0, h) + } + ctx.strokeStyle = overTerrain && !selected ? color + '66' : color + ctx.lineWidth = selected ? 2.5 : 1.5 + ctx.setLineDash(p.public ? [] : [5, 4]) + ctx.strokeRect(x0, y0, x1 - x0, h) + ctx.setLineDash([]) + this.label(p.name, (x0 + x1) / 2, y0 + h - 10, color, true) + } else if (p.geometry.type === 'polyline') { ctx.beginPath() this.tracePath(p.geometry.points) ctx.strokeStyle = color diff --git a/src/world/schema.ts b/src/world/schema.ts index cc0e593..0dc57d3 100644 --- a/src/world/schema.ts +++ b/src/world/schema.ts @@ -32,6 +32,13 @@ export type Geometry = | { type: 'point'; theta: number; z: number } | { type: 'polyline'; points: [number, number][] } | { type: 'polygon'; points: [number, number][] } + /** + * A slice of the whole circumference between two z. Not expressible as a + * polygon: theta 0 and theta 2pi normalise to the same map row, so a band + * written as four corners collapses into a hairline. Tower's desert and forest + * both ring the world, so this is a first-class shape here, not an edge case. + */ + | { type: 'band'; zFrom: number; zTo: number } export interface Place { id: string @@ -109,11 +116,17 @@ export function parseWorld(raw: unknown): World { } function parseGeometry(g: unknown): Geometry | null { - const src = g as { type?: string; theta?: number; z?: number; points?: unknown } + const src = g as { + type?: string; theta?: number; z?: number; points?: unknown + zFrom?: number; zTo?: number + } if (!src) return null if (src.type === 'point' && Number.isFinite(src.theta) && Number.isFinite(src.z)) { return { type: 'point', theta: src.theta!, z: src.z! } } + if (src.type === 'band' && Number.isFinite(src.zFrom) && Number.isFinite(src.zTo)) { + return { type: 'band', zFrom: src.zFrom!, zTo: src.zTo! } + } if ((src.type === 'polygon' || src.type === 'polyline') && Array.isArray(src.points)) { const points = src.points .filter((pt): pt is [number, number] => diff --git a/static/world.json b/static/world.json index 93d62e2..9ee8dc0 100644 --- a/static/world.json +++ b/static/world.json @@ -6,25 +6,9 @@ "name": "The Sunward Sea", "kind": "lake", "geometry": { - "type": "polygon", - "points": [ - [ - 0, - 0 - ], - [ - 6.283185306179586, - 0 - ], - [ - 6.283185306179586, - 42000 - ], - [ - 0, - 42000 - ] - ] + "type": "band", + "zFrom": 0, + "zTo": 42000 }, "blurb": "" }, @@ -33,25 +17,9 @@ "name": "The Deepward Sea", "kind": "lake", "geometry": { - "type": "polygon", - "points": [ - [ - 0, - 496000 - ], - [ - 6.283185306179586, - 496000 - ], - [ - 6.283185306179586, - 540000 - ], - [ - 0, - 540000 - ] - ] + "type": "band", + "zFrom": 496000, + "zTo": 540000 }, "blurb": "" }, @@ -660,25 +628,9 @@ "name": "The Dry Reach", "kind": "desert", "geometry": { - "type": "polygon", - "points": [ - [ - 0, - 148000 - ], - [ - 6.283185306179586, - 148000 - ], - [ - 6.283185306179586, - 218000 - ], - [ - 0, - 218000 - ] - ] + "type": "band", + "zFrom": 148000, + "zTo": 218000 }, "blurb": "" }, @@ -950,25 +902,9 @@ "name": "Greatwood", "kind": "forest", "geometry": { - "type": "polygon", - "points": [ - [ - 0, - 214000 - ], - [ - 6.283185306179586, - 214000 - ], - [ - 6.283185306179586, - 430000 - ], - [ - 0, - 430000 - ] - ] + "type": "band", + "zFrom": 214000, + "zTo": 430000 }, "blurb": "" }, diff --git a/test/world.test.ts b/test/world.test.ts index 0becafc..69963fc 100644 --- a/test/world.test.ts +++ b/test/world.test.ts @@ -80,6 +80,22 @@ describe('geometry kinds', () => { { name: 'poly', geometry: { type: 'polygon', points: two } }, ] }).places.map((p) => p.name)).toEqual(['line']) }) + it('keeps a band as a band, which a polygon cannot express', () => { + // theta 0 and theta 2pi normalise to the same map row, so four corners + // collapse to a hairline. Tower's desert and forest both ring the world. + const w = parseWorld({ places: [ + { id: 'd', name: 'D', kind: 'desert', + geometry: { type: 'band', zFrom: 148000, zTo: 218000 } }] }) + expect(w.places[0]!.geometry).toEqual({ type: 'band', zFrom: 148000, zTo: 218000 }) + expect(toPublicWorld(w).places[0]!.geometry).toEqual( + { type: 'band', zFrom: 148000, zTo: 218000 }) + }) + + it('rejects a band with unusable bounds', () => { + expect(parseWorld({ places: [ + { name: 'x', geometry: { type: 'band', zFrom: NaN, zTo: 1 } }] }).places).toEqual([]) + }) + it('round-trips a polyline through the public export', () => { const pts: [number, number][] = [[0.1, 1000], [0.2, 2000], [0.3, 3000]] const w = parseWorld({ places: [ @@ -98,7 +114,7 @@ describe('the seeded world loads without losing anything', () => { it('knows every kind and geometry the tool uses', () => { for (const p of seed.places) { expect(PLACE_KINDS).toContain(p.kind) - expect(['point', 'polyline', 'polygon']).toContain(p.geometry.type) + expect(['point', 'polyline', 'polygon', 'band']).toContain(p.geometry.type) } }) it('keeps the river and the canyon, which are polylines', () => { @@ -106,6 +122,13 @@ describe('the seeded world loads without losing anything', () => { expect(names).toContain('The Long Water') expect(names).toContain('The Narrows') }) + + it('keeps the ringing regions as bands', () => { + const byName = new Map(parseWorld(seed).places.map((p) => [p.name, p])) + for (const n of ['The Dry Reach', 'Greatwood', 'The Sunward Sea', 'The Deepward Sea']) { + expect(byName.get(n)?.geometry.type).toBe('band') + } + }) }) describe('the public export is the privacy boundary', () => { diff --git a/tools/terrain.mjs b/tools/terrain.mjs index 267a93b..01a425b 100644 --- a/tools/terrain.mjs +++ b/tools/terrain.mjs @@ -638,12 +638,9 @@ function toPlaceGeometry(g) { return { type: 'polygon', points: [ [g.thetaFrom, g.zFrom], [g.thetaTo, g.zFrom], [g.thetaTo, g.zTo], [g.thetaFrom, g.zTo]] } } - // A band rings the world, so as a place it is a polygon spanning all of theta. - if (g.type === 'band') { - const T = 2 * Math.PI - 1e-9 - return { type: 'polygon', points: [ - [0, g.zFrom], [T, g.zFrom], [T, g.zTo], [0, g.zTo]] } - } + // A band rings the world. As a polygon it would collapse — theta 0 and theta 2pi + // land on the same map row — so it stays its own shape all the way through. + if (g.type === 'band') return { type: 'band', zFrom: g.zFrom, zTo: g.zTo } throw new Error(`cannot convert geometry ${g.type}`) }