diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..ebcf79b --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/kartios/repos/gitea/tera/node_modules \ No newline at end of file diff --git a/src/cities/unify.ts b/src/cities/unify.ts index 9fd6834..35e3ac2 100644 --- a/src/cities/unify.ts +++ b/src/cities/unify.ts @@ -179,30 +179,69 @@ export function unifiedCalifornia(): { city: City; report: UnifyReport } { ); /* - * ---- Chapters: the state's, and only the state's ------------------------- + * ---- Chapters: the state's, and both metros' ----------------------------- * - * **Tried, photographed, reverted.** Merging all twenty-four rungs onto this - * board is arithmetically easy — `focus.lat/lng` is absolute, `distance` is a - * horizontal length in the owning board's units and `height` has already been - * through that board's exaggeration, so each converts by keeping its true - * metres. That was done, and the camera arrives exactly where it should. + * **Tried, photographed, reverted — and now restored, because the thing that + * was wrong with it was fixed one commit later.** * - * The frame it arrives at is the problem, and it is a fact about the board - * rather than about the conversion. Flying to FiDi puts the camera about two - * scene units from its target on a board that is 551 units across, and what - * is at that range is *state-scale content*: `createFreewayWorld` draws the - * corridor as a deliberate 4.7 km-wide atlas glyph — `main.ts` says so where - * it sets the corridor altitude, because DRIVE mode has to be able to drive - * down it on a board where a real freeway is a fifth of a pixel — and the - * terrain under it is 300 m cells. The delivered picture is a black slab - * across San Francisco. + * The revert was recorded here at `64618cb` and its reason was specific: at + * FiDi's stand-off the frame filled with *state-scale content*, because + * `createFreewayWorld` draws the corridor as a deliberate 4.7 km-wide atlas + * glyph and a 4.7 km ribbon lying across a 12 km peninsula is a black slab. + * That was a true report of the board as it stood. * - * So a rung that flies you into that is worse than not having the rung. The - * metro chapters come back when the corridor is drawn at true width on this - * board, which is `createFreewayWorld`'s hard-coded scene units and the - * `roads` reconciliation rule between them — not this file. + * `23e4bb8` — the next commit — added `overviewLayers` to `engine/scene.ts`, + * which **withdraws the corridor glyph** once the camera comes inside + * `DETAIL_STANDOFF_M`, for exactly this reason and naming exactly this + * photograph. The blocker was removed and nobody came back for the rungs it + * was blocking, so the board kept six chapters and both metros stayed + * unreachable on the one board built to hold them. + * + * What the revert note also said is still true and is *not* a blocker: the + * terrain under a metro here is 300 m cells against that metro's own 45 m. + * That is a quality gap, it is named in `UNIFIED_FINE_METRES`, and its fix is + * terrain level of detail. A rung that arrives at coarse ground is a board + * that needs finer ground; it is not a reason to have no rung. + * + * ## The conversion + * + * `focus.lat/lng` is absolute and carries over untouched. The other two are + * lengths in the owning board's scene units and have to be restated in this + * one's: + * + * - **`distance` is horizontal**, so it keeps its true metres: + * `d x (mpu_src / mpu_here)`, and `mpu` is `111320 / latScale`, so the + * whole factor is `latScale_here / latScale_src`. + * - **`height` is vertical, and vertical is exaggerated per pack.** It has + * already been through the source board's `verticalExaggeration`, and + * this board draws its relief at a different one — 15x against San + * Francisco's 3.6. Converting on true metres alone would put the camera + * at the right altitude over the wrong-looking hills and flatten every + * authored angle. It carries the exaggeration ratio too, which keeps the + * camera at the same height *relative to the terrain it is looking at* — + * which is what makes the framing survive the move. + * + * Each metro's own whole-board chapter is dropped: `california-overview` + * already is the whole board, and two rungs that frame the same thing at the + * same range is a ladder with a repeated step. */ - const mergedChapters = state.chapters; + const mergedChapters: City["chapters"] = [ + ...state.chapters, + ...metros.flatMap((m) => { + const horizontal = state.latScale / m.latScale; + const vertical = horizontal * (state.verticalExaggeration / m.verticalExaggeration); + return m.chapters + .filter((chapter) => chapter.id !== "all") + .map((chapter) => ({ + ...chapter, + focus: { + ...chapter.focus, + distance: chapter.focus.distance * horizontal, + height: chapter.focus.height * vertical, + }, + })); + }), + ]; const fine = UNIFIED_FINE_METRES / 111_320; diff --git a/src/engine/scene.ts b/src/engine/scene.ts index d37724d..b64501e 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -761,7 +761,39 @@ export async function createScene( Math.hypot(eastX, southZ), ); - const orbitMinDistance = Math.max(4, boardSpan * 0.02); + /** + * Does this board carry metro detail? Asked here, off the pack, rather than + * read from `hasDetail` below, because the orbit floor is a constructor + * argument and `hasDetail` is not known until the blocks are built. + */ + const carriesDetail = city.districts.some((d) => d.detail === true); + + /** + * The closest the camera may stand to what it is looking at, in true metres. + * + * **1,900 m, and it is the Bay Area board's own floor rather than a new + * number.** `boardSpan * 0.02` is 20.1 units there and 94.3 m to the unit, so + * San Francisco has always let you come to 1.89 km — and that is the range + * every close photograph of this product was taken at. + * + * On the merged board the *same rule* is 11.1 units of 1,919 m, which is + * **21.3 km**: eleven times further out, on the one board that carries both + * metros' districts, landmarks and bridges. The rule is a fraction of board + * size, and board size is exactly the thing `cities/unify.ts` changed without + * changing what is standing on it. So the state tier could never resolve a + * city no matter what the level of detail did — not because the lots are + * coarse, but because the camera is not allowed to arrive. + * + * A fraction of the board is still right for a board with nothing close to + * look at: California with no cities on it is an atlas, 21 km is the scale it + * reads at, and dropping its floor would only let a visitor push their face + * into a 3,473 m terrain cell. Hence the two cases, keyed on the one fact + * that distinguishes them. + */ + const ORBIT_MIN_STANDOFF_M = 1_900; + const orbitMinDistance = carriesDetail + ? ORBIT_MIN_STANDOFF_M / world.metresPerUnit + : Math.max(4, boardSpan * 0.02); /** * The far end of the orbit, held rather than written twice. * diff --git a/src/main.ts b/src/main.ts index 41f934d..65ea711 100644 --- a/src/main.ts +++ b/src/main.ts @@ -239,11 +239,36 @@ import type { PresenceIndicator } from "./realtime/presenceIndicator.ts"; */ const ONE_CALIFORNIA = new URLSearchParams(location.search).get("one") === "1"; -const CITIES: { id: string; label: string; city: City }[] = [ - { id: "california", label: "California", city: ONE_CALIFORNIA ? unifiedCalifornia().city : CALIFORNIA }, - { id: "sf", label: "Bay Area", city: SAN_FRANCISCO }, - { id: "socal", label: "SoCal", city: SOCAL }, -]; +/** + * The boards on offer — three, or **one**. + * + * With `?one=1` this is a single entry, and that is the whole of what makes the + * merged board a *board* rather than a nicer statewide tier on a three-board + * product. Everything downstream that decides "is this somewhere else" reads + * this array: + * + * - `LADDER` is built from it, so every rung's `board` is `california` and + * `goToPlace`'s `rung.board !== cityId` switch never fires. Clicking FiDi + * moves the camera instead of tearing the board down and building another. + * - `PREFETCH_TIERS` and the handover rule get one tier, so there is no + * handover: nothing to promote to, nothing to demote from, and no cut to + * conceal with a fog dip. + * - The office return links and `?city=` fall back to the one board that + * exists rather than to a board the visitor was never on. + * + * The metro packs are still imported and still merged — `unifiedCalifornia()` + * reads both — they simply stop being *destinations*. Without the flag nothing + * here moves, which is what keeps the twenty-nine capture guards and every cell + * of `scripts/performance-budget.mjs` aiming at the boards they were written + * against. + */ +const CITIES: { id: string; label: string; city: City }[] = ONE_CALIFORNIA + ? [{ id: "california", label: "California", city: unifiedCalifornia().city }] + : [ + { id: "california", label: "California", city: CALIFORNIA }, + { id: "sf", label: "Bay Area", city: SAN_FRANCISCO }, + { id: "socal", label: "SoCal", city: SOCAL }, + ]; /** * The board a bare URL lands on. **The detailed one, not the state tier.** @@ -277,7 +302,14 @@ const CITIES: { id: string; label: string; city: City }[] = [ * `?city=california` still goes straight to the state board, and nothing about * the deep link changed. */ -const DEFAULT_CITY_ID = "sf"; +/* + * With one board there is nothing to choose: the argument above is entirely + * about which of three products a bare URL lands on, and `?one=1` is the answer + * that there are not three. The landing *pose* is a separate question and still + * belongs to the chapter list — `california-overview` frames the state, and the + * metro rungs restored in `cities/unify.ts` are how you get down from it. + */ +const DEFAULT_CITY_ID = ONE_CALIFORNIA ? "california" : "sf"; /** The corridor's scale doors, and the office each detailed board arrives near. */ const CALIFORNIA_DESTINATIONS = new Map([ @@ -5210,7 +5242,25 @@ function flyToChapter(viewId: string) { renderChrome(); } else { - const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined; + /* + * A door is a door only while there is somewhere else to go. + * + * `los-angeles` and `san-francisco` carry real authored poses — 38 units at + * 26 of height, which is a 73 km stand-off over the city — and on the three + * -board product those poses are never used, because this line intercepts + * the click and switches boards instead. On the merged board both cities + * are *on this board*, so the interception is what stops you looking at + * them: it is the reason a visitor who clicks SF ends up somewhere the + * merged board is not. + * + * Left alone, the fall-through below flies the authored pose, and the + * offices stay reachable the way they already are on every other board — + * the amber pins, which `scene.ts` gates on the board's own bounds and the + * merged board's bounds contain both. + */ + const destination = cityId === "california" && !ONE_CALIFORNIA + ? CALIFORNIA_DESTINATIONS.get(view.id) + : undefined; if (destination) { requestControlMode("overview"); officeId = destination.officeId; diff --git a/src/test/unify.test.ts b/src/test/unify.test.ts index 0dc6cc3..66ca8d3 100644 --- a/src/test/unify.test.ts +++ b/src/test/unify.test.ts @@ -18,6 +18,7 @@ import { unifiedCalifornia, } from "../cities/unify.ts"; import CALIFORNIA from "../cities/california.ts"; +import { reconciledCity } from "../cities/reconcile.ts"; import SAN_FRANCISCO from "../cities/sf.ts"; import SOCAL from "../cities/socal.ts"; @@ -129,19 +130,99 @@ describe("one California", () => { }); describe("one California's chapters", () => { - it("is the state's list, until the corridor is drawn at true width", () => { - /* - * The metro rungs were merged, photographed and reverted; `unify.ts` records - * why in full. The short version is that the conversion was right and the - * frame was not — a two-unit stand-off on a 551-unit board lands inside the - * corridor's 4.7 km-wide atlas glyph. This test exists so that the day - * somebody merges them again, they do it deliberately. - */ + /* + * The metro rungs were merged, photographed and reverted at `64618cb`, and + * this test used to assert the revert so that whoever merged them again did it + * deliberately. This is that deliberate merge, and the reason is in `unify.ts` + * in full: the revert's stated blocker was the corridor's 4.7 km atlas glyph + * filling every close frame, and `23e4bb8` — the next commit — withdraws that + * glyph inside `DETAIL_STANDOFF_M`. The guard is kept and turned around: it + * now asserts the conversion, which is the part a careless edit breaks + * silently. + */ + it("carries the state's rungs first, in their authored order", () => { + // `createScene` opens on `chapters[0]` and the capture harness reaches the + // state's chapters by index, so the state's list has to stay a prefix. assert.deepEqual( - city.chapters.map((c) => c.id), + city.chapters.slice(0, CALIFORNIA.chapters.length).map((c) => c.id), CALIFORNIA.chapters.map((c) => c.id), ); }); + + it("adds both metros' rungs, without their whole-board duplicates", () => { + const ids = city.chapters.map((c) => c.id); + for (const metro of [SAN_FRANCISCO, SOCAL]) { + for (const chapter of metro.chapters) { + if (chapter.id === "all") { + // `california-overview` already frames the whole board; a second rung + // at the same range is a repeated step on the ladder. + assert.ok(!ids.includes(chapter.id), `whole-board rung "${chapter.id}" was merged`); + continue; + } + assert.ok(ids.includes(chapter.id), `metro rung "${chapter.id}" is missing`); + } + } + // Nothing merged twice, which a naive concat of two packs sharing an id + // would do without failing anything else. + assert.equal(new Set(ids).size, ids.length); + }); + + it("restates each merged rung in this board's units, keeping its true metres", () => { + /* + * `distance` is horizontal and converts on `latScale` alone. `height` is + * vertical, has already been through the source pack's exaggeration, and + * has to carry the ratio of exaggerations as well — otherwise the camera + * arrives at the right altitude over relief drawn 4.2x taller and every + * authored angle flattens. Both are checked against the arithmetic rather + * than against a recorded number, so a pack edit cannot falsify them. + */ + /* + * Reconciled on both sides, because `unifiedCalifornia` merges reconciled + * packs and `reconciledCity` is **not** the identity: the relief rule + * rewrites `verticalExaggeration`, which takes San Francisco from the 3.6 + * its file authors to 5.78. Checking the arithmetic against the raw packs + * measures a board nobody is looking at — and it fails, which is the only + * reason this is written down rather than assumed. + */ + const here = reconciledCity(CALIFORNIA); + for (const raw of [SAN_FRANCISCO, SOCAL]) { + const metro = reconciledCity(raw); + const horizontal = here.latScale / metro.latScale; + const vertical = + horizontal * (here.verticalExaggeration / metro.verticalExaggeration); + for (const source of metro.chapters) { + if (source.id === "all") continue; + const merged = city.chapters.find((c) => c.id === source.id); + assert.ok(merged, `metro rung "${source.id}" is missing`); + // The place itself is absolute and must not have moved at all. + assert.equal(merged.focus.lat, source.focus.lat); + assert.equal(merged.focus.lng, source.focus.lng); + assert.ok( + Math.abs(merged.focus.distance - source.focus.distance * horizontal) < 1e-9, + `"${source.id}" stand-off was not restated in this board's units`, + ); + assert.ok( + Math.abs(merged.focus.height - source.focus.height * vertical) < 1e-9, + `"${source.id}" height ignored the exaggeration ratio`, + ); + } + } + }); + + it("puts every merged rung inside the board it now belongs to", () => { + // A rung whose focus is off the bounds is a camera aimed at empty sea, and + // the merge is the one operation that can produce one. + for (const chapter of city.chapters) { + assert.ok( + chapter.focus.lat >= city.bounds.minLat && chapter.focus.lat <= city.bounds.maxLat, + `chapter "${chapter.id}" points off the board in latitude`, + ); + assert.ok( + chapter.focus.lng >= city.bounds.minLng && chapter.focus.lng <= city.bounds.maxLng, + `chapter "${chapter.id}" points off the board in longitude`, + ); + } + }); }); describe("one California's cities are lotted like cities", () => {