From 16dee119d6007a9c844e41eb708fce33ff2e1050 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 15:46:01 +1000 Subject: [PATCH] Sprint 14 gate 1: the yard editor core + two shipped bugs it found web/world/editor.html + js/editor.js. Loads any existing site or an empty template and renders the REAL dressed world through the same loadSite/createWorld/dress path the game boots -- not a diagram, and no second renderer to keep in sync. Place/drag/delete on the ground plane by raycast, writing the SAME named-key site-JSON shapes world.js already reads (the gnome pattern; there is deliberately no generic props[] and the editor does not invent one). Live validateSite into a panel, canonical stable-key-order export with a loud _INVALID key, visible _design/_why authoring fields, and an export panel that says export != shipped. Published the DOM seam contract in editor.html's header so B's SCORE IT and C's wind authoring can start against it immediately -- mountPanel + a class kit, the way E's .letterhead kit worked in Sprint 12. Two rendering bugs in world.js that the editor surfaced in its first ten minutes, both verified by looking at the yard rather than by reasoning: - the graybox house was built unconditionally and dress() only retires it when a house GLB loads, so site_02_corner_block -- which declares no house -- has been shipping a 16x3x6 m grey slab across night 3's whole north horizon, in solids, on the public URL. - SHED alone was bound raw while SHED_TABLE/GNOME/BIKE all get their degrees converted, so SHED.rotY was undefined, the shed's quaternion was all NaN, and three.js silently declines to draw a NaN world matrix. The Hendersons' shed has never rendered; ladder.js has been leaning the ladder on thin air. Also: createWorld threw on a site with no shedTable (unreachable for both shipped sites, reached by the editor template on its first frame). Now null, which every consumer already guards and which CONTRACT.world permits. Selftest 362/0/0. Asserts for the two fixes land with the round-trip test in the next commit -- pushing now because B and C are blocked on the seam. Co-Authored-By: Claude Fable 5 --- THREADS.md | 69 +++ web/world/editor.html | 216 +++++++ web/world/js/editor.js | 1248 ++++++++++++++++++++++++++++++++++++++++ web/world/js/world.js | 99 +++- 4 files changed, 1602 insertions(+), 30 deletions(-) create mode 100644 web/world/editor.html create mode 100644 web/world/js/editor.js 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