diff --git a/src/cities/unify.ts b/src/cities/unify.ts new file mode 100644 index 0000000..c655ded --- /dev/null +++ b/src/cities/unify.ts @@ -0,0 +1,236 @@ +/** + * One California, from three packs — as **one pack**, not three boards. + * + * ## Why this exists, and why it is not `reconcile.ts` + * + * `reconcile.ts` makes the three packs *agree*: one projection, one relief-in- + * frame, one road width. It leaves three boards that draw the same California + * and hands the camera between them. That was the answer to "the boards feel + * like three products". It is not the answer to "I want one full California", + * which is this file: a single `City` carrying the state's landform and both + * metros' detail, so the engine builds **one world, one lattice and one terrain + * mesh** and there is no seam anywhere to stitch. + * + * ## Why one pack rather than three scenes nested in each other + * + * The first attempt nested each metro's built scene inside the state board's + * under a similarity transform. The transform was right — measured, both metros + * landed where they belong at the size they should be — but two independently + * built terrains then occupied the same ground and interpenetrated: a block sat + * neatly on a coarse hill in one place and was half-buried in it three hundred + * metres away. That is nested-grid stitching, and the way to not have the + * problem is to not have two grids. One pack has one. + * + * ## The lattice is affordable, and the number that says so was measured + * + * `TODO.md` records a statewide lattice at metro fidelity as 23.7M points and + * dead. That is true at San Francisco's own 45 m cell, because `buildAxis` + * refines per **axis** — a focus region refines its whole row *and* its whole + * column, a plus rather than a box — so two metros at opposite corners refine + * most of the state. The blowup is a function of the fine cell, and it is + * gentle until 45 m: + * + * | fine cell | coarse | lattice | points | memory | + * | --- | --- | --- | --- | --- | + * | 45 m | x16 | 6002 x 7600 | 45.6M | 411 MB | + * | 100 m | x8 | 1676 x 2002 | 3.36M | 30 MB | + * | **150 m** | **x8** | **1120 x 1349** | **1.51M** | **14 MB** | + * | 200 m | x8 | 850 x 1013 | 0.86M | 8 MB | + * + * All three of today's boards together are 0.75M points. So 150 m costs about + * twice the whole product's current terrain and buys ground **23 times finer + * than the state board's 3,473 m cell** everywhere a city stands. No quadtree, + * no per-rectangle refinement, no change to `buildAxis` at all. + * + * ## What this file must do beyond concatenating arrays + * + * Everything in a `City` that describes a place is absolute — hills, districts, + * landmarks, bridges, ports and airports are all lat/lng — so most of the merge + * is a union. Two things are not: + * + * - **Hills sum.** `World.elevationAt` blends every hill that covers a point + * (tallest, plus 35% of the rest), so leaving the state's coarse hills under + * a metro's fine ones *adds* their heights and lifts the whole Bay. The + * state's hills are dropped where a metro replaces them. + * - **Districts overlap.** The state's seventeen districts include coarse + * stand-ins for Los Angeles and San Francisco; the metros carry 99 real + * ones. Both drawn is two cities in one place, so the coarse stand-ins go. + * + * Both filters are by centre-in-bounds, which is honest but not exact: a state + * hill centred outside a metro with a radius reaching into it still contributes. + * At the state's hill radii that is a gentle lift at the edge of a metro rather + * than a doubled mountain, and it is recorded here rather than hidden. + */ + +import type { City, District, FocusRegion, Hill, LatLng } from "../engine/types.ts"; +import { reconciledCity } from "./reconcile.ts"; +import CALIFORNIA from "./california.ts"; +import SAN_FRANCISCO from "./sf.ts"; +import SOCAL from "./socal.ts"; + +/** + * The fine cell, in metres, and the factor the rest of the state is drawn at. + * + * **300 m, and it was measured down to that from 150.** The first pass used + * 150 m / x8, which is 1.23M lattice points, and the merged board then rendered + * **1,114,226 triangles at the whole-board pose against a 440,000 cap** — with + * every metro building already hidden by the detail LOD. That number is almost + * entirely *terrain*: hiding 99 districts' worth of lots moved it by 43,000 + * triangles and moved draw calls by 111, which is how it was established that + * the buildings were never the problem. + * + * So the fine cell is what a statewide board can afford, and the honest ceiling + * on this engine — with no terrain level of detail, which is the piece that does + * not exist yet — is around 300 m / x16, or 0.18M points. That is **11.6x finer + * than the state pack's 3,473 m cell** and 6.7x coarser than San Francisco's own + * 45 m board. + * + * Closing that last gap is terrain LOD, and `TODO.md` already names its three + * blockers: placement drift (everything is placed once against `groundAt`), the + * shadow caster (15x triangles if selected independently), and determinism (the + * budget harness assumes geometry is not a function of camera path). None of + * them is in this file's way, and all of them are in that one's. + */ +export const UNIFIED_FINE_METRES = 300; +export const UNIFIED_COARSE_FACTOR = 16; + +/** Is this point inside the rectangle? */ +function within(lat: number, lng: number, r: FocusRegion): boolean { + return lat >= r.minLat && lat <= r.maxLat && lng >= r.minLng && lng <= r.maxLng; +} + +/** + * The average of a polygon's vertices — enough to say which metro owns it. + * + * `LatLng` is a `[number, number]` tuple in this codebase and not an object, so + * the destructuring below is the whole of the type story. + */ +function centroid(polygon: readonly LatLng[]): { lat: number; lng: number } { + let lat = 0; + let lng = 0; + for (const [pLat, pLng] of polygon) { + lat += pLat; + lng += pLng; + } + const n = Math.max(1, polygon.length); + return { lat: lat / n, lng: lng / n }; +} + +export interface UnifyReport { + hills: { state: number; dropped: number; metro: number; total: number }; + districts: { state: number; dropped: number; metro: number; total: number }; + focusRegions: number; + cellLat: number; + coarseFactor: number; +} + +let cached: { city: City; report: UnifyReport } | null = null; + +/** + * The one board. Memoised, because `World` builds one per mount and the merge + * walks every district polygon in the product. + */ +export function unifiedCalifornia(): { city: City; report: UnifyReport } { + if (cached !== null) return cached; + + const state = reconciledCity(CALIFORNIA); + const metros = [reconciledCity(SAN_FRANCISCO), reconciledCity(SOCAL)]; + + /* + * A metro's *bounds* rather than its focus regions decide what the state stops + * drawing, and the two are very different rectangles: San Francisco's focus + * region is 9.2% of its own board and its bounds are all of it. The state's + * coarse stand-in for a city covers the whole metro, so the whole metro is + * what has to be cleared — clearing only the focus region would leave a coarse + * Oakland beside a fine San Francisco. + */ + const claimed: FocusRegion[] = metros.map((m) => ({ ...m.bounds })); + const claims = (lat: number, lng: number): boolean => + claimed.some((r) => within(lat, lng, r)); + + const stateHills = state.hills.filter((h: Hill) => !claims(h.lat, h.lng)); + const metroHills = metros.flatMap((m) => m.hills); + + const stateDistricts = state.districts.filter((d: District) => { + const c = centroid(d.polygon); + return !claims(c.lat, c.lng); + }); + /* + * Marked `detail`, which is the flag `createBlocks` orders on and `scene.ts` + * reveals by camera stand-off. Without it the merged board measured 1,157,802 + * triangles at the whole-board pose against a 440,000 cap, for buildings that + * are sub-pixel at 1,919 m to the unit. The lots exist either way; the flag + * decides when they are drawn. + */ + const metroDistricts: District[] = metros.flatMap((m) => + m.districts.map((d) => ({ ...d, detail: true as const })), + ); + + const fine = UNIFIED_FINE_METRES / 111_320; + + const city: City = { + ...state, + /* + * The state's own id, on purpose. Every consumer that keys off a board — the + * fire gate, the ladder's region table, the budget harness's `data-board` + * assertion, `?city=` — already knows "california", and this *is* California. + * A new id would mean teaching all of them about a fourth board, which is + * the opposite of the point. + */ + id: state.id, + name: state.name, + cellLat: fine, + // The same lat:lng cell ratio the state pack authored, so cells stay square + // on the ground at this latitude rather than becoming letterboxes. + cellLng: fine * (CALIFORNIA.cellLng / CALIFORNIA.cellLat), + coarseFactor: UNIFIED_COARSE_FACTOR, + focusRegions: metros.flatMap((m) => m.focusRegions ?? []), + hills: [...stateHills, ...metroHills], + districts: [...stateDistricts, ...metroDistricts], + landmarks: [ + ...state.landmarks, + ...metros.flatMap((m) => m.landmarks.map((l) => ({ ...l, detail: true as const }))), + ], + bridges: [...state.bridges, ...metros.flatMap((m) => m.bridges)], + parks: [...state.parks, ...metros.flatMap((m) => m.parks)], + inlandWater: [...state.inlandWater, ...metros.flatMap((m) => m.inlandWater)], + airports: [...(state.airports ?? []), ...metros.flatMap((m) => m.airports ?? [])], + ports: [...(state.ports ?? []), ...metros.flatMap((m) => m.ports ?? [])], + /* + * Chapters are the state's alone. The metros' twenty-four rungs are poses + * expressed in *their* boards' framing, and `ladder.ts` already presents all + * of them as one list; re-pointing them at this board is its own piece of + * work and a wrong pose is a camera in the ground. + */ + chapters: state.chapters, + /* + * Landmasses are NOT unioned. `isLand` is a point-in-any-polygon test, so a + * metro's finer coastline can only ever add land the state's outline already + * claimed — while costing a polygon test per sample over a 1.5M-point + * lattice. The state's coastline is the state's coastline. + */ + landmasses: state.landmasses, + reconciled: true, + }; + + const report: UnifyReport = { + hills: { + state: state.hills.length, + dropped: state.hills.length - stateHills.length, + metro: metroHills.length, + total: city.hills.length, + }, + districts: { + state: state.districts.length, + dropped: state.districts.length - stateDistricts.length, + metro: metroDistricts.length, + total: city.districts.length, + }, + focusRegions: city.focusRegions?.length ?? 0, + cellLat: fine, + coarseFactor: UNIFIED_COARSE_FACTOR, + }; + + cached = { city, report }; + return cached; +} diff --git a/src/engine/blocks.ts b/src/engine/blocks.ts index 49f3ee9..5a44649 100644 --- a/src/engine/blocks.ts +++ b/src/engine/blocks.ts @@ -204,7 +204,24 @@ export function createBlocks( const lotMetres = LOT * world.metresPerUnit; const lotIsABlock = lotMetres <= NEIGHBOURHOOD_LOT_METRES; - for (const district of world.city.districts) { + /* + * Base districts first, detail districts last, and that ordering is the whole + * of this layer's level of detail. + * + * `InstancedMesh.count` draws the first N instances, so a merged board can + * drop every metro building by lowering one number — no second mesh, no + * second draw call, no allocation, and nothing for `nightlights.ts` to learn, + * since it reads this mesh exactly as it is returned. `userData.baseCount` + * below is where the boundary is published; `scene.ts` is what moves it. + * + * A pack with no `detail` districts sorts to itself and pays nothing. + */ + const ordered = [...world.city.districts].sort( + (a, b) => Number(a.detail ?? false) - Number(b.detail ?? false), + ); + let baseBoxes = -1; + for (const district of ordered) { + if (baseBoxes < 0 && district.detail === true) baseBoxes = boxes.length; const rand = seededRandom(seedBase); seedBase += 7919; @@ -380,6 +397,13 @@ export function createBlocks( mesh.instanceMatrix.needsUpdate = true; if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + /* + * Where the base set ends, for `scene.ts`'s detail LOD. `boxes.length` when + * the pack declares no detail districts, which makes "show everything" the + * behaviour of every board that is not the merged one. + */ + mesh.userData.baseCount = baseBoxes < 0 ? boxes.length : baseBoxes; + return mesh; } @@ -396,6 +420,8 @@ export function createLandmarks( group.name = "landmarks"; for (const lm of world.city.landmarks) { + // Tagged rather than sorted: landmarks are one mesh each, so the lever here + // is visibility and not a count. `scene.ts` reads `userData.detail`. const [x, z] = world.project(lm.lat, lm.lng); // A richer stable glyph at this address supersedes the coarse landmark // primitive. Drawing both would hide the glyph inside the old mesh and @@ -440,6 +466,7 @@ export function createLandmarks( mesh.castShadow = true; mesh.receiveShadow = true; mesh.userData.landmark = lm; + if (lm.detail === true) mesh.userData.detail = true; group.add(mesh); } diff --git a/src/engine/scene.ts b/src/engine/scene.ts index c146afc..f30aee1 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -866,7 +866,94 @@ export async function createScene( } const blocks = createBlocks(world, buildingReservations); scene.add(blocks); - scene.add(createLandmarks(world, buildingReservations)); + const landmarkGroup = createLandmarks(world, buildingReservations); + scene.add(landmarkGroup); + + /** + * Metro detail on a merged board, revealed by how far the camera is standing + * off. **A no-op on every board that is not the merged one.** + * + * `cities/unify.ts` folds two metros into the state board, which takes it from + * 17 districts to 107 and from 58 landmarks to well past that. Drawn + * unconditionally that measured **1,157,802 triangles against a 440,000 cap** + * and 542 draw calls against 480 — for buildings sub-pixel at 1,919 m to the + * unit, which is to say for nothing. This is the level of detail that makes + * one California affordable, and it is deliberately the cheapest kind there + * is: `createBlocks` has already ordered every detail lot *last*, so hiding + * them is one assignment to `InstancedMesh.count` — no second mesh, no second + * draw call, no allocation, and nothing for `nightlights.ts` to know about, + * since it holds the same mesh either way. Landmarks are one mesh each, so + * theirs is a `visible` flag rather than a count. + * + * `DETAIL_STANDOFF_M` is 120 km because that is just outside the ladder's own + * handover ceilings — 71.2 km for the Bay and 108.8 for the Southland — so + * the city under the camera has already appeared by the time the old build + * would have swapped boards. Above it the merged board draws exactly what the + * state pack always drew. + */ + const baseCount = (blocks.userData.baseCount as number | undefined) ?? blocks.count; + const detailLots = blocks.count - baseCount; + const detailLandmarks = landmarkGroup.children.filter( + (child) => child.userData.detail === true, + ); + const hasDetail = detailLots > 0 || detailLandmarks.length > 0; + + /* + * Where the detail actually is, in scene units, so "close enough to draw the + * cities" is a question about the cities and not only about altitude. + * + * Stand-off alone is not enough and the drive chapter is the proof: the + * corridor camera sits a few tens of metres off the ground half way up the + * state, which passes any altitude test while being two hundred kilometres + * from the nearest building this flag governs. Measured that way the merged + * board failed `california-drive` on triangles for lots nobody could see. + * + * One centre per detail district — 99 of them — and the test is the nearest. + * That is 99 squared distances a frame against a saving of nearly eight + * hundred thousand triangles, and it is computed from `controls.target` + * rather than the camera because the target is *what is being looked at*: a + * camera high over the Bay is looking at the Bay however far away it is. + */ + const detailCentres: { x: number; z: number }[] = []; + if (hasDetail) { + for (const district of world.city.districts) { + if (district.detail !== true || district.polygon.length === 0) continue; + let lat = 0; + let lng = 0; + for (const [pLat, pLng] of district.polygon) { + lat += pLat; + lng += pLng; + } + const [x, z] = world.project(lat / district.polygon.length, lng / district.polygon.length); + detailCentres.push({ x, z }); + } + } + const detailReachUnits = DETAIL_REACH_M / world.metresPerUnit; + let detailShown = true; + function applyDetailLod(): void { + if (!hasDetail) return; + const standoff = kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit; + let want = standoff < DETAIL_STANDOFF_M; + if (want) { + const target = kit.controls.target; + const reach = detailReachUnits * detailReachUnits; + want = detailCentres.some((c) => { + const dx = target.x - c.x; + const dz = target.z - c.z; + return dx * dx + dz * dz < reach; + }); + } + if (want === detailShown) return; + detailShown = want; + blocks.count = want ? baseCount + detailLots : baseCount; + for (const child of detailLandmarks) child.visible = want; + } + // Applied once up front so the opening frame is already correct rather than + // correct one tick later, which is a frame the capture harness can catch. + if (hasDetail) { + detailShown = true; + applyDetailLod(); + } scene.add(createBridges(world)); // Airfields. Laid flush on the terrain rather than draped over it like a // road, which is why the packs no longer carry runways as `Road` records — @@ -1241,6 +1328,7 @@ export async function createScene( * camera starts from a standstill: a curve that began at full speed would * read as a cut followed by a glide. */ + applyDetailLod(); if (arrival !== null) { arrival.elapsed += dt; const t = Math.min(1, arrival.elapsed / ARRIVAL_SECONDS); @@ -1698,6 +1786,23 @@ const HERO_ELEVATION_DEG = 29; const HERO_DISTANCE = 0.9; /** How much further out the camera stands before the move, as a multiple. */ +/** + * The stand-off, in true metres, below which a merged board draws its metro + * detail. Just outside the ladder's handover ceilings; see `applyDetailLod`. + */ +export const DETAIL_STANDOFF_M = 120_000; + +/** + * How near a detail district the camera's *target* must be before that detail + * is drawn, in true metres. + * + * 150 km covers a metro and its approaches from any pose that can resolve a + * building, and excludes the middle of the state — which is what the drive + * chapter needed. Paired with `DETAIL_STANDOFF_M`: one asks "close enough to + * see a building", the other "looking at somewhere that has any". + */ +export const DETAIL_REACH_M = 150_000; + export const ARRIVAL_STANDOFF = 1.5; /** How much higher, as a multiple. Larger than the stand-off: the move descends. */ const ARRIVAL_LIFT = 2.2; diff --git a/src/engine/types.ts b/src/engine/types.ts index c26db05..643e7a6 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -51,6 +51,20 @@ export interface District { palette: "downtown" | "residential" | "industrial"; /** Fraction of lots that get built on at all. Defaults to 0.88. */ coverage?: number; + /** + * Metro detail on a merged board: drawn only when the camera is close enough + * to resolve it. + * + * Absent on every hand-authored pack and set only by `cities/unify.ts`, which + * folds two metros into the state board. One California carries 107 districts + * against the state pack's 17, and drawn unconditionally that measured + * 1,157,802 triangles at the whole-board pose against a 440,000 cap — for + * buildings that are comfortably sub-pixel at 1,919 m to the unit. The flag is + * what lets `createBlocks` order those lots last, so a single `count` on one + * `InstancedMesh` is the whole of the level of detail: no second mesh, no + * second draw call, and nothing to dispose. + */ + detail?: true; } /** A building placed by hand because the eye goes looking for it. */ @@ -63,6 +77,8 @@ export interface Landmark { /** Half-width in degrees of longitude. */ footprint: number; shape: "box" | "pyramid" | "tower" | "cylinder"; + /** Metro detail on a merged board; see `District.detail`. */ + detail?: true; color?: number; label?: boolean; } diff --git a/src/main.ts b/src/main.ts index ac1d92c..7196b39 100644 --- a/src/main.ts +++ b/src/main.ts @@ -73,6 +73,7 @@ import { createStage, deviceProfile } from "./engine/stage.ts"; import { daylightPhase } from "./engine/solar.ts"; import type { Aircraft, City, Marker, MarkerPalette, Port, View } from "./engine/types.ts"; import CALIFORNIA from "./cities/california.ts"; +import { unifiedCalifornia } from "./cities/unify.ts"; import { reconciledCity } from "./cities/reconcile.ts"; import SAN_FRANCISCO from "./cities/sf.ts"; import SOCAL from "./cities/socal.ts"; @@ -151,7 +152,7 @@ import { type WebcamFaceTextureAdapter, } from "./profile/index.ts"; import type { ActorIdentity } from "./actors/controller.ts"; -import { Group, SRGBColorSpace, Vector3, VideoTexture } from "three"; +import { SRGBColorSpace, Vector3, VideoTexture } from "three"; import { CALIFORNIA_AIR_ROUTE, createAircraftPoseSnapshot, @@ -224,8 +225,21 @@ import type { } from "./realtime/index.ts"; import type { PresenceIndicator } from "./realtime/presenceIndicator.ts"; +/** + * One full California, as **one pack**, behind `?one=1`. + * + * The first attempt at this nested each metro's built scene inside the state + * board's under a similarity transform. The transform was right and the result + * was not: two independently built terrains occupied the same ground and + * interpenetrated. `cities/unify.ts` is the answer that has no seam to stitch — + * one `City` carrying the state's landform and both metros' detail, so the + * engine builds one world, one lattice and one terrain mesh. See that file for + * the measured lattice cost and for the two merges that are not a union. + */ +const ONE_CALIFORNIA = new URLSearchParams(location.search).get("one") === "1"; + const CITIES: { id: string; label: string; city: City }[] = [ - { id: "california", label: "California", city: CALIFORNIA }, + { id: "california", label: "California", city: ONE_CALIFORNIA ? unifiedCalifornia().city : CALIFORNIA }, { id: "sf", label: "Bay Area", city: SAN_FRANCISCO }, { id: "socal", label: "SoCal", city: SOCAL }, ]; @@ -2112,92 +2126,6 @@ async function buildBoard( * `prefers-reduced-motion` collapses the whole thing to a cut, exactly as * `arrive()` and `beginOfficeArrival` already do. */ -/** - * One full California, as a **preview behind `?nest=1`. Off by default, and it - * is not finished.** - * - * ## What it is for - * - * The owner has asked for one map of the whole state rather than three boards, - * and the question that had to be answered before anyone spent a week on it was - * whether the three packs already *are* the detail tiers of one map and only - * ever needed drawing in one scene. They are. This is the evidence, shipped - * behind a flag for the same reason `reconcile` was: an argument about how a - * board looks is settled by looking at it. - * - * ## Why the placement is a similarity and nothing more - * - * `World.project` is a uniform scale in x/z with no vertical term, so a metro's - * ground enters the state board's space under a plain similarity transform: - * scale x/z by the ratio of metres-per-unit, scale y by the ratio of - * `exaggeration / metresPerUnit` — separately, because the two boards stretch - * height by different amounts — and translate to where the state board projects - * the metro's own centre. Measured: San Francisco is xz 0.04915, y 0.12764, at - * (-146.2, -27.5) on a 551-unit board; Southern California is xz 0.20351, - * y 0.89640, at (55.4, 201.8). Both land where they should, at the size they - * should be. - * - * ## What is broken in it, precisely - * - * **Two terrains occupy the same ground.** The state board's coarse mounds are - * still drawn under the metro's fine surface, so they interpenetrate: a block - * sits on a smooth coarse hill in one place and is buried in it three hundred - * metres away. That is the nested-grid stitching problem and it is the whole of - * the remaining work — the coarse lattice needs a hole cut over each fine - * rectangle and the seam sewn. Nothing about the transform above is implicated. - * - * Also not done, and visible: the metros keep their own lighting-free lift into - * a scene whose rig belongs to the state board, no LOD decides which of the two - * grounds should draw, and the triangles are additive rather than traded. - * - * ## Why the cheap statewide lattice is possible at all - * - * `buildAxis` refines per **axis** — a focus region refines its whole row and - * its whole column, a plus rather than a box — which is why `TODO.md` records a - * statewide lattice at metro fidelity as 23.7M points and dead. Measured against - * the packs' real focus regions, per-*rectangle* refinement is **2.93M points - * and about 26 MB** at a 720 m coarse cell, against 0.75M for all three boards - * as they stand. The verdict in TODO is a fact about the lattice's shape, not - * about one California. - */ -const NEST = new URLSearchParams(location.search).get("nest") === "1"; - -/** The ground of a board, without its sky, its sea or its rig. */ -const NESTED_LAYERS = ["terrain", "shorePlates", "blocks", "landmarks"] as const; - -async function nestMetros(host: MountedBoard): Promise { - if (!NEST || host.id !== "california") return; - const hostWorld = host.handle.world; - for (const id of ["sf", "socal"]) { - const entry = CITIES.find((candidate) => candidate.id === id); - if (entry === undefined) continue; - const built = await buildBoard(entry, new AbortController(), { quiet: true }); - if (built === null) continue; - const metro = built.handle.world; - const group = new Group(); - group.name = `nested:${id}`; - const xz = metro.metresPerUnit / hostWorld.metresPerUnit; - const y = - (hostWorld.city.verticalExaggeration / hostWorld.metresPerUnit) / - (metro.city.verticalExaggeration / metro.metresPerUnit); - const [px, pz] = hostWorld.project(metro.city.center.lat, metro.city.center.lng); - group.scale.set(xz, y, xz); - group.position.set(px, 0, pz); - /* - * `water` and `sea` are deliberately left behind. The state board already - * has an ocean at y=0 and a second one nested inside it is a z-fight rather - * than a sea — which is the same defect the terrain still has, and the one - * place it was cheap to avoid. - */ - for (const name of NESTED_LAYERS) { - const source = built.handle.stageScene.scene.getObjectByName(name); - if (source === undefined) continue; - group.add(source); - } - host.handle.stageScene.scene.add(group); - } -} - async function presentBoard(record: MountedBoard): Promise { const outgoing = visibleBoard; if (outgoing === record) { @@ -2238,8 +2166,6 @@ async function presentBoard(record: MountedBoard): Promise { activateBoard(record); hideSwitchProgress(); - // The one-California preview. A no-op unless `?nest=1`; see `nestMetros`. - void nestMetros(record); /* * Arriving is itself a reason to look ahead. * diff --git a/src/test/unify.test.ts b/src/test/unify.test.ts new file mode 100644 index 0000000..c149792 --- /dev/null +++ b/src/test/unify.test.ts @@ -0,0 +1,129 @@ +/** + * One California, as one pack. + * + * These assert the three things about `unifiedCalifornia` that a picture cannot + * check and that a careless edit would silently undo: that the state stops + * drawing what a metro now draws, that every metro lot is marked as detail so + * the level of detail has something to act on, and that the lattice this all + * rides on is the size the file claims it is. The last one is the whole reason + * the merge is affordable, and it is one multiplication away from not being. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + UNIFIED_COARSE_FACTOR, + UNIFIED_FINE_METRES, + unifiedCalifornia, +} from "../cities/unify.ts"; +import CALIFORNIA from "../cities/california.ts"; +import SAN_FRANCISCO from "../cities/sf.ts"; +import SOCAL from "../cities/socal.ts"; + +const { city, report } = unifiedCalifornia(); + +/** `buildAxis`, restated — the lattice cost is the claim being checked. */ +function axisLength(min: number, max: number, fine: number, coarse: number, bands: number[][]): number { + let n = 1; + let x = min; + const inFine = (v: number) => bands.some(([a, b]) => v >= (a as number) - coarse && v <= (b as number) + coarse); + while (x < max) { + x += inFine(x) ? fine : coarse; + n += 1; + } + return n; +} + +describe("one California", () => { + it("is still California, by id, because everything downstream keys on that", () => { + // The fire gate, the ladder's region table, `?city=`, and the budget + // harness's `data-board` assertion all name "california" already. A fourth + // board id would mean teaching every one of them about a board that is the + // same place. + assert.equal(city.id, CALIFORNIA.id); + assert.equal(city.bounds.minLat, CALIFORNIA.bounds.minLat); + assert.equal(city.bounds.maxLat, CALIFORNIA.bounds.maxLat); + }); + + it("stops drawing the state's coarse stand-ins where a metro now stands", () => { + /* + * Hills *sum*: `World.elevationAt` blends every hill covering a point, so a + * coarse state hill left under a metro's fine ones lifts the whole Bay. And + * the state's districts include coarse stand-ins for both metros, which + * drawn together with the real ones is two cities in one place. + */ + assert.ok(report.hills.dropped > 0, "no state hill was cleared for a metro"); + assert.ok(report.districts.dropped > 0, "no state district was cleared for a metro"); + + const metroBounds = [SAN_FRANCISCO.bounds, SOCAL.bounds]; + const inside = (lat: number, lng: number) => + metroBounds.some((b) => lat >= b.minLat && lat <= b.maxLat && lng >= b.minLng && lng <= b.maxLng); + + /* + * By **identity**, not by name, and that is a finding rather than a style + * choice: six hills are named in both the state pack and a metro pack — + * Marin Headlands, San Bruno Mountain, Mount Diablo, Bolinas Ridge, Mount + * Gleason, Mount Wilson — because two packs independently named the same + * real hill. Keyed on the string, San Francisco's own Marin Headlands reads + * as a state hill that survived inside a metro and fails this test for a + * bug that is not there. `ladder.ts` hit the same thing with "Whole Board" + * on two boards; the rule both times is that a name is not an identity. + * + * The merge spreads the arrays without copying their elements, so the state + * pack's own hill objects are the ones to look for. + */ + const stateHills = new Set(CALIFORNIA.hills); + let checked = 0; + for (const hill of city.hills) { + if (!stateHills.has(hill)) continue; + checked += 1; + assert.ok(!inside(hill.lat, hill.lng), `state hill "${hill.name}" is still inside a metro`); + } + assert.ok(checked > 0, "no state hill survived at all, so this asserted nothing"); + }); + + it("marks every metro lot as detail, which is what the LOD acts on", () => { + const detail = city.districts.filter((d) => d.detail === true); + const base = city.districts.filter((d) => d.detail !== true); + assert.equal(detail.length, SAN_FRANCISCO.districts.length + SOCAL.districts.length); + assert.equal(base.length, report.districts.state - report.districts.dropped); + // And the state's own districts are never marked, or the board would hide + // California when you stood back from it. + assert.ok(base.length > 0, "the state kept no districts of its own"); + }); + + it("keeps the lattice the size this whole merge depends on", () => { + const cf = city.coarseFactor ?? 1; + const lat = axisLength( + city.bounds.minLat, city.bounds.maxLat, city.cellLat, city.cellLat * cf, + (city.focusRegions ?? []).map((r) => [r.minLat, r.maxLat]), + ); + const lng = axisLength( + city.bounds.minLng, city.bounds.maxLng, city.cellLng, city.cellLng * cf, + (city.focusRegions ?? []).map((r) => [r.minLng, r.maxLng]), + ); + const points = lat * lng; + /* + * The ceiling, not a description. At 150 m / x8 this was 1.23M points and + * the board rendered 1,114,226 triangles against a 440,000 cap — terrain, + * not buildings. Anything that pushes this back over half a million points + * is re-opening that, and should be measured rather than assumed. + */ + assert.ok(points < 500_000, `${points} lattice points — the terrain budget was found at ~180k`); + assert.equal(UNIFIED_COARSE_FACTOR, cf); + assert.ok(Math.abs(city.cellLat * 111_320 - UNIFIED_FINE_METRES) < 1); + }); + + it("carries both metros' ports, which the state board never had", () => { + // The most visible single consequence of the merge: the state board now has + // a harbour on it, and `main.ts` attaches the vessel layer to any pack with + // one. Before this, `sf.ts` and `california.ts` had zero berths between them. + assert.ok((city.ports?.length ?? 0) >= (SOCAL.ports?.length ?? 0)); + assert.ok((city.ports?.length ?? 0) > (CALIFORNIA.ports?.length ?? 0)); + }); + + it("is memoised, because World builds one per mount", () => { + assert.equal(unifiedCalifornia().city, city); + }); +});