diff --git a/THREADS.md b/THREADS.md
index 50c7c01..8114789 100644
--- a/THREADS.md
+++ b/THREADS.md
@@ -5852,3 +5852,72 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
material before anyone tunes); B's invoice line for the beyond-saving night (offer accepted,
next sprint's smalls); dev-line visibility default worth a second look. **Gate 4 remains open:
John plays the week β SEVEN sprints standing, and the game has never looked more like itself.**
+
+[A] 2026-07-18 β π **SEAM CONTRACT FOR B AND C, PUBLISHED EARLY AND ON PURPOSE β the editor page is
+ up and you are not blocked.** Gate 1's core landed (`web/world/editor.html` + `js/editor.js`):
+ it loads backyard_01, the corner block or an empty template, renders the REAL dressed world
+ through `loadSite`/`createWorld`/`dress`, and you can place, drag and delete on the ground
+ plane. Read editor.html's header comment β it is the contract, kept next to the CSS it
+ describes, the way E's `.letterhead` kit was. The short version, so nobody has to open a file
+ to start:
+
+ **DOM.** Never append to `#ed-side` by hand. `EDITOR.mountPanel({id, title, order})` β `{root,
+ body}`; fill `body`. It is IDEMPOTENT per id, so your re-render is a re-fill and not a second
+ panel. Reserved orders, so two lanes landing the same sprint can't fight over the rail:
+ `10 site Β· 20 palette Β· 30 inspector Β· 40 SCORE IT (B) Β· 50 wind (C) Β· 80 validation Β·
+ 90 export`. Classes: `.ed-panel/.ed-panel-title/.ed-panel-body`, `.ed-row`, `.ed-label`,
+ `.ed-btn[.primary|.danger|.on]`, `.ed-num/.ed-sel/.ed-text`, `.ed-card/.ed-card-head/
+ .ed-card-row/.ed-kv`, `.ed-ok/.ed-warn/.ed-err`, `.ed-note`, `.ed-tag`. B: `.ed-card` is
+ literally there for your score card β SPRINT14 says "results as a card, not console text", so
+ the card is in the contract rather than in your file.
+
+ **Scene + data.** `EDITOR.site` is the LIVE object β mutate it then call `EDITOR.markDirty()`
+ (revalidate + re-render + fire `change`). `EDITOR.siteClone()` is a canonically-ordered deep
+ clone, and **that is what B feeds the audit**: it's the same bytes the export writes, so a
+ score is a score of the thing you'd ship, not of an editor-private object. `EDITOR.world` is
+ the live world; `EDITOR.overlay` is a `THREE.Group` that the editor CLEARS on every rebuild β
+ C, put every gizmo in there and you never have to think about staleness (a stale arrow over a
+ moved venturi is the wind cousin of the phantom sail, and one lane clearing beats two lanes
+ remembering). `EDITOR.raycastGround(ev)` β `{x,y,z}`; `EDITOR.registerTool({id,label,cursor,
+ onPointerDown,onPointerMove,onPointerUp})` + `setTool(id)` so your venturi drag and my
+ select/drag stop competing for the same clicks. Events: `on('change'|'rebuild'|'select'|
+ 'siteload', fn)`. C: `site.wind` round-trips through the same canonical export as everything
+ else and `validateSiteWind` is already inside `validateSite`, so a bad gain lands in my
+ validation panel for free β you do not need to write a wind validator.
+
+ **The one thing I ask back.** Neither of you should build a private harness on this page. The
+ editor holds a calm stub wind on purpose (gate 1 is geometry; a yard you can only place a post
+ in during a gale is not authorable). Everything that SCORES has to go through `windForSite()`
+ and the real commitβattach chain β that's SPRINT13's whole lesson and gate 2.3 is the pin that
+ proves it. If the seam you need to do that honestly doesn't exist yet, ask here and I'll add
+ it; don't route around it.
+
+ **Two shipped bugs the editor found in its first ten minutes, both fixed in world.js, both
+ verified by LOOKING rather than by reasoning.** Filing these because they are the argument for
+ gate 1 existing at all β neither is subtle, and neither was findable from the bench:
+ Β· **The corner block has been shipping a graybox house.** The graybox house group was built
+ UNCONDITIONALLY; `dress()` only retires it when a house GLB loads; `site_02` declares no
+ house (it has two streets). So night 3 has had a 16 Γ 3 Γ 6 m featureless grey slab across
+ its entire north horizon β in `solids`, on the public URL, since sites became data. The
+ anchor path was already `HOUSE?.anchors ?? []`; the data path was guarded and the geometry
+ path was not. Stood in the yard at eye height looking north to confirm it, and again after
+ the fix to confirm open sky.
+ Β· **The Hendersons' shed has never rendered.** `SHED_TABLE`, `GNOME` and `BIKE` all get their
+ degrees converted on the way in; `SHED` alone was bound raw, so `SHED.rotY` was `undefined`,
+ `shed.rotation.y = undefined` gave the object an all-NaN quaternion, and three.js silently
+ declines to draw a NaN world matrix. No throw, no console warning. Meanwhile `ladder.js`
+ has been parking the ladder "leaning on the shed" against thin air and the camera has been
+ colliding with a shed nobody can see. Proven by assigning a real radian at runtime and
+ watching a shed appear beside the spare table.
+ Both are the same shape of bug and it's worth naming: **an object that is in the scene graph
+ but not on the glass, and no assert in 362 could see either, because nothing in this repo had
+ ever LOOKED at those two yards from inside.** That is what the editor is for. `undefined`
+ arriving in a three.js setter is silent β if you write one, assert the composed matrix.
+
+ Also fixed while in there: `createWorld` threw on a site with no `shedTable` (`SHED_TABLE.x`).
+ Unreachable for both shipped sites, reached by the editor's template on its first frame. Now
+ null, which every consumer already guards β interact.js's comment literally says "until it
+ lands, the pickup self-skips". Contract-legal: `shedTable` isn't in `CONTRACT.world`.
+
+ Still mine this sprint: the round-trip assert (template β place β export β `loadSite` β
+ boots), the dev-line pool look, and the phantom-sail view half I filed and didn't land in S13.
diff --git a/web/world/editor.html b/web/world/editor.html
new file mode 100644
index 0000000..4bc6130
--- /dev/null
+++ b/web/world/editor.html
@@ -0,0 +1,216 @@
+
+
+
+
+
+HARD YARDS β yard editor
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/world/js/editor.js b/web/world/js/editor.js
new file mode 100644
index 0000000..5798583
--- /dev/null
+++ b/web/world/js/editor.js
@@ -0,0 +1,1248 @@
+/**
+ * SHADES / HARD YARDS β the yard editor. Lane A owns this file (SPRINT14 gate 1).
+ *
+ * docs/MANUAL.md's "authoring a site" cookbook, with the typing removed. It
+ * renders the REAL dressed world through the SAME `loadSite`/`createWorld`
+ * path the game boots, so what you place is what you play β this is not a
+ * diagram and there is deliberately no second renderer to keep in sync.
+ *
+ * Two rules it exists to enforce, both of them from THREADS:
+ *
+ * 1. **No parallel schema.** Placement writes the same named-key site-JSON
+ * shapes `world.js` already reads. There is no generic `props[]` in this
+ * repo on purpose (the gnome pattern) and the editor does not invent one.
+ * If you can't express a thing as a named key, the answer is a loader
+ * change plus a palette entry β not an editor-private format.
+ * 2. **The editor cannot make a yard the game can't boot.** `validateSite`
+ * runs on every edit into a panel (not the console), an invalid yard
+ * exports under a loud `_INVALID` key, and a.test round-trips a placed
+ * yard through export β `loadSite` β `createWorld` β `dress` every run.
+ *
+ * ββ THE SEAM API (Lane B's SCORE IT, Lane C's wind authoring) βββββββββββββββ
+ * `globalThis.EDITOR`, also the resolved value of `createEditor()`:
+ *
+ * scene, renderer, camera three.js handles (camera is a PerspectiveCamera)
+ * world the live world from createWorld (null before first build)
+ * site the LIVE in-memory site object β mutate it, then markDirty()
+ * siteClone() deep clone, canonically ordered (what export would write)
+ * setSite(obj) β Promise; replace the whole site and rebuild
+ * rebuild() β Promise; re-run createWorld + dress against `site`
+ * markDirty() re-validate + re-render panels + fire 'change'
+ * validation() β { ok, errors:string[], warnings:string[] }
+ * exportJSON() β canonical JSON string (stable key order, 2-space)
+ * on(evt, fn) / off(evt, fn) 'change' | 'rebuild' | 'select' | 'siteload'
+ * mountPanel({id,title,order}) β { root, body } β idempotent per id; see editor.html
+ * overlay THREE.Group, CLEARED ON EVERY REBUILD β put gizmos here
+ * raycastGround(ev) β {x,y,z} | null (y = heightAt(x,z))
+ * registerTool(spec)/setTool(id) pointer tools; see `registerTool` below
+ * select(ref)/selection ref = {kind, id} β see REFS
+ * HEIGHT_AT world.js's heightAt, re-exported for gizmo placement
+ *
+ * Events carry `{ site }` (change/siteload), `{ world, site }` (rebuild) or
+ * `{ ref }` (select). C: mutate `EDITOR.site.wind` then `markDirty()` β the
+ * wind block round-trips through the same canonical export as everything else,
+ * and `validateSiteWind` is already wired into `validateSite`, so a bad gain
+ * lands in the validation panel for free.
+ *
+ * ββ WHY REBUILD-ON-DROP ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ * Dragging translates the entry's scene nodes live (cheap, one object). The
+ * WORLD is rebuilt on drop, because anchors are the thing downstream cares
+ * about and only `createWorld` + `dress` produce them honestly: a GLB-backed
+ * anchor's position and its `rating_hint` come out of the asset, not out of
+ * arithmetic the editor could do itself. Re-deriving them here would be a
+ * fourth harness rebuilding site truth by hand β the exact mistake
+ * docs/MANUAL.md's `windForSite()` gotcha is a monument to. Measured on this
+ * machine: a warm rebuild of backyard_01 is ~40-90 ms, which is a drop, not a
+ * drag. If that ever stops being true, cache by model name β don't re-derive.
+ */
+
+import * as THREE from '../vendor/three.module.js';
+import { ANCHOR_TYPE, createStubWind } from './contracts.js';
+import { createWorld, heightAt, loadSite, validateSite } from './world.js';
+
+// ---------------------------------------------------------------------------
+// Canonical serialisation β "same yard in, byte-identical JSON out"
+// ---------------------------------------------------------------------------
+
+/**
+ * Key order per shape. Site diffs are reviewed by humans, so a key that moves
+ * because an editor session happened to set it second is noise that hides the
+ * one number that actually changed.
+ *
+ * The rule is total and therefore deterministic: keys named here come first in
+ * THIS order, then every remaining key sorted alphabetically. Insertion order
+ * is never consulted β it isn't stable across a load/edit/export cycle, which
+ * is precisely what "byte-identical" has to survive.
+ *
+ * `_`-prefixed authoring notes are listed in place rather than swept to the
+ * end: this repo expects levels to carry their design notes, and a `_design`
+ * block that reads BEFORE the geometry it explains is the reason the corner
+ * block's carport is still legible three sprints later.
+ */
+const KEY_ORDER = {
+ $root: [
+ 'id', 'name', 'blurb', '_blurb', '_design', '_why',
+ 'yard', 'sun', 'gardenBed', 'gardenBedModel',
+ 'house', 'structures', 'trees', 'posts',
+ 'fence', 'shed', 'shedTable', 'gnome', 'bike',
+ 'separation', 'wind',
+ ],
+ yard: ['width', 'depth'],
+ sun: ['elevationDeg', 'azimuthDeg'],
+ gardenBed: ['x', 'z', 'w', 'd'],
+ house: [
+ 'model', 'wreckedModel', 'collateralKey', 'collateralValue', 'collateralLabel',
+ '_collateral', 'x', 'z', 'rotYDeg', 'anchors',
+ ],
+ structure: [
+ 'id', 'model', 'wreckedModel', 'x', 'z', 'rotYDeg', 'solid',
+ 'collateralValue', 'collateralLabel', '_collateral', '_types', 'anchors',
+ ],
+ tree: ['id', 'model', 'x', 'z', 'rotYDeg', 'phase', 'trunkH', 'anchorY', '_why', 'anchors'],
+ anchor: ['id', 'node', 'type', 'work'],
+ post: ['id', 'x', 'z', 'h', 'type', 'work'],
+ fence: ['sides'],
+ shed: ['model', 'x', 'z', 'rotYDeg'],
+ shedTable: ['model', 'x', 'z', 'rotYDeg', 'pickupNode'],
+ gnome: ['model', 'x', 'z', 'rotYDeg', 'collateralValue', '_why'],
+ bike: ['model', 'x', 'z', 'rotYDeg', '_why'],
+ wind: ['_venturi', 'venturi'],
+ venturi: ['x', 'z', 'axis', 'gain', 'radius', 'sharp'],
+ separation: ['_why', '_playedReference', 'stormKey', 'tension', 'line',
+ 'heldMustExceed', 'bareMustLoseBelow', 'separationAtLeast'],
+ line: ['anchor', 'hw'],
+};
+
+/** Which KEY_ORDER table applies to the objects inside a given key. */
+const CHILD_SHAPE = {
+ structures: 'structure', trees: 'tree', posts: 'post', anchors: 'anchor',
+ venturi: 'venturi', line: 'line',
+};
+
+/**
+ * Deep-clone `value` with every object's keys in canonical order.
+ * Pure: it reads, it never rounds. Rounding happens at EDIT time (see
+ * `round3`) so that export stays a plain serialisation and the in-memory site
+ * and the exported file can never disagree about a coordinate.
+ */
+function canonical(value, shape = '$root') {
+ if (Array.isArray(value)) {
+ const child = CHILD_SHAPE[shape] ?? shape;
+ return value.map((v) => canonical(v, child));
+ }
+ if (value === null || typeof value !== 'object') return value;
+ const order = KEY_ORDER[shape] ?? [];
+ const known = order.filter((k) => Object.hasOwn(value, k));
+ const rest = Object.keys(value).filter((k) => !order.includes(k)).sort();
+ const out = {};
+ // A child's shape name is its KEY. That single rule covers both cases:
+ // `yard` finds KEY_ORDER.yard, and `posts` reaches the array branch which
+ // maps it through CHILD_SHAPE to `post`. An unknown key finds no table and
+ // sorts alphabetically, which is still deterministic.
+ for (const k of [...known, ...rest]) out[k] = canonical(value[k], k);
+ return out;
+}
+
+/**
+ * The export. `_INVALID` first and unmissable when the yard doesn't validate β
+ * an invalid site must still be exportable (you save your work mid-build) but
+ * it must not be able to land in `data/sites/` looking clean. `loadSite` would
+ * throw on it anyway; the key is there so a HUMAN sees it in the diff first.
+ *
+ * @param {object} site
+ * @param {{ok:boolean, errors:string[]}} [validation]
+ * @returns {string}
+ */
+export function exportSiteJSON(site, validation) {
+ const body = canonical(site, '$root');
+ const out = (validation && !validation.ok)
+ ? { _INVALID: [
+ 'THIS YARD DOES NOT VALIDATE β the game cannot boot it. Exported so you',
+ 'do not lose the work. Fix these and re-export before it goes anywhere',
+ 'near web/world/data/sites/:',
+ ...validation.errors,
+ ], ...body }
+ : body;
+ return `${JSON.stringify(out, null, 2)}\n`;
+}
+
+/** Coordinates are rounded where they're WRITTEN, so export stays pure. */
+const round3 = (n) => Math.round(n * 1000) / 1000;
+
+// ---------------------------------------------------------------------------
+// The palette β every placeable, and the exact named-key shape it writes
+// ---------------------------------------------------------------------------
+
+/**
+ * Node names are the ones actually baked in the committed GLBs (read out of
+ * the files, not remembered). They are checked after every rebuild by
+ * `adoptionWarnings()`: a palette entry that names a node the asset doesn't
+ * have would otherwise leave the anchor sitting at its graybox position with
+ * `ratingHint` 1.0 β a trap that silently becomes honest steel, which is the
+ * invincible-house gotcha wearing a different hat.
+ */
+const PALETTE = [
+ {
+ kind: 'post', label: 'Post', hint: 'an honest ground post β the cheap, safe anchor',
+ make: (id, x, z) => ({ id, x, z, h: 4.0, type: 'post', work: 'cloth' }),
+ },
+ {
+ kind: 'tree', label: 'Gum (big, 3 anchors)', model: 'tree_gum_01_v1',
+ hint: 'sways β dynamic load, which is why a tree is scarier than a post',
+ make: (id, x, z) => ({
+ id, model: 'tree_gum_01_v1', x, z, phase: 0.7, trunkH: 4.2, anchorY: 3.4,
+ anchors: [1, 2, 3].map((i) => ({
+ id: i === 1 ? id : `${id}${'abc'[i - 1]}`,
+ node: `branch_anchor_0${i}`, type: 'tree', work: 'cloth',
+ })),
+ }),
+ },
+ {
+ kind: 'tree', label: 'Gum (smaller, 2 anchors)', model: 'tree_gum_02_v1',
+ hint: 'shorter reach; the corner block\'s honest anchor',
+ make: (id, x, z) => ({
+ id, model: 'tree_gum_02_v1', x, z, phase: 1.7, trunkH: 3.8, anchorY: 3.1,
+ anchors: [1, 2].map((i) => ({
+ id: i === 1 ? id : `${id}b`,
+ node: `branch_anchor_0${i}`, type: 'tree', work: 'cloth',
+ })),
+ }),
+ },
+ {
+ kind: 'structure', label: 'Carport (the trap)', model: 'carport_01_v1',
+ hint: 'four anchors that look free; rating_hint 0.22/0.30 and it takes $180 with it',
+ make: (id, x, z) => ({
+ id, model: 'carport_01_v1', wreckedModel: 'carport_01_wrecked_v1',
+ x, z, rotYDeg: 0, solid: true,
+ collateralValue: 180, collateralLabel: 'the carport',
+ anchors: [
+ { id: `${id}_b1`, node: 'beam_anchor_01', type: 'carport', work: 'bracket' },
+ { id: `${id}_b2`, node: 'beam_anchor_02', type: 'carport', work: 'bracket' },
+ { id: `${id}_p1`, node: 'post_anchor_01', type: 'carport_post', work: 'cloth' },
+ { id: `${id}_p2`, node: 'post_anchor_02', type: 'carport_post', work: 'cloth' },
+ ],
+ }),
+ },
+ {
+ kind: 'house', label: 'House (north edge)', model: 'house_yardside_v1', singleton: true,
+ hint: 'three fascia anchors at rating_hint 0.35 β "the fascia board is a lie"',
+ make: (_id, x, z) => ({
+ model: 'house_yardside_v1', wreckedModel: 'house_yardside_wrecked_v1',
+ collateralKey: 'gutter', collateralValue: 90, collateralLabel: 'the gutter',
+ x, z,
+ anchors: [1, 2, 3].map((i) => ({
+ id: `h${i}`, node: `fascia_anchor_0${i}`, type: 'house', work: 'bracket',
+ })),
+ }),
+ },
+ {
+ kind: 'shed', label: 'Shed', model: 'shed_01_v1', singleton: true,
+ hint: 'scenery β no rig anchors (its only node is door_anchor)',
+ make: (_id, x, z) => ({ model: 'shed_01_v1', x, z, rotYDeg: -90 }),
+ },
+ {
+ kind: 'shedTable', label: 'Spare table', model: 'shed_table_v1', singleton: true,
+ hint: 'where a spare gets picked up β no table, no mid-storm repair',
+ make: (_id, x, z) => ({ model: 'shed_table_v1', x, z, rotYDeg: -90, pickupNode: 'pickup_anchor' }),
+ },
+ {
+ kind: 'gnome', label: 'Gnome ($25)', model: 'garden_gnome_01_v1', singleton: true,
+ hint: 'breakable client property β the gnome pattern, priced in the SITE',
+ make: (_id, x, z) => ({ model: 'garden_gnome_01_v1', x, z, rotYDeg: 0, collateralValue: 25 }),
+ },
+ {
+ kind: 'bike', label: "Kid's bike", model: 'bike_kid_01_v1', singleton: true,
+ hint: 'deliberately unpriced until the sim can knock it over (a tripwire pins this)',
+ make: (_id, x, z) => ({ model: 'bike_kid_01_v1', x, z, rotYDeg: 180 }),
+ },
+];
+
+/** Singletons the template always carries and you may move but not delete. */
+const UNDELETABLE = new Set(['gardenBed']);
+
+/**
+ * The empty template β SPRINT14 gate 1.1's "flat yard + fence ring + garden bed",
+ * PLUS one post, and the post is not padding.
+ *
+ * `validateSite` rejects a yard with no anchors ("nothing to rig to"), and
+ * `createWorld` validates before it builds anything β so a literally
+ * anchor-less template cannot be rendered AT ALL. Shipped as spec'd, the
+ * editor's front door was a black viewport with an error in the corner, which
+ * I only found by opening it. One post is the smallest thing that is a yard
+ * rather than a rectangle, and it makes the first frame a place you can stand.
+ *
+ * The lesson the anchor-less template was meant to teach survives, in a better
+ * place: delete this post and the validation panel says why, in those words,
+ * while the last valid yard stays on screen so you can put it back.
+ */
+function emptyTemplate() {
+ return {
+ id: 'site_new',
+ name: 'Untitled yard',
+ blurb: '',
+ _design: [
+ 'Why this yard exists, what the trap is, and what you measured. Sites are',
+ 'data and this block is the level\'s design doc β docs/MANUAL.md step 1.',
+ ],
+ yard: { width: 24, depth: 16 },
+ sun: { elevationDeg: 55, azimuthDeg: -125 },
+ gardenBed: { x: 0, z: 0, w: 5, d: 3.5 },
+ posts: [{ id: 'p1', x: -4, z: -3, h: 4.0, type: 'post', work: 'cloth' }],
+ fence: { sides: ['north', 'south', 'east', 'west'] },
+ wind: {},
+ };
+}
+
+// ---------------------------------------------------------------------------
+// REFS β how the editor addresses a placed thing
+// ---------------------------------------------------------------------------
+// { kind: 'post'|'tree'|'structure', id } an entry in that array
+// { kind: 'house'|'shed'|'shedTable'|'gnome'|'bike'|'gardenBed' } the singleton
+// A ref is plain data so it survives a rebuild; nothing holds a mesh.
+
+const ARRAY_KIND = { post: 'posts', tree: 'trees', structure: 'structures' };
+
+const refEq = (a, b) => !!a && !!b && a.kind === b.kind && (a.id ?? null) === (b.id ?? null);
+
+/** Every scene node world.js builds for this ref (graybox AND dressed AND wreck). */
+function nodeNamesFor(ref) {
+ switch (ref.kind) {
+ case 'post': return [`sail_post_${ref.id}`];
+ case 'tree': return [`tree_${ref.id}`];
+ case 'structure': return [ref.id, `${ref.id}_wrecked`];
+ case 'house': return ['house_yardside', 'house_yardside_wrecked'];
+ case 'shed': return ['shed_01'];
+ case 'shedTable': return ['shed_table'];
+ case 'gnome': return ['garden_gnome_01'];
+ case 'bike': return ['bike_kid_01'];
+ case 'gardenBed': return ['garden_bed'];
+ default: return [];
+ }
+}
+
+// ---------------------------------------------------------------------------
+
+/**
+ * Build the editor.
+ * @param {{canvas:HTMLCanvasElement, side:HTMLElement, toolbar:HTMLElement,
+ * readout:HTMLElement, hint:HTMLElement, site?:string}} opts
+ */
+export async function createEditor(opts) {
+ const { canvas, side, toolbar, readout, hint } = opts;
+
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+
+ const scene = new THREE.Scene();
+ const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 400);
+
+ /** C's gizmos live here. Cleared on every rebuild β see `rebuild()`. */
+ const overlay = new THREE.Group();
+ overlay.name = 'editor_overlay';
+ scene.add(overlay);
+
+ /** The editor's OWN pick handles + selection ring. Never exported, never dressed. */
+ const handles = new THREE.Group();
+ handles.name = 'editor_handles';
+ scene.add(handles);
+
+ // The wind the editor renders with is the calm stub, deliberately: gate 1 is
+ // geometry, and a yard that only looks right in a gale is a yard nobody can
+ // place a post in. C's overlay samples the REAL site wind for the storm read
+ // (gate 2.2) β that's their seam, and it does not become this one.
+ const wind = createStubWind({ calm: true });
+
+ let site = emptyTemplate();
+ let world = null;
+ let selection = null;
+ let validationCache = { ok: true, errors: [], warnings: [] };
+ let rebuilding = false;
+ let rebuildMs = 0;
+ let lastStatus = 'template';
+
+ const listeners = new Map();
+ const emit = (evt, payload) => {
+ for (const fn of listeners.get(evt) ?? []) {
+ try { fn(payload); } catch (err) { console.error(`[editor] listener for '${evt}' threw:`, err); }
+ }
+ };
+
+ // --- panels ------------------------------------------------------------
+ const panels = new Map();
+ /**
+ * Idempotent per id β see editor.html's seam contract. `order` is what keeps
+ * B's card and C's wind block in a fixed place in the rail no matter which
+ * lane's script happens to run first.
+ */
+ function mountPanel({ id, title, order = 100, collapsed = false }) {
+ if (panels.has(id)) {
+ const p = panels.get(id);
+ if (title) p.titleEl.firstChild.textContent = title;
+ return p;
+ }
+ const root = document.createElement('section');
+ root.className = 'ed-panel';
+ root.dataset.panel = id;
+ root.dataset.order = String(order);
+ if (collapsed) root.classList.add('collapsed');
+
+ const titleEl = document.createElement('h2');
+ titleEl.className = 'ed-panel-title';
+ titleEl.append(document.createTextNode(title ?? id));
+ titleEl.addEventListener('click', () => root.classList.toggle('collapsed'));
+
+ const body = document.createElement('div');
+ body.className = 'ed-panel-body';
+
+ root.append(titleEl, body);
+ side.append(root);
+ // Re-sort by declared order so a late mount lands in its reserved slot.
+ [...side.children]
+ .sort((a, b) => Number(a.dataset.order) - Number(b.dataset.order))
+ .forEach((el) => side.append(el));
+
+ const panel = { root, body, titleEl, id };
+ panels.set(id, panel);
+ return panel;
+ }
+
+ // --- validation --------------------------------------------------------
+
+ /**
+ * Anchors that name a GLB node the asset doesn't actually have.
+ *
+ * `validateSite` can't catch this β it never opens the model. But an anchor
+ * whose node is missing is silently NOT adopted: it keeps its graybox
+ * position and `ratingHint` defaults to 1, so a carport beam rated 0.22
+ * becomes honest steel and the site's whole trap evaporates while every test
+ * stays green. The tell is `collateral`: `adoptAnchor` always sets it (to
+ * null when the node carries none), and nothing else ever does.
+ */
+ function adoptionWarnings() {
+ if (!world) return [];
+ const out = [];
+ const declared = [
+ ...(site.house?.anchors ?? []).map((a) => [a, site.house.model]),
+ ...(site.trees ?? []).flatMap((t) => (t.anchors ?? []).map((a) => [a, t.model])),
+ ...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => [a, s.model])),
+ ];
+ for (const [a, model] of declared) {
+ if (!a.node) continue;
+ const live = world.anchors.find((x) => x.id === a.id);
+ if (!live) { out.push(`anchor ${a.id}: declared but not built`); continue; }
+ if (!Object.hasOwn(live, 'collateral')) {
+ out.push(`anchor ${a.id}: node "${a.node}" not found in ${model} β it is sitting at its `
+ + 'graybox position with rating_hint 1.0, so any trap on it is not real');
+ }
+ }
+ return out;
+ }
+
+ /** Runs on EVERY edit. Errors render in the panel, never the console. */
+ function revalidate() {
+ const errors = [];
+ try {
+ validateSite(structuredClone(site), site.id ?? '?');
+ } catch (err) {
+ // validateSite throws one message with the reasons on indented lines.
+ const lines = String(err.message).split('\n').map((s) => s.trim()).filter(Boolean);
+ errors.push(...lines.slice(1));
+ if (!errors.length) errors.push(String(err.message));
+ }
+ validationCache = { ok: errors.length === 0, errors, warnings: adoptionWarnings() };
+ return validationCache;
+ }
+
+ // --- world rebuild -----------------------------------------------------
+
+ /**
+ * Rebuild the yard from the live site object, through the game's own path.
+ *
+ * Guarded per-call: an invalid site (which the editor produces constantly and
+ * on purpose while you're mid-build) must leave the LAST GOOD world standing
+ * rather than blanking the viewport β you cannot fix a yard you can't see.
+ */
+ async function rebuild() {
+ revalidate();
+ if (!validationCache.ok && world) {
+ // Keep the standing yard; the panel already says why.
+ lastStatus = 'invalid β showing last valid yard';
+ rebuildHandles();
+ renderPanels();
+ emit('change', { site });
+ return world;
+ }
+ const t0 = performance.now();
+ rebuilding = true;
+ try {
+ const next = createWorld(scene, { wind, site: structuredClone(site) });
+ if (world) world.dispose();
+ world = next;
+ await world.dress();
+ lastStatus = 'ok';
+ } catch (err) {
+ lastStatus = `build failed: ${err.message}`;
+ validationCache.errors.push(`createWorld: ${err.message}`);
+ validationCache.ok = false;
+ } finally {
+ rebuilding = false;
+ rebuildMs = performance.now() - t0;
+ }
+ // C's gizmos are rebuilt from the site, so the editor owns clearing them β
+ // a stale arrow over a moved venturi is the wind-authoring cousin of the
+ // phantom sail, and one lane clearing is better than two lanes remembering.
+ overlay.clear();
+ revalidate();
+ rebuildHandles();
+ renderPanels();
+ emit('rebuild', { world, site });
+ emit('change', { site });
+ return world;
+ }
+
+ // --- pick handles ------------------------------------------------------
+
+ const HANDLE_MAT = new THREE.MeshBasicMaterial({ visible: false });
+ const RING_MAT = new THREE.MeshBasicMaterial({ color: 0x7ee0ff, transparent: true, opacity: 0.85 });
+ const RING_GEO = new THREE.RingGeometry(0.72, 0.92, 28).rotateX(-Math.PI / 2);
+ const DOT_GEO = new THREE.RingGeometry(0.34, 0.44, 20).rotateX(-Math.PI / 2);
+ const DOT_MAT = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.28 });
+
+ /**
+ * The editor picks its OWN invisible handles rather than the dressed meshes.
+ *
+ * Two reasons, both learned elsewhere in this repo: raycasting a GLB gives
+ * you a leaf mesh and no way back to the site entry that spawned it, and
+ * `world.js`'s node naming is world.js's business β coupling the editor's
+ * picking to it would make a rename in the loader a silent break in the
+ * editor. This is the same trick rigging.js uses for anchor markers
+ * (`userData.anchorId`), which is precedent, not novelty.
+ */
+ function rebuildHandles() {
+ handles.clear();
+ for (const ref of allRefs()) {
+ const e = entryFor(ref);
+ if (!e) continue;
+ const y = heightAt(e.x, e.z);
+ const box = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.85, 3.4, 10), HANDLE_MAT);
+ box.position.set(e.x, y + 1.7, e.z);
+ box.userData.ref = ref;
+ handles.add(box);
+
+ const dot = new THREE.Mesh(DOT_GEO, DOT_MAT);
+ dot.position.set(e.x, y + 0.04, e.z);
+ dot.renderOrder = 2;
+ handles.add(dot);
+
+ if (refEq(ref, selection)) {
+ const ring = new THREE.Mesh(RING_GEO, RING_MAT);
+ ring.position.set(e.x, y + 0.05, e.z);
+ ring.renderOrder = 3;
+ handles.add(ring);
+ }
+ }
+ }
+
+ function allRefs() {
+ const out = [];
+ for (const [kind, key] of Object.entries(ARRAY_KIND)) {
+ for (const e of site[key] ?? []) out.push({ kind, id: e.id });
+ }
+ for (const kind of ['house', 'shed', 'shedTable', 'gnome', 'bike', 'gardenBed']) {
+ if (site[kind]) out.push({ kind });
+ }
+ return out;
+ }
+
+ function entryFor(ref) {
+ if (!ref) return null;
+ const key = ARRAY_KIND[ref.kind];
+ if (key) return (site[key] ?? []).find((e) => e.id === ref.id) ?? null;
+ return site[ref.kind] ?? null;
+ }
+
+ function labelFor(ref) {
+ const e = entryFor(ref);
+ if (!e) return ref.kind;
+ if (ARRAY_KIND[ref.kind]) return `${ref.kind} ${ref.id}`;
+ return ref.kind;
+ }
+
+ // --- placement / move / delete ----------------------------------------
+
+ /** Unique id in the namespace this kind occupies, e.g. p1, p2, t1, carport_2. */
+ function nextId(kind) {
+ const key = ARRAY_KIND[kind];
+ const taken = new Set((site[key] ?? []).map((e) => e.id));
+ const stem = kind === 'post' ? 'p' : kind === 'tree' ? 't' : 's';
+ for (let i = 1; i < 999; i++) if (!taken.has(`${stem}${i}`)) return `${stem}${i}`;
+ return `${stem}${Date.now()}`;
+ }
+
+ function place(paletteItem, x, z) {
+ const key = ARRAY_KIND[paletteItem.kind];
+ const id = key ? nextId(paletteItem.kind) : null;
+ const entry = paletteItem.make(id, round3(x), round3(z));
+ if (key) {
+ site[key] ??= [];
+ site[key].push(entry);
+ } else {
+ site[paletteItem.kind] = entry;
+ }
+ selection = key ? { kind: paletteItem.kind, id } : { kind: paletteItem.kind };
+ emit('select', { ref: selection });
+ return rebuild();
+ }
+
+ /**
+ * Move without a rebuild β drag feedback. The site entry is written and the
+ * entry's scene nodes TRANSLATE by the delta.
+ *
+ * Translation, not assignment, on all three axes: world.js places a dressed
+ * GLB at `(x, heightAt(x,z), z)` but leaves the graybox house group at the
+ * origin with its offsets in the children, so assigning y would teleport the
+ * graybox and only the graybox. A delta is correct for both, which is the
+ * point of never assuming another module's local frame.
+ */
+ function moveTo(ref, x, z) {
+ const e = entryFor(ref);
+ if (!e) return;
+ const nx = round3(x), nz = round3(z);
+ const dx = nx - e.x;
+ const dz = nz - e.z;
+ const dy = heightAt(nx, nz) - heightAt(e.x, e.z);
+ e.x = nx;
+ e.z = nz;
+ for (const name of nodeNamesFor(ref)) {
+ const node = world?.root?.getObjectByName(name);
+ if (node) node.position.set(node.position.x + dx, node.position.y + dy, node.position.z + dz);
+ }
+ rebuildHandles();
+ }
+
+ function remove(ref) {
+ if (!ref || UNDELETABLE.has(ref.kind)) return false;
+ const key = ARRAY_KIND[ref.kind];
+ if (key) {
+ const i = (site[key] ?? []).findIndex((e) => e.id === ref.id);
+ if (i < 0) return false;
+ site[key].splice(i, 1);
+ if (!site[key].length) delete site[key];
+ } else {
+ if (!site[ref.kind]) return false;
+ delete site[ref.kind];
+ }
+ selection = null;
+ emit('select', { ref: null });
+ rebuild();
+ return true;
+ }
+
+ // --- camera: orbit / pan / dolly --------------------------------------
+
+ const cam = { target: new THREE.Vector3(0, 1, 0), dist: 26, yaw: 0.6, pitch: 0.62 };
+ function applyCamera() {
+ cam.pitch = Math.max(0.06, Math.min(1.45, cam.pitch));
+ cam.dist = Math.max(4, Math.min(120, cam.dist));
+ const r = cam.dist * Math.cos(cam.pitch);
+ camera.position.set(
+ cam.target.x + r * Math.sin(cam.yaw),
+ cam.target.y + cam.dist * Math.sin(cam.pitch),
+ cam.target.z + r * Math.cos(cam.yaw),
+ );
+ camera.lookAt(cam.target);
+ }
+
+ // --- pointer -----------------------------------------------------------
+
+ const ray = new THREE.Raycaster();
+ const ndc = new THREE.Vector2();
+ const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
+ const _hit = new THREE.Vector3();
+
+ function setNdc(ev) {
+ const r = canvas.getBoundingClientRect();
+ ndc.set(((ev.clientX - r.left) / r.width) * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1);
+ ray.setFromCamera(ndc, camera);
+ }
+
+ /**
+ * Where on the ground the cursor is. Two steps on purpose: the flat y=0 plane
+ * gives the xz, then `heightAt` gives the true ground y there. Raycasting the
+ * terrain mesh instead would be 4800 triangles to learn something world.js
+ * already answers in closed form (its own comment says so).
+ */
+ function raycastGround(ev) {
+ setNdc(ev);
+ if (!ray.ray.intersectPlane(GROUND_PLANE, _hit)) return null;
+ return { x: _hit.x, y: heightAt(_hit.x, _hit.z), z: _hit.z };
+ }
+
+ function pickRef(ev) {
+ setNdc(ev);
+ const hits = ray.intersectObjects(handles.children, false);
+ for (const h of hits) if (h.object.userData.ref) return h.object.userData.ref;
+ return null;
+ }
+
+ /**
+ * Pointer tools. C's venturi placement is one of these: register it, call
+ * `setTool('venturi')`, and the editor's own select/drag stops competing for
+ * the same clicks instead of two lanes both listening on the canvas.
+ *
+ * spec: { id, label, cursor?, onPointerDown(ev, ground), onPointerMove, onPointerUp }
+ * Return `true` from a handler to consume the event.
+ */
+ const tools = new Map();
+ let activeTool = null;
+ function registerTool(spec) {
+ tools.set(spec.id, spec);
+ renderToolbar();
+ return spec;
+ }
+ function setTool(id) {
+ activeTool = id ? (tools.get(id) ?? null) : null;
+ armed = null;
+ canvas.style.cursor = activeTool?.cursor ?? 'default';
+ renderToolbar();
+ renderPanels();
+ }
+
+ /** The palette item waiting for a click, if any. */
+ let armed = null;
+
+ let drag = null;
+ canvas.addEventListener('pointerdown', (ev) => {
+ canvas.setPointerCapture(ev.pointerId);
+ const ground = raycastGround(ev);
+ if (activeTool?.onPointerDown?.(ev, ground)) return;
+
+ if (ev.button === 2 || ev.shiftKey) { drag = { mode: 'pan', x: ev.clientX, y: ev.clientY }; return; }
+ if (ev.button !== 0) return;
+
+ if (armed && ground) {
+ const item = armed;
+ armed = null;
+ renderPanels();
+ place(item, ground.x, ground.z);
+ return;
+ }
+ const ref = pickRef(ev);
+ if (ref) {
+ selection = ref;
+ emit('select', { ref });
+ rebuildHandles();
+ renderPanels();
+ drag = { mode: 'move', ref, moved: false };
+ return;
+ }
+ selection = null;
+ emit('select', { ref: null });
+ rebuildHandles();
+ renderPanels();
+ drag = { mode: 'orbit', x: ev.clientX, y: ev.clientY };
+ });
+
+ canvas.addEventListener('pointermove', (ev) => {
+ const ground = raycastGround(ev);
+ if (activeTool?.onPointerMove?.(ev, ground)) return;
+ if (!drag) return;
+ if (drag.mode === 'orbit') {
+ cam.yaw -= (ev.clientX - drag.x) * 0.006;
+ cam.pitch += (ev.clientY - drag.y) * 0.005;
+ drag.x = ev.clientX; drag.y = ev.clientY;
+ applyCamera();
+ } else if (drag.mode === 'pan') {
+ const k = cam.dist * 0.0016;
+ const dx = (ev.clientX - drag.x) * k, dy = (ev.clientY - drag.y) * k;
+ cam.target.x += -Math.cos(cam.yaw) * dx + Math.sin(cam.yaw) * dy;
+ cam.target.z += Math.sin(cam.yaw) * dx + Math.cos(cam.yaw) * dy;
+ drag.x = ev.clientX; drag.y = ev.clientY;
+ applyCamera();
+ } else if (drag.mode === 'move' && ground) {
+ drag.moved = true;
+ moveTo(drag.ref, ground.x, ground.z);
+ renderPanels();
+ }
+ });
+
+ canvas.addEventListener('pointerup', (ev) => {
+ canvas.releasePointerCapture?.(ev.pointerId);
+ const ground = raycastGround(ev);
+ if (activeTool?.onPointerUp?.(ev, ground)) { drag = null; return; }
+ const wasMove = drag?.mode === 'move' && drag.moved;
+ drag = null;
+ // The rebuild is on DROP, not on every move: anchors have to come out of
+ // createWorld+dress to be honest, and that is a drop-cost, not a drag-cost.
+ if (wasMove) rebuild();
+ });
+
+ canvas.addEventListener('contextmenu', (ev) => ev.preventDefault());
+ canvas.addEventListener('wheel', (ev) => {
+ ev.preventDefault();
+ cam.dist *= Math.exp(ev.deltaY * 0.0012);
+ applyCamera();
+ }, { passive: false });
+
+ addEventListener('keydown', (ev) => {
+ if (/^(INPUT|TEXTAREA|SELECT)$/.test(ev.target?.tagName ?? '')) return;
+ if ((ev.key === 'Delete' || ev.key === 'Backspace') && selection) {
+ ev.preventDefault();
+ remove(selection);
+ }
+ if (ev.key === 'Escape') { armed = null; setTool(null); }
+ });
+
+ // =======================================================================
+ // Panels
+ // =======================================================================
+
+ const pSite = mountPanel({ id: 'site', title: 'SITE', order: 10 });
+ const pPalette = mountPanel({ id: 'palette', title: 'PLACE', order: 20 });
+ const pInspector = mountPanel({ id: 'inspector', title: 'SELECTED', order: 30 });
+ const pValidate = mountPanel({ id: 'validate', title: 'VALIDATION', order: 80 });
+ const pExport = mountPanel({ id: 'export', title: 'EXPORT', order: 90 });
+
+ const el = (tag, cls, text) => {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text != null) n.textContent = text;
+ return n;
+ };
+ function row(label, control) {
+ const r = el('div', 'ed-row');
+ r.append(el('span', 'ed-label', label), control);
+ return r;
+ }
+ function numInput(get, set, step = 0.1) {
+ const i = el('input', 'ed-num');
+ i.type = 'number'; i.step = String(step); i.value = String(get() ?? 0);
+ i.addEventListener('change', () => {
+ const v = Number(i.value);
+ if (Number.isFinite(v)) { set(round3(v)); rebuild(); }
+ });
+ return i;
+ }
+ function textInput(get, set) {
+ const i = el('input', 'ed-text');
+ i.type = 'text'; i.value = get() ?? '';
+ i.addEventListener('change', () => { set(i.value); markDirty(); });
+ return i;
+ }
+
+ /**
+ * `_design` / `_why` are edited as text and STORED as a string array, because
+ * that is how every shipped site stores them β JSON has no comments, so the
+ * repo's convention is one array element per line. Making the field visible
+ * is gate 1.3's actual requirement: the levels carry their design notes, and
+ * a note you have to remember to add is a note that doesn't get added.
+ */
+ function notesInput(key) {
+ const t = el('textarea', 'ed-text');
+ t.rows = 5;
+ t.value = Array.isArray(site[key]) ? site[key].join('\n') : (site[key] ?? '');
+ t.addEventListener('change', () => {
+ const lines = t.value.replace(/\s+$/, '').split('\n');
+ if (lines.length === 1 && !lines[0]) delete site[key];
+ else site[key] = lines;
+ markDirty();
+ });
+ return t;
+ }
+
+ function renderSitePanel() {
+ const b = pSite.body;
+ b.replaceChildren();
+ b.append(row('id', textInput(() => site.id, (v) => { site.id = v; })));
+ b.append(row('name', textInput(() => site.name, (v) => { site.name = v; })));
+
+ const blurb = el('textarea', 'ed-text');
+ blurb.rows = 3;
+ blurb.value = site.blurb ?? '';
+ blurb.addEventListener('change', () => { site.blurb = blurb.value; markDirty(); });
+ b.append(row('blurb', blurb));
+ b.append(el('p', 'ed-note',
+ 'The blurb carries real design load β night 3\'s "plenty to tie off to" is the '
+ + 'carport trap\'s cover story.'));
+
+ b.append(row('yard w', numInput(() => site.yard.width, (v) => { site.yard.width = v; }, 0.5)));
+ b.append(row('yard d', numInput(() => site.yard.depth, (v) => { site.yard.depth = v; }, 0.5)));
+ b.append(row('sun elev', numInput(() => site.sun.elevationDeg, (v) => { site.sun.elevationDeg = v; }, 1)));
+ b.append(row('sun azim', numInput(() => site.sun.azimuthDeg, (v) => { site.sun.azimuthDeg = v; }, 1)));
+
+ const sides = el('div', 'ed-row');
+ sides.append(el('span', 'ed-label', 'fence'));
+ for (const s of ['north', 'south', 'east', 'west']) {
+ const btn = el('button', 'ed-btn', s[0].toUpperCase());
+ const on = (site.fence?.sides ?? []).includes(s);
+ if (on) btn.classList.add('on');
+ btn.title = s;
+ btn.addEventListener('click', () => {
+ site.fence ??= { sides: [] };
+ const list = new Set(site.fence.sides ?? []);
+ if (list.has(s)) list.delete(s); else list.add(s);
+ // Fixed order, not click order: a fence list that reorders itself is a
+ // diff that looks like a change and isn't.
+ site.fence.sides = ['north', 'south', 'east', 'west'].filter((k) => list.has(k));
+ rebuild();
+ });
+ sides.append(btn);
+ }
+ b.append(sides);
+
+ b.append(el('p', 'ed-label', '_design'), notesInput('_design'));
+ b.append(el('p', 'ed-note', 'Why this yard exists and what you measured. Ships in the JSON.'));
+ b.append(el('p', 'ed-label', '_why'), notesInput('_why'));
+
+ const load = el('div', 'ed-row');
+ load.append(el('span', 'ed-label', 'load'));
+ for (const s of ['backyard_01', 'site_02_corner_block']) {
+ const btn = el('button', 'ed-btn', s === 'backyard_01' ? 'backyard' : 'corner');
+ btn.addEventListener('click', async () => {
+ try {
+ const def = await loadSite(s);
+ await setSite(structuredClone(def));
+ } catch (err) {
+ validationCache.errors.push(`load ${s}: ${err.message}`);
+ renderPanels();
+ }
+ });
+ load.append(btn);
+ }
+ const blank = el('button', 'ed-btn', 'template');
+ blank.addEventListener('click', () => setSite(emptyTemplate()));
+ load.append(blank);
+ b.append(load);
+ }
+
+ function renderPalettePanel() {
+ const b = pPalette.body;
+ b.replaceChildren();
+ for (const item of PALETTE) {
+ const exists = item.singleton && site[item.kind];
+ const btn = el('button', 'ed-btn', item.label);
+ btn.style.width = '100%';
+ btn.style.textAlign = 'left';
+ btn.style.marginBottom = '4px';
+ btn.title = item.hint;
+ if (armed === item) btn.classList.add('on');
+ if (exists) {
+ btn.disabled = true;
+ btn.textContent = `${item.label} (placed)`;
+ }
+ btn.addEventListener('click', () => {
+ armed = armed === item ? null : item;
+ setToolNone();
+ renderPanels();
+ });
+ b.append(btn);
+ }
+ b.append(el('p', 'ed-note', armed
+ ? `Click the ground to place: ${armed.label}. ${armed.hint}. ESC cancels.`
+ : 'Pick one, then click the ground. Drag a placed thing to move it; '
+ + 'Delete removes it. Right-drag or shift-drag pans, wheel zooms.'));
+ }
+
+ function setToolNone() { if (activeTool) setTool(null); }
+
+ function renderInspectorPanel() {
+ const b = pInspector.body;
+ b.replaceChildren();
+ const e = entryFor(selection);
+ if (!e) {
+ b.append(el('p', 'ed-note', 'Nothing selected. Click a marker in the yard.'));
+ const list = el('ul', 'ed-list');
+ for (const ref of allRefs()) {
+ const li = el('li');
+ li.append(el('span', null, labelFor(ref)));
+ const ent = entryFor(ref);
+ li.append(el('span', 'ed-kv', `${ent.x?.toFixed?.(1) ?? 'β'}, ${ent.z?.toFixed?.(1) ?? 'β'}`));
+ li.addEventListener('click', () => {
+ selection = ref;
+ emit('select', { ref });
+ rebuildHandles();
+ renderPanels();
+ });
+ list.append(li);
+ }
+ b.append(list);
+ return;
+ }
+ const head = el('div', 'ed-card-head');
+ head.append(el('span', null, labelFor(selection)));
+ if (e.model) head.append(el('span', 'ed-tag', e.model));
+ b.append(head);
+
+ b.append(row('x', numInput(() => e.x, (v) => { e.x = v; })));
+ b.append(row('z', numInput(() => e.z, (v) => { e.z = v; })));
+ if (Object.hasOwn(e, 'rotYDeg')) b.append(row('rotYΒ°', numInput(() => e.rotYDeg, (v) => { e.rotYDeg = v; }, 5)));
+ if (Object.hasOwn(e, 'h')) b.append(row('height', numInput(() => e.h, (v) => { e.h = v; }, 0.25)));
+ if (Object.hasOwn(e, 'trunkH')) b.append(row('trunkH', numInput(() => e.trunkH, (v) => { e.trunkH = v; }, 0.1)));
+ if (Object.hasOwn(e, 'anchorY')) b.append(row('anchorY', numInput(() => e.anchorY, (v) => { e.anchorY = v; }, 0.1)));
+ if (Object.hasOwn(e, 'phase')) b.append(row('phase', numInput(() => e.phase, (v) => { e.phase = v; }, 0.1)));
+ if (selection.kind === 'gardenBed') {
+ b.append(row('bed w', numInput(() => e.w, (v) => { e.w = v; }, 0.25)));
+ b.append(row('bed d', numInput(() => e.d, (v) => { e.d = v; }, 0.25)));
+ }
+ if (Object.hasOwn(e, 'collateralValue')) {
+ b.append(row('collateral $', numInput(() => e.collateralValue, (v) => { e.collateralValue = v; }, 5)));
+ }
+
+ if (e.anchors?.length) {
+ b.append(el('p', 'ed-note', 'anchors (live, from the dressed world):'));
+ const list = el('ul', 'ed-list');
+ for (const a of e.anchors) {
+ const live = world?.anchors.find((x) => x.id === a.id);
+ const li = el('li');
+ li.append(el('span', null, `${a.id} Β· ${a.type} Β· ${a.work}`));
+ const rh = live?.ratingHint;
+ const tag = el('span', 'ed-tag', live ? `hint ${(rh ?? 1).toFixed(2)}` : 'not built');
+ if (!live || !Object.hasOwn(live, 'collateral')) tag.classList.add('ed-err');
+ else if ((rh ?? 1) < 0.5) tag.classList.add('ed-warn');
+ li.append(tag);
+ list.append(li);
+ }
+ b.append(list);
+ }
+
+ if (!UNDELETABLE.has(selection.kind)) {
+ const del = el('button', 'ed-btn danger', `delete ${labelFor(selection)}`);
+ del.style.marginTop = '8px';
+ del.addEventListener('click', () => remove(selection));
+ b.append(del);
+ }
+ }
+
+ function renderValidationPanel() {
+ const b = pValidate.body;
+ b.replaceChildren();
+ const v = validationCache;
+ const head = el('div', 'ed-card-head');
+ head.append(el('span', v.ok ? 'ed-ok' : 'ed-err', v.ok ? 'VALID β the game can boot this' : `INVALID β ${v.errors.length} problem(s)`));
+ b.append(head);
+
+ if (!v.ok) {
+ const list = el('ul', 'ed-list');
+ for (const e of v.errors) list.append(el('li', 'ed-err', e));
+ b.append(list);
+ if (v.errors.some((e) => e.includes('no anchors at all'))) {
+ b.append(el('p', 'ed-note',
+ 'A yard with nothing to tie off to is not a level. Place a Post (or a tree, '
+ + 'or the house) and this goes green.'));
+ }
+ }
+ if (v.warnings.length) {
+ b.append(el('p', 'ed-warn', `${v.warnings.length} asset warning(s) β validateSite cannot see these:`));
+ const list = el('ul', 'ed-list');
+ for (const w of v.warnings) list.append(el('li', 'ed-warn', w));
+ b.append(list);
+ }
+ if (v.ok && !v.warnings.length) {
+ b.append(el('p', 'ed-note',
+ 'Validation is structural only: it says the game can BOOT this yard, not that '
+ + 'the yard is a game. That question is SCORE IT.'));
+ }
+ }
+
+ function renderExportPanel() {
+ const b = pExport.body;
+ b.replaceChildren();
+
+ const bar = el('div', 'ed-row');
+ const copy = el('button', 'ed-btn primary', 'copy JSON');
+ copy.addEventListener('click', async () => {
+ const text = exportJSON();
+ try {
+ await navigator.clipboard.writeText(text);
+ copy.textContent = 'copied β';
+ } catch {
+ copy.textContent = 'clipboard blocked β use download';
+ }
+ setTimeout(() => { copy.textContent = 'copy JSON'; }, 1800);
+ });
+ const dl = el('button', 'ed-btn', 'download');
+ dl.addEventListener('click', () => {
+ const blob = new Blob([exportJSON()], { type: 'application/json' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = `${site.id || 'site_new'}.json`;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
+ });
+ bar.append(copy, dl);
+ b.append(bar);
+
+ if (!validationCache.ok) {
+ b.append(el('p', 'ed-err',
+ 'This yard does NOT validate. It will still export β you should not lose work β '
+ + 'but under a loud "_INVALID" key listing every problem, so it cannot land in '
+ + 'data/sites/ looking clean.'));
+ }
+
+ // Gate 1.6 β export is not shipping, and the editor has to say so, because
+ // the one thing you cannot do from this page is the thing that makes a yard
+ // playable. docs/MANUAL.md, "authoring a site", step 6.
+ const card = el('div', 'ed-card');
+ card.append(el('div', 'ed-card-head', 'EXPORT β SHIPPED'));
+ const ol = document.createElement('ol');
+ ol.className = 'ed-note';
+ ol.style.paddingLeft = '18px';
+ ol.style.margin = '0';
+ for (const step of [
+ `Save the JSON as web/world/data/sites/${site.id || 'site_new'}.json.`,
+ 'A site becomes PLAYABLE only when a week.js NIGHTS entry names it β client, '
+ + 'addr, brief and storm live there, not here. Until then the yard exists and '
+ + 'nobody can reach it.',
+ 'boot({ site: \'' + (site.id || 'site_new') + '\' }) seeks the week to that night.',
+ 'Then run the gauntlet before calling it a level: SCORE IT here, then '
+ + 'tools/site_audit, gardenfly, storm_envelope β and a cold playthrough by '
+ + 'someone who did not author it.',
+ ]) {
+ const li = document.createElement('li');
+ li.textContent = step;
+ ol.append(li);
+ }
+ card.append(ol);
+ b.append(card);
+
+ const pre = el('pre', 'ed-note');
+ pre.style.cssText = 'max-height:220px;overflow:auto;background:#0e1418;padding:8px;border-radius:4px;margin:8px 0 0;white-space:pre;';
+ pre.textContent = exportJSON();
+ b.append(pre);
+ }
+
+ function renderToolbar() {
+ toolbar.replaceChildren();
+ for (const spec of tools.values()) {
+ const btn = el('button', 'ed-btn', spec.label ?? spec.id);
+ if (activeTool === spec) btn.classList.add('on');
+ btn.addEventListener('click', () => setTool(activeTool === spec ? null : spec.id));
+ toolbar.append(btn);
+ }
+ }
+
+ let renderQueued = false;
+ function renderPanels() {
+ if (renderQueued) return;
+ renderQueued = true;
+ queueMicrotask(() => {
+ renderQueued = false;
+ renderSitePanel();
+ renderPalettePanel();
+ renderInspectorPanel();
+ renderValidationPanel();
+ renderExportPanel();
+ });
+ }
+
+ function markDirty() {
+ revalidate();
+ rebuildHandles();
+ renderPanels();
+ emit('change', { site });
+ }
+
+ function exportJSON() { return exportSiteJSON(site, validationCache); }
+
+ async function setSite(next) {
+ site = next;
+ selection = null;
+ emit('siteload', { site });
+ await rebuild();
+ // The camera frames whatever yard just arrived rather than whatever yard
+ // the last one was β a 24x16 corner block viewed from a 30x20 framing reads
+ // as a mistake you then chase in the data.
+ cam.target.set(0, 1, 0);
+ cam.dist = Math.max(site.yard.width, site.yard.depth) * 1.15;
+ applyCamera();
+ return world;
+ }
+
+ // --- the loop ----------------------------------------------------------
+
+ function resize() {
+ const w = canvas.clientWidth || 1;
+ const h = canvas.clientHeight || 1;
+ renderer.setSize(w, h, false);
+ camera.aspect = w / Math.max(1, h);
+ camera.updateProjectionMatrix();
+ }
+ addEventListener('resize', resize);
+
+ let t = 0;
+ renderer.setAnimationLoop(() => {
+ t += 1 / 60;
+ // The editor is a TOOL: this loop is for looking, not for simulating, so it
+ // does not pretend to be the game's fixed-dt chain. Nothing here feeds a
+ // number anyone scores.
+ if (world && !rebuilding) world.update(1 / 60, t);
+ readout.textContent =
+ `${site.id} ${site.yard.width}Γ${site.yard.depth} m\n`
+ + `anchors ${world?.anchors.length ?? 0} ${validationCache.ok ? 'valid' : 'INVALID'}\n`
+ + `rebuild ${rebuildMs.toFixed(0)} ms ${lastStatus}`;
+ renderer.render(scene, camera);
+ });
+
+ hint.textContent =
+ 'drag empty ground = orbit Β· shift/right-drag = pan Β· wheel = zoom\n'
+ + 'click a marker = select Β· drag it = move Β· Delete = remove';
+
+ // --- the object ---------------------------------------------------------
+
+ const api = {
+ // three.js handles
+ scene, renderer, camera, overlay,
+ get world() { return world; },
+ // site
+ get site() { return site; },
+ siteClone: () => canonical(structuredClone(site), '$root'),
+ setSite,
+ rebuild,
+ markDirty,
+ validation: () => ({ ...validationCache }),
+ exportJSON,
+ // selection
+ get selection() { return selection; },
+ select(ref) {
+ selection = ref;
+ emit('select', { ref });
+ rebuildHandles();
+ renderPanels();
+ },
+ // seams
+ mountPanel,
+ raycastGround,
+ registerTool,
+ setTool,
+ get tool() { return activeTool?.id ?? null; },
+ on(evt, fn) { listeners.set(evt, [...(listeners.get(evt) ?? []), fn]); return () => api.off(evt, fn); },
+ off(evt, fn) { listeners.set(evt, (listeners.get(evt) ?? []).filter((f) => f !== fn)); },
+ // constants other lanes need
+ HEIGHT_AT: heightAt,
+ PALETTE,
+ ANCHOR_TYPE,
+ };
+
+ globalThis.EDITOR = api;
+
+ resize();
+ applyCamera();
+
+ const startSite = new URLSearchParams(location.search).get('site');
+ if (startSite) {
+ try {
+ await setSite(structuredClone(await loadSite(startSite)));
+ } catch (err) {
+ await setSite(emptyTemplate());
+ validationCache.errors.push(`?site=${startSite}: ${err.message}`);
+ renderPanels();
+ }
+ } else {
+ await setSite(emptyTemplate());
+ }
+
+ return api;
+}
diff --git a/web/world/js/world.js b/web/world/js/world.js
index 688e48d..458b821 100644
--- a/web/world/js/world.js
+++ b/web/world/js/world.js
@@ -197,7 +197,24 @@ export function createWorld(scene, opts = {}) {
const GARDEN_BED = site.gardenBed;
const SUN_DIR = sunDirOf(site);
const HOUSE = site.house;
- const SHED = site.shed;
+ // SPRINT14 β `rotY` was MISSING here, and it made the shed invisible.
+ //
+ // Every other rotatable prop gets its degrees converted on the way in
+ // (SHED_TABLE, GNOME, BIKE, all one line below); the shed alone was bound
+ // raw, so `SHED.rotY` was `undefined`, `shed.rotation.y = undefined` gave the
+ // object an all-NaN quaternion, and three.js quietly declines to draw a mesh
+ // whose world matrix is NaN. No throw, no warning, nothing on the console β
+ // the Hendersons' shed has simply never been in the yard, through every
+ // sprint and onto the public URL, while `ladder.js` has been parking the
+ // ladder "leaning on the shed" against thin air and the camera has been
+ // colliding with it (it is in `solids`).
+ //
+ // Found by opening backyard_01 in the yard editor and noticing an object in
+ // the scene graph that was not on the glass. Proven by assigning a real
+ // radian value at runtime and watching the shed appear. There is now an
+ // a.test assert on the composed matrix, because "nothing is NaN" is exactly
+ // the kind of claim that has to be able to fail.
+ const SHED = site.shed ? { ...site.shed, rotY: rad(site.shed.rotYDeg) } : null;
const SHED_TABLE = site.shedTable ? { ...site.shedTable, rotY: rad(site.shedTable.rotYDeg) } : null;
const GNOME = site.gnome ? { ...site.gnome, rotY: rad(site.gnome.rotYDeg) } : null;
// SPRINT12: the per-client prop, the gnome pattern β a named top-level key,
@@ -280,36 +297,50 @@ export function createWorld(scene, opts = {}) {
// Rear wall sits exactly on z = -10 so the fascia anchors have a round
// number to live on. Lane E's house_yardside.glb replaces this group and
// should keep fascia_anchor_* at these positions.
- const house = new THREE.Group();
- house.name = 'house_yardside';
- const wall = new THREE.Mesh(
- new THREE.BoxGeometry(16, 3.0, 6),
- new THREE.MeshStandardMaterial({ color: COLORS.house, roughness: 0.85 }),
- );
- wall.position.set(0, 1.5, -13);
- wall.castShadow = true;
- wall.receiveShadow = true;
- house.add(wall);
+ //
+ // SPRINT14 β ONLY WHEN THE SITE DECLARES A HOUSE. This graybox used to be
+ // built unconditionally, and dress() only retires it when a house GLB loads,
+ // so a site with no `house` key kept it forever. site_02_corner_block has no
+ // house β it has two streets β and has therefore been shipping a 16 Γ 3 Γ 6 m
+ // featureless grey slab across the whole north horizon of night 3, in
+ // `solids`, on the public URL. Found by opening the corner block in the yard
+ // editor and standing in it at eye height; nothing else in the repo looks
+ // north from inside that yard, which is why five sprints of green tests never
+ // said a word. The anchor loop below was already `HOUSE?.anchors ?? []` β the
+ // data path was guarded and the geometry path was not.
+ let house = null;
+ if (HOUSE) {
+ house = new THREE.Group();
+ house.name = 'house_yardside';
+ const wall = new THREE.Mesh(
+ new THREE.BoxGeometry(16, 3.0, 6),
+ new THREE.MeshStandardMaterial({ color: COLORS.house, roughness: 0.85 }),
+ );
+ wall.position.set(0, 1.5, -13);
+ wall.castShadow = true;
+ wall.receiveShadow = true;
+ house.add(wall);
- const roof = new THREE.Mesh(
- new THREE.BoxGeometry(16.8, 0.22, 6.8),
- new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }),
- );
- roof.position.set(0, 3.1, -13);
- roof.castShadow = true;
- house.add(roof);
+ const roof = new THREE.Mesh(
+ new THREE.BoxGeometry(16.8, 0.22, 6.8),
+ new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }),
+ );
+ roof.position.set(0, 3.1, -13);
+ roof.castShadow = true;
+ house.add(roof);
- // The fascia line β the lie the player will be tempted by (DESIGN.md).
- const fascia = new THREE.Mesh(
- new THREE.BoxGeometry(16, 0.24, 0.12),
- new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }),
- );
- fascia.position.set(0, 2.72, -9.98);
- house.add(fascia);
+ // The fascia line β the lie the player will be tempted by (DESIGN.md).
+ const fascia = new THREE.Mesh(
+ new THREE.BoxGeometry(16, 0.24, 0.12),
+ new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }),
+ );
+ fascia.position.set(0, 2.72, -9.98);
+ house.add(fascia);
- root.add(house);
- solids.push(wall, roof);
- graybox.house = house;
+ root.add(house);
+ solids.push(wall, roof);
+ graybox.house = house;
+ }
// Graybox stand-ins at the graybox's own spacing. dress() moves every one of
// these onto the position Lane E baked, which is why the numbers here don't
@@ -558,9 +589,17 @@ export function createWorld(scene, opts = {}) {
// and the selftest build a yard without a server β and Lane D's
// wireYardActions reads world.shedTable at wiring time. dress() refines the
// point to Lane E's baked `pickup_anchor` if it's there.
- const shedTable = {
+ // SPRINT14: null when the site declares no table, rather than throwing on
+ // `SHED_TABLE.x`. Both shipped sites have one, so this had never been reached
+ // β the editor reaches it on its first frame, because the empty template has
+ // no shed and you can delete the table off a yard. Every consumer already
+ // guards it (`if (world.shedTable)` in interact.js, ladder.js, broom.js,
+ // whose comment even says "until it lands, the pickup self-skips"), so the
+ // null branch was designed for and merely unreachable. `shedTable` is not in
+ // CONTRACT.world, so this stays contract-legal.
+ const shedTable = SHED_TABLE ? {
pos: new THREE.Vector3(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z) + 0.9, SHED_TABLE.z),
- };
+ } : null;
/**
* Show one of E's three wilt states. No-op against the graybox bed, so the