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