/** * Fly the camera somewhere, like what you see, and get back the five numbers * that put it there. * * Adding a city to this repo means hand-authoring `chapters: Chapter[]`, and * every chapter carries a `focus` — `{ lat, lng, distance, height, rotation }` * — which is only knowable by flying somewhere, liking the frame, and then * working out what the numbers were. The loop that produced the twelve poses in * `cities/sf.ts` was: guess five numbers, rebuild, look, guess again. New York * is on the roadmap and it needs a dozen more of them. * * ## This is the inverse of `chapterPose`, and the round trip is measured * * `scene.ts` turns a focus into a camera with * * target = (x, groundAt(lat,lng), z) where [x,z] = project(lat,lng) * position = target + (sin(rot)·distance, height, cos(rot)·distance) * * which is invertible in one direction and *not* in the other, and the part * that is not is the whole reason this file measures itself. Going backwards: * `lat`/`lng` come from `unproject` of the orbit target's x and z, `distance` * and `rotation` are the polar form of the horizontal offset from target to * camera, and `height` is the camera's y above the ground under the target. The * camera position comes back exactly. The *target* does not, because * `chapterPose` pins target.y to the ground and OrbitControls does not: pan the * view and the target lifts off the terrain, and no chapter can express that. * The panel therefore shows the lift and the aim error it causes rather than * quietly emitting a pose that frames something else. See `measure`. * * ## The maths is duplicated from `scene.ts` on purpose, and it is a liability * * `chapterPose` is a closure inside `createScene` and is not exported, so * `poseOf` below is a copy of it. That is the one thing in this file that can * rot silently: change the pose convention in `scene.ts` and this tool will go * on confidently emitting the old one. The fix is for `scene.ts` to export the * conversion and for this file to import it; until then, the two blocks are * written to look identical so a diff between them is obvious. * * ## Precision: five decimals of degree, two of unit, five of radian * * The point of the tool is a block you can paste, so the numbers have to be * short enough to read and long enough to reproduce the frame. Measured over * every authored chapter in both packs plus eight jittered poses around each, * worst case: * * - **What the packs carry today** (4 dp of degree, integer distance and * height, 2 dp of radian, all typed by hand from a map): the camera lands * 265 m from where it was on the Bay Area board and 466 m out on SoCal, and * the view direction is 0.7°–1.2° off — about thirty pixels across a * 1600-pixel frame. Fine for a pose a human invented at those digits; * useless for reproducing one a human found by flying. * - **This scheme**: camera position within 0.0066 scene units (0.62 m) on * the Bay Area and 0.0063 units (2.44 m) on SoCal, orbit target within * 0.74 m and 0.89 m, and the aim within 0.0098° — 0.024% of a 42° frame, * which is a third of a pixel at 1600 wide. * * Going finer buys nothing anyone can see and costs a digit in a file people * read. Trailing zeros are trimmed, so a pose that happens to be round emits * `rotation: 0.4`, exactly as `sf.ts` already has it. * * **The order of the quantisation is load-bearing.** Round `lat`/`lng` *first*, * then solve `distance`, `height` and `rotation` against the ground under the * rounded point. The obvious order — take all five numbers off the live camera, * round all five — feeds the terrain's own slope into the camera: `height` is * measured from the ground under the exact target and re-applied over the * ground under the rounded one, and with `verticalExaggeration` at 3.6 a metre * of horizontal rounding on the side of Twin Peaks is several units of altitude. * Measured, that order costs 0.98 m instead of 0.62 m at these digits, and * 7.8 m instead of 3.0 m at the packs' four decimals. * * ## Shape * * Same self-contained imperative handle as `engine/minimap.ts`, and the same * two rules: the caller supplies a container and owns where it goes, and * `tick()` runs inside a frame loop so it compares a timestamp and eight * numbers and returns. Unlike the minimap it never writes to the camera at all * — the only way this tool moves anything is by handing a `Pose` to the kit's * own `flyTo`. * * God tier only. `access.ts` is clear that a browser-side check is theatre * against anyone with a console, so the gate here is not security; it is a * loaded gun pointed away from the ninety-nine percent of sessions that have no * business seeing an authoring instrument at all. */ import * as THREE from "three"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { Pose } from "../engine/scenekit.ts"; import type { Chapter } from "../engine/types.ts"; import type { World } from "../engine/world.ts"; export interface PoseEditorOptions { /** * Where the panel mounts. The tool appends exactly one element to it and * owns everything below that; the caller owns the box, its position and its * size. */ container: HTMLElement; /** * Pass `access.can.debug`. Construction throws when it is false — see the * file header for why that is a thrown programming error and not a hidden * no-op. */ allowed: boolean; /** The same `World` the scene is drawing. `groundAt` must agree, or nothing does. */ world: World; /** The live scene camera. Read every tick, written never. */ camera: THREE.PerspectiveCamera; /** The live orbit controls. `controls.target` is the pose's target. */ controls: OrbitControls; /** * The kit's flight, `SceneKit.flyTo`. The only channel through which this * tool is allowed to move the camera, and the reason it takes a callback * rather than the controls: a tool that wrote `camera.position` directly * would be a second thing with opinions about where the camera is, which is * the failure CONTRACT.md §1 splits `Stage` and `SceneKit` to avoid. */ flyTo(pose: Pose): void; /** * `SceneKit.flying`. Capture is refused mid-flight: a frame sampled halfway * through an eased interpolation is a pose nobody chose, and it looks * plausible enough in the readout to get pasted. */ flying?(): boolean; /** * The chapters already in the pack, for the starting number and for the * duplicate-id warning. `chapterById` in `scene.ts` is built from an object * literal, so two chapters sharing an id means one of them silently is not * in the tour. */ existingChapters?: readonly Chapter[]; } /** How far the emitted block lands from the camera it was taken off. */ export interface PoseResidual { /** Camera position error, in scene units. */ position: number; /** Orbit target error, in scene units. Carries the ground lock as well as rounding. */ target: number; /** Angle between the live view direction and the emitted one, in degrees. */ aim: number; /** `aim` as a fraction of the camera's vertical field of view. */ frame: number; /** * How far the live orbit target floats above the terrain under it, in scene * units. Measured against the ground under the *unrounded* target, so it is * a statement about the camera and not about the emission precision: it is * non-zero exactly when the view has been panned, and that is the one part * of a pose a `Chapter` cannot carry. */ lift: number; } export interface CapturedPose { chapter: Chapter; residual: PoseResidual; } export interface PoseEditor { /** Read the live camera, append a chapter to the session list, select it. */ capture(): CapturedPose | null; poses(): readonly CapturedPose[]; /** The whole list as a paste-ready `chapters` array. */ code(): string; /** Call from the frame loop. Cheap by construction; see `tick`. */ tick(): void; setVisible(visible: boolean): void; destroy(): void; } // ---- Precision -------------------------------------------------------------- const LATLNG_DP = 5; const SPAN_DP = 2; const ROT_DP = 5; const TAU = Math.PI * 2; /** Where the packs wrap. Not enforced by a formatter in this repo; matched by eye. */ const COLUMNS = 100; /** Live readout ceiling. The stage runs at 60 and none of these digits need it. */ const FRAME_MS = 120; /** * Aim error, in degrees, above which the panel stops calling a pose clean. * A tenth of a degree is four pixels across a 1600-pixel frame — under it * nothing on screen moves, over it the pasted chapter is framing something * slightly different from what was approved. */ const AIM_WARN = 0.1; export function createPoseEditor(options: PoseEditorOptions): PoseEditor { if (!options.allowed) { throw new Error("poseEditor is a god-tier instrument and was constructed without the tier"); } const { world, camera, controls } = options; const existing = options.existingChapters ?? []; // ---- The conversion ------------------------------------------------------- /** * A copy of `chapterPose` in `engine/scene.ts`. Kept character-for-character * where it can be, so that a diff between the two files reads as a diff. See * the file header: this duplication is the one thing here that can rot. * * It writes into scratch vectors rather than allocating, because `measure` * calls it from the frame loop. `SceneKit.flyTo` copies out of the pose it is * given, so handing it the scratch is safe — and if that ever stops being * true this is where it breaks. */ const scratch: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() }; function poseOf(focus: Chapter["focus"], into: Pose = scratch): Pose { const [x, z] = world.project(focus.lat, focus.lng); const groundY = world.groundAt(focus.lat, focus.lng); into.target.set(x, groundY, z); into.position.set( x + Math.sin(focus.rotation) * focus.distance, groundY + focus.height, z + Math.cos(focus.rotation) * focus.distance, ); return into; } /** * The live camera, as a `Chapter["focus"]`, already at emission precision. * * Quantised in the order the file header argues for: the target first, * everything else against where the target landed. The bearing convention is * `chapterPose`'s — `atan2(dx, dz)`, so zero is due south of the target and * the angle opens toward the east, which is not the compass bearing anyone * expects and is what the packs are already written in. */ function readFocus(): Chapter["focus"] { const [rawLat, rawLng] = world.unproject(controls.target.x, controls.target.z); const lat = round(rawLat, LATLNG_DP); const lng = round(rawLng, LATLNG_DP); const [x, z] = world.project(lat, lng); const groundY = world.groundAt(lat, lng); const dx = camera.position.x - x; const dz = camera.position.z - z; const distance = Math.hypot(dx, dz); // Directly overhead the bearing is undefined, and `atan2` does not say so: // on a pair of negative zeros it answers -π, which the wrap below turns // into a confident π. The pose still round-trips either way — sin and cos // of anything times a zero distance is a zero offset — but // `rotation: 3.14159, distance: 0` in a city pack is a riddle, so straight // down is written as zero. let rotation = distance < 1e-6 ? 0 : Math.atan2(dx, dz); if (rotation < 0) rotation += TAU; rotation = round(rotation, ROT_DP); // Rounding up through a full turn: a bearing a hair below due south comes // out of the wrap as 6.283185…, which at five decimals is 6.28319, which is // larger than a turn. Zero is the same pose and reads like one. if (rotation >= TAU) rotation = 0; return { lat, lng, distance: round(distance, SPAN_DP), height: round(camera.position.y - groundY, SPAN_DP), rotation, }; } const probe: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() }; const liveDir = new THREE.Vector3(); const emitDir = new THREE.Vector3(); /** * What the emitted block costs, against the camera it was taken off. * * This is the correctness claim of the whole tool and it is checked at * capture rather than asserted in a comment: the focus goes back through the * local copy of `chapterPose` and the two poses are differenced. Three * separate numbers because they fail separately — rounding moves the camera, * the ground lock moves the target, and only the angle between the two view * vectors says whether any of it is visible. */ function measure(focus: Chapter["focus"]): PoseResidual { poseOf(focus, probe); liveDir.subVectors(controls.target, camera.position); emitDir.subVectors(probe.target, probe.position); const degrees = liveDir.lengthSq() > 0 && emitDir.lengthSq() > 0 ? (liveDir.angleTo(emitDir) * 180) / Math.PI : 0; // Against the ground under the live target rather than under the rounded // one. The difference is sub-millimetre and it is still worth the second // sample: `probe.target.y` folds the terrain's slope across a metre of // rounding into a number the panel presents as "you panned", and a warning // that fires on its own rounding is a warning people learn to ignore. const [rawLat, rawLng] = world.unproject(controls.target.x, controls.target.z); return { position: probe.position.distanceTo(camera.position), target: probe.target.distanceTo(controls.target), aim: degrees, frame: camera.fov > 0 ? degrees / camera.fov : 0, lift: controls.target.y - world.groundAt(rawLat, rawLng), }; } // ---- The session list ----------------------------------------------------- interface Entry { chapter: Chapter; residual: PoseResidual; /** False once the id has been typed, so a later label edit stops overwriting it. */ idAuto: boolean; row: HTMLElement; name: HTMLElement; note: HTMLElement; } const entries: Entry[] = []; let selected: Entry | null = null; let visible = true; let destroyed = false; // ---- DOM ------------------------------------------------------------------ /** * The panel lives in a shadow root. * * `index.html` is the entire stylesheet of this application and it belongs to * whoever is integrating this tool, not to the tool. A shadow root is the * only way to ship a widget with its own styling that neither reads from nor * writes to that file. Custom properties still cross the boundary, which is * the useful half of the isolation: `var(--amber)` below picks up the app's * own accent when the panel is mounted inside it and falls back to the same * literal when it is mounted anywhere else. */ const host = document.createElement("div"); host.className = "tera-pose-editor"; const root = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = CSS; root.append(style); const panel = el("div", "panel"); root.append(panel); panel.append(el("div", "hd", "Chapter pose")); // The live readout. const live = el("div", "live"); const liveLatLng = el("div", "row mono"); const liveFocus = el("div", "row mono"); const liveMeta = el("div", "row sub"); const liveWarn = el("div", "warn"); liveWarn.hidden = true; live.append(liveLatLng, liveFocus, liveMeta, liveWarn); panel.append(live); const captureBtn = el("button", "btn primary", "Capture pose") as HTMLButtonElement; captureBtn.type = "button"; captureBtn.addEventListener("click", () => { capture(); }); panel.append(captureBtn); // The naming form. It edits whichever pose is selected rather than being a // form you fill in before capturing: you find the frame first and work out // what to call it second, which is the order the job actually happens in. const form = el("div", "form"); const fId = field(form, "id", "kebab-case, unique in the pack"); const fNumber = field(form, "number", '"13"'); const fLabel = field(form, "label", "Mission Bay"); const fShort = field(form, "shortLabel", "Mission Bay"); const fDesc = area(form, "description", "A sentence about why it is on the map."); panel.append(form); const list = el("div", "list"); panel.append(list); const actions = el("div", "actions"); const copyOne = el("button", "btn", "Copy chapter") as HTMLButtonElement; const copyAll = el("button", "btn", "Copy all") as HTMLButtonElement; copyOne.type = "button"; copyAll.type = "button"; copyOne.addEventListener("click", () => { if (selected) void copy(emitChapter(selected.chapter)); }); copyAll.addEventListener("click", () => { if (entries.length > 0) void copy(code()); }); actions.append(copyOne, copyAll); panel.append(actions); const msg = el("div", "msg"); panel.append(msg); /** * The clipboard fallback. * * `navigator.clipboard` needs a secure context, and a self-hoster running * this off `http://` on a LAN address has none — which is a documented * deployment in `deploy/STATIC.md`, not an edge case. So the failure path is * a textarea with the text already selected, and the user presses their own * copy key. * * `document.execCommand("copy")` is deliberately not tried in between. Inside * a shadow root the selection it copies is not reliably the one you just * made, and it reports success either way; a button that says "Copied" and * copied nothing is worse than a button that says it could not. */ const out = el("textarea", "out") as HTMLTextAreaElement; out.readOnly = true; out.spellcheck = false; out.hidden = true; panel.append(out); /** * Keystrokes stop at the shadow boundary. * * `main.ts` binds the application's shortcuts to `window` and guards them by * testing whether `event.target` is an input or a textarea. **That guard does * not work through a shadow root**: the event is retargeted on its way out, so * by the time it reaches `window` the target is this host `