Gate 1.1 RULED in THREADS: the honest post is 1.00 — the shipped value becomes canon, E re-bakes to match, and the adoption mechanism must make a baked hint reach JSON-declared anchors (same integration, no 0.90 window). B's re-measure becomes a zero-delta verification. Gate 3: numInput/textInput commit on Enter (idempotent — a no-op commit pays no rebuild); boundsFaults() rides the validation cache so header, panel and export read one measurement, export leads with _OUT_OF_BOUNDS (not _INVALID — that key's sentence would lie); the yard rectangle draws as an amber line where fence-off sides hid it; +/- zoom on the wheel's own curve; separation ruled a measured pin with the SITE panel saying so. New a.test mutation-checked both ways in the browser harness. 410/0/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1496 lines
61 KiB
JavaScript
1496 lines
61 KiB
JavaScript
/**
|
||
* 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',
|
||
})),
|
||
}),
|
||
},
|
||
{
|
||
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;
|
||
}
|