/** * 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 `
` 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; } `;