1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/tools/poseEditor.ts
T
karti e41c90fe8d Real weather, real aircraft, a heightfield off the main thread, and instruments
Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 03:25:31 -07:00

959 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `<div>` and the
* guard passes. Typing "Mission Bay" into the label field would fly the camera
* to chapter one on the "1", toggle the plan view on the "m" and open an
* office on the "o" — which is to say a tool whose entire premise is that it
* does not perturb the scene would be the only thing in the app that did.
*
* Everything is swallowed rather than only the keys that currently mean
* something, because the alternative is this list going stale the first time
* somebody adds a shortcut. Escape is the one that also does something here:
* it puts the clipboard fallback away.
*/
function containKeys(event: Event) {
event.stopPropagation();
if (!(event instanceof KeyboardEvent) || event.key !== "Escape") return;
if (out.hidden) return;
out.hidden = true;
say("");
}
root.addEventListener("keydown", containKeys);
root.addEventListener("keyup", containKeys);
options.container.append(host);
// ---- Form wiring ----------------------------------------------------------
function bindField(input: HTMLInputElement | HTMLTextAreaElement, apply: (v: string) => void) {
input.addEventListener("input", () => {
if (!selected) return;
apply(input.value);
refreshRow(selected);
warnDuplicate();
});
}
bindField(fId, (v) => {
if (!selected) return;
selected.chapter.id = v;
// Typing an id takes it off the leash; a later label edit stops rewriting
// it. Clearing the field puts it back, which is the only way to undo that
// without a reset button nobody would find.
selected.idAuto = v.trim() === "";
if (selected.idAuto) selected.chapter.id = slug(selected.chapter.label);
});
bindField(fNumber, (v) => {
if (selected) selected.chapter.number = v;
});
bindField(fLabel, (v) => {
if (!selected) return;
selected.chapter.label = v;
if (selected.idAuto) {
selected.chapter.id = slug(v);
fId.value = selected.chapter.id;
}
});
bindField(fShort, (v) => {
if (selected) selected.chapter.shortLabel = v;
});
bindField(fDesc, (v) => {
if (selected) selected.chapter.description = v;
});
// ---- Capture and the list -------------------------------------------------
function capture(): CapturedPose | null {
if (options.flying?.()) {
say("Still flying — wait for the camera to land.");
return null;
}
const focus = readFocus();
const residual = measure(focus);
const number = pad(existing.length + entries.length + 1);
const label = `Untitled ${number}`;
const chapter: Chapter = {
id: slug(label),
number,
label,
shortLabel: label,
focus,
description: "",
};
const row = el("div", "item");
const name = el("button", "name") as HTMLButtonElement;
name.type = "button";
const note = el("div", "res mono");
const fly = el("button", "ico", "fly") as HTMLButtonElement;
const drop = el("button", "ico", "×") as HTMLButtonElement;
fly.type = "button";
drop.type = "button";
fly.title = "Fly to the emitted pose — the rounded numbers, not the live camera";
drop.title = "Forget this pose";
const head = el("div", "item-head");
head.append(name, fly, drop);
row.append(head, note);
const entry: Entry = { chapter, residual, idAuto: true, row, name, note };
name.addEventListener("click", () => select(entry));
fly.addEventListener("click", () => {
// Through the kit's own flight, and to the *emitted* pose rather than the
// captured one, because the emitted pose is what the paste will produce
// and the whole point is to see it before trusting it.
options.flyTo(poseOf(entry.chapter.focus));
});
drop.addEventListener("click", () => remove(entry));
entries.push(entry);
list.append(row);
refreshRow(entry);
select(entry);
warnDuplicate();
return { chapter, residual };
}
function remove(entry: Entry) {
const at = entries.indexOf(entry);
if (at < 0) return;
entries.splice(at, 1);
entry.row.remove();
if (selected === entry) select(entries[Math.min(at, entries.length - 1)] ?? null);
warnDuplicate();
}
function select(entry: Entry | null) {
if (selected) selected.row.classList.remove("sel");
selected = entry;
if (entry) entry.row.classList.add("sel");
form.classList.toggle("off", entry === null);
copyOne.disabled = entry === null;
copyAll.disabled = entries.length === 0;
fId.value = entry?.chapter.id ?? "";
fNumber.value = entry?.chapter.number ?? "";
fLabel.value = entry?.chapter.label ?? "";
fShort.value = entry?.chapter.shortLabel ?? "";
fDesc.value = entry?.chapter.description ?? "";
}
function refreshRow(entry: Entry) {
const r = entry.residual;
entry.name.textContent = `${entry.chapter.number} · ${entry.chapter.label || "—"}`;
entry.note.textContent =
`pos ${r.position.toFixed(4)}u · aim ${r.aim.toFixed(4)}° · ` +
`${(r.frame * 100).toFixed(3)}% of frame`;
entry.note.classList.toggle("bad", r.aim > AIM_WARN);
}
/**
* Two chapters with the same id is not a lint, it is a missing chapter:
* `scene.ts` keys its flights off an object built from the list, so the
* second one wins and the first is unreachable from the legend.
*/
function warnDuplicate() {
const seen = new Set(existing.map((c) => c.id));
const clashes: string[] = [];
for (const e of entries) {
if (seen.has(e.chapter.id)) clashes.push(e.chapter.id);
seen.add(e.chapter.id);
}
if (clashes.length > 0) say(`Duplicate id: ${clashes.join(", ")}`, true);
else if (msg.classList.contains("bad")) say("");
}
function say(text: string, bad = false) {
msg.textContent = text;
msg.classList.toggle("bad", bad && text !== "");
}
async function copy(text: string) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
say("Copied.");
return;
} catch {
// Permission refused, or a context the API decided was not secure
// enough after all. Fall through to the textarea.
}
}
out.hidden = false;
out.value = text;
out.focus({ preventScroll: true });
out.select();
say("Clipboard unavailable — press Ctrl/Cmd-C.");
}
function code(): string {
return emitChapters(entries.map((e) => e.chapter));
}
// ---- The live readout -----------------------------------------------------
let lastDraw = 0;
// Compared exactly rather than with an epsilon, for the reason `minimap.ts`
// spells out: OrbitControls' damping asymptotes, and an epsilon freezes the
// readout a few frames before the camera has actually stopped.
let lastCamX = NaN;
let lastCamY = NaN;
let lastCamZ = NaN;
let lastTgtX = NaN;
let lastTgtY = NaN;
let lastTgtZ = NaN;
function moved(): boolean {
return (
camera.position.x !== lastCamX ||
camera.position.y !== lastCamY ||
camera.position.z !== lastCamZ ||
controls.target.x !== lastTgtX ||
controls.target.y !== lastTgtY ||
controls.target.z !== lastTgtZ
);
}
function paintLive() {
const focus = readFocus();
const r = measure(focus);
liveLatLng.textContent = `lat ${num(focus.lat)} lng ${num(focus.lng)}`;
liveFocus.textContent =
`dist ${num(focus.distance)} height ${num(focus.height)} rot ${num(focus.rotation)}`;
// The 3D standoff against the controls' own ceiling, because a pose outside
// it is one `controls.update()` away from being quietly reeled in — the
// trap `sf.ts` has a paragraph about above its regional chapters.
const radius = Math.hypot(focus.distance, focus.height);
const ceiling = controls.maxDistance;
const groundM = world.unitsToMetres(controls.target.y - r.lift);
liveMeta.textContent =
`ground ${groundM.toFixed(0)} m · standoff ${radius.toFixed(1)} of ${ceiling.toFixed(0)}`;
const tight = ceiling > 0 && radius > ceiling * 0.99;
if (Math.abs(r.lift) > 1e-4 && r.aim > AIM_WARN) {
liveWarn.hidden = false;
liveWarn.textContent =
`Target is ${r.lift.toFixed(2)}u off the ground — a chapter cannot carry that, ` +
`so the emitted pose aims ${r.aim.toFixed(2)}° elsewhere. Re-fly a chapter to reset it.`;
} else if (tight) {
liveWarn.hidden = false;
liveWarn.textContent = "At the orbit ceiling — the pose may be reeled in on arrival.";
} else if (r.aim > AIM_WARN) {
liveWarn.hidden = false;
liveWarn.textContent = `Round trip is ${r.aim.toFixed(3)}° out.`;
} else {
liveWarn.hidden = true;
}
}
function tick() {
if (destroyed || !visible) return;
const now = performance.now();
if (now - lastDraw < FRAME_MS) return;
if (!moved()) return;
lastDraw = now;
lastCamX = camera.position.x;
lastCamY = camera.position.y;
lastCamZ = camera.position.z;
lastTgtX = controls.target.x;
lastTgtY = controls.target.y;
lastTgtZ = controls.target.z;
paintLive();
}
select(null);
paintLive();
return {
capture,
poses: () => entries.map((e) => ({ chapter: e.chapter, residual: e.residual })),
code,
tick,
setVisible(next) {
visible = next;
host.hidden = !next;
// The readout is stale by however long the panel was shut, and `moved()`
// will say nothing changed if the camera happens to be back where it was.
if (next) paintLive();
},
destroy() {
if (destroyed) return;
destroyed = true;
root.removeEventListener("keydown", containKeys);
root.removeEventListener("keyup", containKeys);
// Everything else this file listens to is on a node inside `host`, so
// removing it takes the listeners with it. The entries hold DOM that is
// inside `host` too; dropping the array is what stops them being reachable.
host.remove();
entries.length = 0;
selected = null;
},
};
}
// ---- Emission ---------------------------------------------------------------
/**
* One chapter, in the shape `cities/sf.ts` already has: two-space indent, key
* order `id, number, label, shortLabel, focus, description`, and a trailing
* comma, because what you are pasting is an element of the `CHAPTERS` array
* rather than a standalone declaration.
*/
export function emitChapter(chapter: Chapter, indent = " "): string {
const inner = `${indent} `;
return [
`${indent}{`,
`${inner}id: ${quote(chapter.id)},`,
`${inner}number: ${quote(chapter.number)},`,
`${inner}label: ${quote(chapter.label)},`,
`${inner}shortLabel: ${quote(chapter.shortLabel)},`,
emitFocus(chapter.focus, inner),
emitDescription(chapter.description, inner),
`${indent}},`,
].join("\n");
}
/** The session list as the declaration a city pack ends with. */
export function emitChapters(chapters: readonly Chapter[]): string {
const body = chapters.map((c) => emitChapter(c)).join("\n");
return `export const CHAPTERS: City["chapters"] = [\n${body}\n];\n`;
}
/**
* The focus, on one line where it fits and one key per line where it does not.
*
* Every focus in both packs is on one line today, but they were typed at four
* decimals and integer distances; a captured pose at five and two runs to about
* 97 columns and a long one goes over. This is the only formatting rule in the
* file and it is the one the rest of the tree follows by eye — there is no
* formatter in `devDependencies` to defer to.
*/
function emitFocus(focus: Chapter["focus"], indent: string): string {
const pairs = [
`lat: ${num(focus.lat)}`,
`lng: ${num(focus.lng)}`,
`distance: ${num(focus.distance)}`,
`height: ${num(focus.height)}`,
`rotation: ${num(focus.rotation)}`,
];
const flat = `${indent}focus: { ${pairs.join(", ")} },`;
if (flat.length <= COLUMNS) return flat;
return [`${indent}focus: {`, ...pairs.map((p) => `${indent} ${p},`), `${indent}},`].join("\n");
}
function emitDescription(description: string, indent: string): string {
// Collapsed to one line rather than escaped as `\n`. The field is a sentence
// in a legend; a textarea that has been typed into with the Enter key still
// means one paragraph, and a literal newline inside the quotes would not
// compile.
const text = quote(description.replace(/\s+/g, " ").trim());
const flat = `${indent}description: ${text},`;
if (flat.length <= COLUMNS) return flat;
return `${indent}description:\n${indent} ${text},`;
}
/**
* Already-rounded numbers, printed short. `String` drops the trailing zeros
* `toFixed` would leave, so a pose that lands on 0.4 emits `0.4` and matches
* what is in the packs; nothing here reaches the magnitude where JavaScript
* switches to exponent notation.
*/
function num(value: number): string {
return String(value === 0 ? 0 : value);
}
function quote(text: string): string {
return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function round(value: number, dp: number): number {
return Number(value.toFixed(dp));
}
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function slug(label: string): string {
const s = label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return s === "" ? "chapter" : s;
}
// ---- DOM helpers ------------------------------------------------------------
function el(tag: string, className: string, text?: string): HTMLElement {
const node = document.createElement(tag);
node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function field(parent: HTMLElement, name: string, placeholder: string): HTMLInputElement {
const wrap = el("label", "f");
wrap.append(el("span", "k", name));
const input = document.createElement("input");
input.type = "text";
input.placeholder = placeholder;
input.spellcheck = false;
wrap.append(input);
parent.append(wrap);
return input;
}
function area(parent: HTMLElement, name: string, placeholder: string): HTMLTextAreaElement {
const wrap = el("label", "f col");
wrap.append(el("span", "k", name));
const input = document.createElement("textarea");
input.rows = 3;
input.placeholder = placeholder;
wrap.append(input);
parent.append(wrap);
return input;
}
/**
* The panel's stylesheet.
*
* Written against the application's custom properties with the literal as the
* fallback, so the tool looks like it belongs when it is mounted inside Tera
* and still looks deliberate when it is mounted in a bare page. `:host` sets no
* `all: initial` on purpose — that would reset the custom properties along with
* everything else and the fallbacks would be all anyone ever saw.
*/
const CSS = `
:host { display: block; }
:host([hidden]) { display: none; }
* { box-sizing: border-box; }
.panel {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11px;
line-height: 1.5;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: var(--glass-strong, rgba(9, 13, 18, 0.86));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r, 8px);
padding: var(--s3, 12px);
display: flex;
flex-direction: column;
gap: var(--s2, 8px);
}
.hd {
text-transform: uppercase;
letter-spacing: 0.09em;
font-size: 10px;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
}
.live {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s2, 8px);
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
border-radius: var(--r-sm, 5px);
}
.row { white-space: pre; overflow-x: auto; }
.sub { color: var(--ink-2, rgba(255, 255, 255, 0.56)); }
.warn {
margin-top: var(--s1, 4px);
color: var(--amber-ink, #ffd68a);
white-space: normal;
}
.btn {
font: inherit;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: var(--s2, 8px);
cursor: pointer;
transition: background var(--t, 150ms ease);
}
.btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn.primary { color: #14202b; background: var(--amber, #f2b134); border-color: transparent; }
.btn.primary:hover { background: var(--amber-lit, #ffc555); }
.form { display: flex; flex-direction: column; gap: var(--s1, 4px); }
.form.off { opacity: 0.35; pointer-events: none; }
.f { display: flex; align-items: center; gap: var(--s2, 8px); }
.f.col { align-items: flex-start; }
.k {
flex: 0 0 74px;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
padding-top: 3px;
}
input, textarea {
font: inherit;
flex: 1 1 auto;
min-width: 0;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: 3px 6px;
resize: vertical;
}
input:focus, textarea:focus { outline: 1px solid var(--amber, #f2b134); }
.list { display: flex; flex-direction: column; gap: 2px; max-height: 34vh; overflow-y: auto; }
.item {
padding: var(--s1, 4px) var(--s2, 8px);
border-radius: var(--r-sm, 5px);
border: 1px solid transparent;
}
.item.sel {
border-color: var(--amber, #f2b134);
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
}
.item-head { display: flex; align-items: center; gap: var(--s1, 4px); }
.name {
font: inherit;
flex: 1 1 auto;
min-width: 0;
text-align: left;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ico {
font: inherit;
color: var(--ink-2, rgba(255, 255, 255, 0.56));
background: none;
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: 0 5px;
cursor: pointer;
}
.ico:hover { color: var(--amber-ink, #ffd68a); }
.res { color: var(--ink-4, rgba(255, 255, 255, 0.26)); font-size: 10px; }
.bad { color: var(--amber-ink, #ffd68a); }
.actions { display: flex; gap: var(--s2, 8px); }
.actions .btn { flex: 1 1 0; }
.msg { min-height: 1.5em; color: var(--ink-2, rgba(255, 255, 255, 0.56)); }
.out {
width: 100%;
height: 18vh;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
.mono { font-variant-numeric: tabular-nums; }
`;