/** * 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', 'collateralKey', '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'); let out = body; // Bounds faults are the SECOND loud key (SPRINT15 gate 3.2): the game will // boot this yard — validateSite is structural — but an object outside the // fence is almost never what the author meant, and "the yard still reads // VALID" was exactly D's complaint. Not folded into _INVALID because that // key's sentence ("the game cannot boot it") would then sometimes lie. const bounds = validation?.bounds ?? []; if (bounds.length) { out = { _OUT_OF_BOUNDS: [ 'These sit outside the yard rectangle. The game boots this, but check it', 'is what you meant before it goes near web/world/data/sites/:', ...bounds, ], ...out }; } if (validation && !validation.ok) { out = { _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, ], ...out }; } return `${JSON.stringify(out, null, 2)}\n`; } /** * Everything placed outside the yard rectangle, as human sentences. * Centre-point check on purpose: footprint-aware bounds need per-model dims * the palette doesn't carry, and D's gate-3 repro (a placement metres past * the fence, silent) is a centre-point miss. The garden bed is the exception * — it's an axis-aligned rect whose w/d we DO know, so its edges count. * Exported for a.test. [SPRINT15 gate 3.2] */ export function boundsFaults(site) { const w = site.yard?.width, d = site.yard?.depth; if (!(w > 0) || !(d > 0)) return []; const hw = w / 2, hd = d / 2; const out = []; const check = (label, x, z, exX = 0, exZ = 0) => { if (!Number.isFinite(x) || !Number.isFinite(z)) return; if (Math.abs(x) + exX > hw + 1e-9 || Math.abs(z) + exZ > hd + 1e-9) { out.push(`${label} at (${x}, ${z}) is outside the ${w}×${d} m yard`); } }; for (const p of site.posts ?? []) check(`post ${p.id}`, p.x, p.z); for (const t of site.trees ?? []) check(`tree ${t.id}`, t.x, t.z); for (const s of site.structures ?? []) check(`structure ${s.id}`, s.x, s.z); for (const k of ['house', 'shed', 'shedTable', 'gnome', 'bike']) { const e = site[k]; if (e) check(k, e.x, e.z); } const g = site.gardenBed; if (g) check('the garden bed', g.x, g.z, (g.w ?? 0) / 2, (g.d ?? 0) / 2); return out; } /** 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. */ export 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, // `collateralKey` is written EXPLICITLY and is not optional here. The // anchors say `collateral: "carport"`; the structure's id is whatever // the editor generated (s1, s2, …). Without this line the $180 prices to // null and the trap becomes a free failure — E caught it, and it is the // gutter bug wearing the editor's hat. site JSON canonical, GLB extra as // the fallback: the same contract as the price beside it. collateralKey: 'carport', 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' }, ], }), }, { // SPRINT14 gate 3.1, E's "honest middle". Two apex anchors at 0.45 — // better steel than the house fascia (0.35), worse than a gum fork — and a // crossbar that is the most anchor-looking object in the game and carries // `tie_off: false`. $140 is E's number, ADOPTED unchanged; the ruling and // the one condition for revisiting it are in THREADS. // ⚠️ The `_v1` suffix is LOAD-BEARING and its absence is silent. These two // entries shipped without it (SPRINT14, D's cold pass): the GLB 404s, the // prop never draws, and because `adoptAnchor` does `rating_hint ?? 1` a // MISSING MODEL BECOMES THE BEST STEEL IN THE GAME — the swing apexes read // 1.00 instead of E's 0.45, the jacaranda 1.00/1.00/1.00 instead of // 0.95/0.52/0.40 — and the yard still validates VALID. Same species as E's // `tie_off` audit: silence is not neutral where a default is generous. kind: 'structure', label: 'Swing set (the honest middle)', model: 'swing_set_01_v1', requires: ['swing_frame'], hint: 'apex anchors 0.45; the crossbar LOOKS like the answer and is not an anchor', make: (id, x, z) => ({ id, model: 'swing_set_01_v1', wreckedModel: 'swing_set_01_wrecked_v1', x, z, rotYDeg: 0, solid: false, collateralKey: 'swing_set', collateralValue: 140, collateralLabel: 'the swing set', anchors: [1, 2].map((i) => ({ id: `${id}_f${i}`, node: `frame_anchor_0${i}`, type: 'swing_frame', work: 'cloth', })), }), }, { // The ladder IS the feature: 0.95 low, then 0.52 / 0.40 — reach for height // on a jacaranda and you pay 58% for it, against the gum's 24%. Unpriced by // E's own ruling (no limb-failure event to watch; billing an unseen event // is the lie the invoice exists to kill). Same node names as the gums. kind: 'tree', label: 'Jacaranda (height costs)', model: 'tree_jacaranda_01_v1', hint: 'forks low and strong, then falls off a cliff — the "which tree" question', make: (id, x, z) => ({ id, model: 'tree_jacaranda_01_v1', x, z, phase: 1.1, trunkH: 3.4, anchorY: 2.4, anchors: [1, 2, 3].map((i) => ({ id: i === 1 ? id : `${id}${'abc'[i - 1]}`, node: `branch_anchor_0${i}`, type: 'tree', work: 'cloth', })), }), }, // SPRINT16 gate 5 — E's three palette entries, applied verbatim by the // integrator at merge (A delegated in THREADS; hunks filed by E with the // _v1 suffixes present and load-bearing, per D's S14 find). { // SPRINT16 gate 5, E. The ladder's middle rung, D-endorsed in S15: bolted // to a deck — better than loose-on-grass (0.45), worse than concrete // (1.00). One type, one rating, so the manifest resolves it node-less. kind: 'structure', label: 'Pergola (the middle rung)', model: 'pergola_01_v1', requires: ['pergola'], hint: 'two bolted corners at 0.65 — the honest step between swing frame and post', make: (id, x, z) => ({ id, model: 'pergola_01_v1', wreckedModel: 'pergola_01_wrecked_v1', x, z, rotYDeg: 0, solid: true, collateralKey: 'pergola', collateralValue: 120, collateralLabel: 'the pergola', anchors: [1, 2].map((i) => ({ id: `${id}_a${i}`, node: `pergola_anchor_0${i}`, type: 'pergola', work: 'cloth', })), }), }, { // SPRINT17 gate 3.1, E. The corroded tier — the pool yard's other half. // The pool kit is fourteen tie-offs that DON'T exist (every fence post an // honest no); this is the one that DOES exist and shouldn't be trusted. // 0.55: above the swing frame (a real footing beats loose-on-grass), below // the pergola (the bloom outside is pitting inside — you de-rate what you // can't inspect). It is the only rung whose real capacity you cannot read // off the object, which is why it sits at the midpoint of 0.45→0.65. // ⚠️ `_v1` is LOAD-BEARING and its absence is SILENT: adoptAnchor does // `rating_hint ?? 1`, so a missing model does not fail — it becomes the // BEST STEEL IN THE GAME. A typo here rates the corroded post 1.00 and the // trap inverts into the safest anchor in the yard. (S14, D's cold pass.) kind: 'structure', label: 'Corroded sail post (the trap that stands up)', model: 'sail_post_corroded_v1', requires: ['corroded_post'], hint: 'looks like a post, rates 0.55 — rust at the base and head, and the pad eye has sagged', make: (id, x, z) => ({ id, model: 'sail_post_corroded_v1', wreckedModel: 'sail_post_corroded_wrecked_v1', x, z, rotYDeg: 0, solid: true, collateralKey: 'corroded_post', collateralValue: 45, collateralLabel: 'the corroded post', anchors: [ { id: `${id}_a1`, node: 'top_anchor', type: 'corroded_post', work: 'cloth' }, ], }), }, { // The collateral apex. NO anchors, on purpose — nothing about a glasshouse // may adopt (the ridge carries tie_off:false in the GLB). collateralKey is // EXPLICIT so the second one placed still bills (the carport lesson). kind: 'structure', label: 'Glasshouse (the apex, $320)', model: 'glasshouse_01_v1', hint: 'no anchors at all; $320 of glass — the thing you keep the whole rig away from', make: (id, x, z) => ({ id, model: 'glasshouse_01_v1', wreckedModel: 'glasshouse_01_wrecked_v1', x, z, rotYDeg: 0, solid: true, collateralKey: 'glasshouse', collateralValue: 320, collateralLabel: 'the glasshouse', anchors: [], }), }, { // The compliance ring. Fourteen posts, every one tie_off:false in the GLB; // unpriced BY RULING (the bike rule — price it when debris can bend it). kind: 'structure', label: 'Pool + fence ring', model: 'pool_kit_01_v1', hint: 'fourteen perfect-looking posts, every one an honest no — the legal tie-offs are elsewhere', make: (id, x, z) => ({ id, model: 'pool_kit_01_v1', x, z, rotYDeg: 0, solid: true, anchors: [], }), }, { 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. */ export 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' }; /** * Unique id in the namespace this kind occupies, e.g. p1, p2, t1, s1. * @param {object} site @param {string} kind */ export function nextId(site, 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()}`; } /** * Write one palette item into a site at (x, z). Pure-ish: mutates `site` and * returns the ref, touching nothing else. * * Extracted from the click handler on purpose — a.test's round-trip places its * yard through THIS function, so the test exercises the code the mouse runs * rather than a test-shaped imitation of it. A round-trip assert against a * separate placement path would prove nothing about the editor. * * @returns {{kind:string, id?:string}} */ export function placeEntry(site, item, x, z) { const key = ARRAY_KIND[item.kind]; const id = key ? nextId(site, item.kind) : null; const entry = item.make(id, round3(x), round3(z)); if (key) { site[key] ??= []; site[key].push(entry); } else { site[item.kind] = entry; } return key ? { kind: item.kind, id } : { kind: item.kind }; } 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: [], bounds: [] }; 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'); } // E's `tie_off: false` (SPRINT14 gate 3.1). A door step, a bench top, a // broom grip and a lighting hint are not steel, but `adoptAnchor` reads // `rating_hint ?? 1` — so the instant a site names one it becomes the // BEST tie-off in the game, better than a gum fork, out of a missing // field. The palette doesn't offer them, but a hand-edited site or an // imported yard can still name one, and this is the only place that can // see it: `validateSite` never opens the GLB. if (live.tieOff === false) { out.push(`anchor ${a.id}: node "${a.node}" in ${model} is NOT a tie-off ` + '(tie_off: false — it is a step/benchtop/grip, not steel). Rigging to it would ' + 'hand you the strongest anchor in the yard by accident. Remove it.'); } } 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(), // bounds ride in the cache so the header, the panel and the export all // read ONE measurement — three surfaces disagreeing about where the // fence is would be the two-harness disease in miniature. bounds: boundsFaults(site), }; 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 yard rectangle (gate 3.2). Amber, not red: out-of-bounds is a warning // state and the line itself is neutral information. const BOUNDS_MAT = new THREE.LineBasicMaterial({ color: 0xd9a94a, transparent: true, opacity: 0.55 }); /** * 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(); // The yard rectangle, drawn as a line at ankle height — the fence only // draws on sides the site enables, so "where do the bounds end" was // invisible on open sides and objects placed past them silently (D's // gate-3.2). Rebuilt with the handles because yard w/d are editable. { const hw = (site.yard?.width ?? 0) / 2, hd = (site.yard?.depth ?? 0) / 2; if (hw > 0 && hd > 0) { const pts = [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]].map( ([x, z]) => new THREE.Vector3(x, heightAt(x, z) + 0.06, z), ); const loop = new THREE.LineLoop( new THREE.BufferGeometry().setFromPoints(pts), BOUNDS_MAT, ); loop.renderOrder = 1; handles.add(loop); } } 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 ---------------------------------------- function place(paletteItem, x, z) { selection = placeEntry(site, paletteItem, x, z); 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 }; } /** * How near the ground point counts as "you meant that one", in metres. A * shade post is ~0.1 m of actual geometry, so picking has to be generous or * authoring becomes a game of hunting pixels. */ const PICK_GROUND_RADIUS = 1.3; /** * What did the user click on? * * TWO passes, and the second is not a nicety — it is the fix for a bug this * file shipped with for an afternoon. Pass one raycasts the invisible body * handles, which is right when you click an object's MIDDLE. But a handle is * a cylinder centred 1.7 m up, and from a raised editor camera the ray * through the pixel at an object's BASE has already travelled ~2 m * horizontally by the time it climbs to that height — so it misses the * cylinder completely. Clicking the foot of the carport, which is where the * eye goes and is the very pixel you clicked to PLACE it, selected nothing. * * Pass two asks the question the user is actually asking: of everything * placed, which is nearest to the patch of ground I clicked? That is * camera-angle independent, so it cannot rot the way the raycast did the * moment the pitch changed. * * Found by driving real pointer events at the page rather than by reading * the code: the handles were provably in the scene, at provably the right * positions, and the raycast still returned nothing. */ 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; const g = raycastGround(ev); if (!g) return null; let best = null; let bestD = PICK_GROUND_RADIUS; for (const ref of allRefs()) { const e = entryFor(ref); if (!e) continue; const d = Math.hypot(e.x - g.x, e.z - g.z); if (d < bestD) { bestD = d; best = ref; } } return best; } /** * 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); } // Keyboard zoom (D's gate-3 friction: wheel-only). Same curve as the // wheel handler — one Exp step ≈ three notches — so the two agree. if (ev.key === '+' || ev.key === '=') { cam.dist *= Math.exp(-0.12); applyCamera(); } if (ev.key === '-' || ev.key === '_') { cam.dist *= Math.exp(0.12); applyCamera(); } }); // ======================================================================= // 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); // Commit on Enter AND change (D's gate-3 friction: "Enter does nothing, // and a house sat on the garden bed while the panel read the new value"). // Idempotent on purpose — Enter then blur fires both paths, and a no-op // commit must not pay a rebuild. const commit = () => { const v = Number(i.value); if (!Number.isFinite(v) || round3(v) === get()) return; set(round3(v)); rebuild(); }; i.addEventListener('change', commit); i.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } }); return i; } function textInput(get, set) { const i = el('input', 'ed-text'); i.type = 'text'; i.value = get() ?? ''; const commit = () => { if (i.value === (get() ?? '')) return; // same idempotence as numInput set(i.value); markDirty(); }; i.addEventListener('change', commit); i.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } }); 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); // Gate 3.4's ruling, said where an author would look for the field: there // is deliberately NO separation input. A `separation` block is a MEASURED // promise (gardenfly's held-vs-bare, through the real chain), and a // hand-typed one would be a wish wearing a pin's clothes. SCORE IT // measures it; the pin lands at integration (MANUAL, site cookbook §5). b.append(el('p', 'ed-note', 'No separation field, on purpose: separation is a measured pin, not an ' + 'authored wish. Run SCORE IT and pin the measured number at integration.')); 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); } /** * The palette can only offer what the CHECKED enum will accept. * * `requires` names the anchor types an item writes. If contracts.js doesn't * carry one yet, the item is hidden rather than offered — placing it would * produce a site `validateSite` rejects, i.e. the editor handing you a yard * the game can't boot, which is the one thing gate 1 exists to prevent. * * It also makes the cross-lane seam self-healing: E's swing set types * `swing_frame`, and it appears in this palette the moment their widening of * ANCHOR_TYPE lands in the merge — no follow-up commit, and no window where * the button exists and the enum doesn't. */ const availablePalette = () => PALETTE.filter((item) => (item.requires ?? []).every((ty) => ANCHOR_TYPE.includes(ty))); function renderPalettePanel() { const b = pPalette.body; b.replaceChildren(); for (const item of availablePalette()) { 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.bounds?.length) { b.append(el('p', 'ed-warn', `${v.bounds.length} outside the yard — the game boots this, but is it what you meant?`)); const list = el('ul', 'ed-list'); for (const w of v.bounds) list.append(el('li', 'ed-warn', w)); b.append(list); } if (v.ok && !v.warnings.length && !v.bounds?.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 ? (validationCache.bounds?.length ? `valid · ${validationCache.bounds.length} OUTSIDE BOUNDS` : '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; }