Sites are data: extract backyard_01.json; world.js builds from it

SPRINT10 gate 1a, carried since Sprint 8. Everything that was a module constant
— bed rect, house line, tree and post placements, shed, gnome, sun angle, fence
sides — is now data/sites/backyard_01.json, and world.js is a builder.

The extraction is byte-identical, and that's checkable rather than claimable:
E's branch-anchor tripwires pin five positions to 6 dp, a.test's quad-band
asserts pin the whole rigging geometry (>=3 quads in 18-45 m2, full coverage
still >45 m2), D's ladder field and B's balance lines all pass unchanged.
287/0/0.

It's byte-identical by construction, not by luck: the site-derived values keep
the builder's original local names, so ~500 lines of geometry that was already
correct is untouched and only its SOURCE changed.

Two things earned their place:

Anchors carry D's `work: "cloth" | "bracket"` — the MECHANISM, not the type. A
post is tensioned from a cleat at its base and a tree strop is thrown, but a
bracket bolted up a bare wall is the one you cannot fake. Keyed on mechanism
because `type` is a closed enum ('house'|'tree'|'post') that site_02's carport
doesn't fit, and needsLadder keyed on type FAILS OPEN there — the ladder
silently vanishes and you re-rig a 2.6 m bracket standing on the grass.

p4's swept position table moved INTO the JSON's `_why`, beside the coordinate it
justifies. That table is the only thing stopping someone nudging the post toward
the bed and collapsing DESIGN.md's central tension; it belongs with the number,
not in a file the number left.

validateSite() fails loud: sites are content, and a missing bed silently becomes
NaN coverage while a missing anchor list silently becomes an unriggable site.
createWorld now requires a site and says so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 15:34:29 +10:00
parent 63beec87a7
commit b98dcad13a
5 changed files with 259 additions and 95 deletions

View File

@ -0,0 +1,86 @@
{
"id": "backyard_01",
"name": "The Backyard",
"blurb": "Your own place. You know where everything is, which is the only advantage you get.",
"yard": { "width": 30, "depth": 20 },
"sun": { "elevationDeg": 55, "azimuthDeg": -125 },
"gardenBed": { "x": 1, "z": 2, "w": 6, "d": 4 },
"house": {
"model": "house_yardside_v1",
"x": 0, "z": -10.5,
"anchors": [
{ "id": "h1", "node": "fascia_anchor_01", "type": "house", "work": "bracket" },
{ "id": "h2", "node": "fascia_anchor_02", "type": "house", "work": "bracket" },
{ "id": "h3", "node": "fascia_anchor_03", "type": "house", "work": "bracket" }
]
},
"trees": [
{
"id": "t1", "model": "tree_gum_01_v1",
"x": -9, "z": 2, "phase": 0.7, "trunkH": 4.2, "anchorY": 3.4,
"anchors": [
{ "id": "t1", "node": "branch_anchor_01", "type": "tree", "work": "cloth" },
{ "id": "t1b", "node": "branch_anchor_02", "type": "tree", "work": "cloth" },
{ "id": "t1c", "node": "branch_anchor_03", "type": "tree", "work": "cloth" }
]
},
{
"id": "t2", "model": "tree_gum_02_v1",
"x": 8, "z": -2, "phase": 2.9, "trunkH": 3.8, "anchorY": 3.1,
"anchors": [
{ "id": "t2", "node": "branch_anchor_01", "type": "tree", "work": "cloth" },
{ "id": "t2b", "node": "branch_anchor_02", "type": "tree", "work": "cloth" }
]
}
],
"posts": [
{ "id": "p1", "x": -4.5, "z": 5.5, "h": 4.0, "type": "post", "work": "cloth" },
{ "id": "p2", "x": 4.0, "z": 6.0, "h": 4.0, "type": "post", "work": "cloth" },
{ "id": "p3", "x": 0, "z": 7.0, "h": 4.0, "type": "post", "work": "cloth" },
{
"id": "p4", "x": -3.2, "z": -1.2, "h": 4.0, "type": "post", "work": "cloth",
"_why": [
"DO NOT move this post toward the bed. Its position is swept, not chosen, and it is the",
"single number holding DESIGN.md's central tension open.",
"",
"Before p4 the wild night had NO winnable line, for a geometric reason: every anchor near",
"the bed (p1/p2/p3) is SOUTH of it and nothing stands north short of the house 10 m away,",
"so covering rigs had to span the yard and died while rigs small enough to survive sat",
"beside the bed rather than over it. p4 supplies the missing north-west corner.",
"",
"Swept against the smallest quad covering 90% of the bed:",
" (-2.2, -1.2) -> 44.4 m2 DO NOT: full coverage lands INSIDE the survivable band,",
" so covering the bed stops costing risk and the rigging",
" puzzle loses its wrong answers.",
" (-3.2, -1.2) -> 48.6 m2 <- here (with the 8 deg rake the top anchor lands at",
" -3.72,-1.40 and full coverage costs 51.6 m2)",
" (-3.2, -2.0) -> 51.7 m2",
" (-4.2, -3.0) -> 60.0 m2",
" beyond z=-4 -> 63.5 m2, i.e. too far out to matter at all",
"",
"Full coverage costs 63.5 m2 without it and 51.6 with: a real new option, still bigger",
"than the 23-38 m2 rigs that survive unaided. a.test.js asserts the >45 m2 floor, so",
"moving this inward goes red — that assert is the tension's only guard."
]
}
],
"shed": { "model": "shed_01_v1", "x": 11.8, "z": 6.2, "rotYDeg": -90 },
"shedTable": { "model": "shed_table_v1", "x": 9, "z": 6, "rotYDeg": -90, "pickupNode": "pickup_anchor" },
"gnome": { "model": "garden_gnome_01_v1", "x": 4.3, "z": 4.4, "rotYDeg": -126, "collateralValue": 25 },
"fence": {
"sides": ["south", "west", "east"]
},
"wind": {
"venturi": []
}
}

View File

@ -15,7 +15,7 @@
import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld } from './world.js';
import { createWorld, loadSite } from './world.js';
import { createCameraRig } from './camera.js';
import { loadStorm, createWind } from './weather.js';
import { SailRig, createSailView } from './sail.js';
@ -376,7 +376,13 @@ export async function boot(opts = {}) {
const wind = createWindRouter(Object.values(winds));
// --- world & camera -----------------------------------------------------
const world = createWorld(scene, { wind });
// SPRINT10: the yard is data. `site` decides which one, and the week hands it
// over per night — night three is the corner block.
const site = await loadSite(opts.site ?? week.site);
const world = createWorld(scene, { wind, site });
// Lane C: a venturi is a shelter's opposite — a gap SPEEDS wind along its axis.
// Absent/empty is a perfect no-op, which is what keeps backyard_01 identical.
wind.setVenturi?.(site.wind?.venturi ?? []);
// Lane E's GLBs land over the graybox. Awaited here because it resolves
// world.shedTable onto E's baked pickup_anchor, and wireYardActions (below,
// inside rigSail) reads that position when it registers the spare pickup.

View File

@ -5,7 +5,7 @@
import * as THREE from '../../vendor/three.module.js';
import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
import { createWorld, heightAt } from '../world.js';
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
import { createCameraRig } from '../camera.js';
import { createGame, createWindRouter, verdictFor } from '../main.js';
import { orderRing } from '../sail.js';
@ -16,7 +16,10 @@ import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */
export default async function run(t) {
const scene = new THREE.Scene();
const world = createWorld(scene, { wind: createStubWind({ calm: true }) });
// SPRINT10: the yard is data now. Loading the REAL site rather than a fixture
// is the point — these asserts are the proof that the extraction moved nothing.
const site = await loadSite('backyard_01');
const world = createWorld(scene, { wind: createStubWind({ calm: true }), site });
// Dress the yard before asserting anything about it: anchors are only FINAL
// after dress(), which moves them onto the positions Lane E baked and adds the

View File

@ -64,7 +64,12 @@ async function buildYard() {
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
};
const world = createWorld(scene, { wind: calm });
// SPRINT10 (Lane A, in Lane B's file — minimal, shout if you'd rather it read
// differently): the yard is data now, so createWorld needs the site. Loading
// the real backyard_01 keeps this suite's "read the yard, never copy it" rule
// intact — it's the same yard, from its source instead of from code.
const { loadSite } = await import('../world.js');
const world = createWorld(scene, { wind: calm, site: await loadSite('backyard_01') });
if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } }
const anchors = world.anchors.map((a) => {
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z };

View File

@ -12,7 +12,10 @@
*/
import * as THREE from '../vendor/three.module.js';
import { YARD, createStubWind } from './contracts.js';
import { createStubWind } from './contracts.js';
/** Degrees → radians. Site JSON is authored in degrees; nobody writes π/2 by hand. */
const rad = (deg) => ((deg ?? 0) * Math.PI) / 180;
/**
* Ground height in meters at a world XZ. Pure and cheap this is called by the
@ -33,32 +36,87 @@ export function heightAt(x, z) {
);
}
const GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 };
const SITE_DIR = new URL('../data/sites', import.meta.url).href;
// Shed on the east side, table out in front of it. Lane D tested reachability
// against (9, 6), so the table stays there and the shed tucks in behind.
const SHED = { x: 11.8, z: 6.2, rotY: -Math.PI / 2 };
const SHED_TABLE = { x: 9, z: 6, rotY: -Math.PI / 2 };
/**
* Load a site. SPRINT10 gate 1 the yard stopped being code today.
*
* Everything that used to be a constant in this file (bed rect, house line,
* tree and post placements, shed, gnome, sun angle) is now `data/sites/*.json`,
* and world.js is a builder. The proof of a faithful extraction is that nothing
* moved: Lane E's anchor tripwires pin five branch positions to 6 dp and
* a.test's quad-band asserts pin the whole rigging geometry, so a byte-identical
* backyard_01 is checkable rather than claimable.
*
* Async and separate from createWorld() for the same reason dress() is: the
* selftest builds yards with no server, and a fetch in the constructor is either
* a break or a flake. Callers load the site, then build.
*
* @param {string} name e.g. 'backyard_01'
*/
export async function loadSite(name, dir = SITE_DIR) {
const url = `${dir}/${name}.json`;
const res = await fetch(url);
if (!res.ok) throw new Error(`world: cannot load site ${url} (${res.status})`);
return validateSite(await res.json(), name);
}
// Lane E's house_yardside GLB is a 9.2 x 2.9 x 1.05 m façade whose fascia
// anchors sit at local z = +0.55, so placing it here lands them on z = -9.95 —
// the same line the graybox taught everyone to expect.
const HOUSE = { x: 0, z: -10.5 };
/**
* Sites are content, so they fail loud rather than half-build. Every field this
* checks is one that would otherwise produce a yard that looks fine and is
* subtly wrong an absent bed silently becomes NaN coverage, a missing anchor
* list silently becomes an unriggable site.
*/
export function validateSite(site, name = site?.id ?? '?') {
const bad = [];
if (!site || typeof site !== 'object') bad.push('not an object');
else {
if (!site.yard?.width || !site.yard?.depth) bad.push('yard.width/depth');
if (!site.gardenBed) bad.push('gardenBed');
if (!Number.isFinite(site.sun?.elevationDeg)) bad.push('sun.elevationDeg');
if (!Number.isFinite(site.sun?.azimuthDeg)) bad.push('sun.azimuthDeg');
const ids = [
...(site.house?.anchors ?? []).map((a) => a.id),
...(site.trees ?? []).flatMap((t) => (t.anchors ?? []).map((a) => a.id)),
...(site.posts ?? []).map((p) => p.id),
...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => a.id)),
];
if (!ids.length) bad.push('no anchors at all — nothing to rig to');
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
if (dupes.length) bad.push(`duplicate anchor ids: ${[...new Set(dupes)].join(', ')}`);
// Lane D's field. `work` is the MECHANISM, not the anchor's type: a post is
// tensioned from a cleat at its base and a tree strop is thrown, but a
// bracket bolted up a bare wall is the one you cannot fake — so that, and
// only that, wants the ladder. Keyed on mechanism because `type` is a closed
// enum ('house'|'tree'|'post') that site_02's carport doesn't fit, and
// needsLadder keyed on type FAILS OPEN there: the ladder mechanic silently
// vanishes and you re-rig a 2.6 m bracket standing on the grass (D's audit).
const works = [
...(site.house?.anchors ?? []),
...(site.trees ?? []).flatMap((t) => t.anchors ?? []),
...(site.posts ?? []),
...(site.structures ?? []).flatMap((s) => s.anchors ?? []),
];
for (const a of works) {
if (a.work !== 'cloth' && a.work !== 'bracket') {
bad.push(`anchor ${a.id}: work must be "cloth" or "bracket", got ${JSON.stringify(a.work)}`);
}
}
}
if (bad.length) throw new Error(`world: site '${name}' is invalid:\n ${bad.join('\n ')}`);
return site;
}
// The gnome stands just off the bed's south-east corner — inside the footprint
// of most rigs, so a sail that lets go has something of the client's to land on.
// DESIGN.md: your own failure is the worst debris of all.
const GNOME = { x: 4.3, z: 4.4, rotY: -2.2, collateralValue: 25 };
// Sun: mid-afternoon, high and off the north-west shoulder. Elevation 55°.
// Stored as the direction from the GROUND toward the SUN (see contracts.js).
const SUN_ELEV = (55 * Math.PI) / 180;
const SUN_AZIM = (-125 * Math.PI) / 180;
const SUN_DIR = new THREE.Vector3(
Math.cos(SUN_ELEV) * Math.sin(SUN_AZIM),
Math.sin(SUN_ELEV),
Math.cos(SUN_ELEV) * Math.cos(SUN_AZIM),
).normalize();
/** Direction from the GROUND toward the SUN (see contracts.js), off site data. */
function sunDirOf(site) {
const el = (site.sun.elevationDeg * Math.PI) / 180;
const az = (site.sun.azimuthDeg * Math.PI) / 180;
return new THREE.Vector3(
Math.cos(el) * Math.sin(az),
Math.sin(el),
Math.cos(el) * Math.cos(az),
).normalize();
}
const COLORS = {
sky: 0x9fc4dd,
@ -89,6 +147,26 @@ const COLORS = {
export function createWorld(scene, opts = {}) {
const wind = opts.wind ?? createStubWind({ calm: true });
// SPRINT10 gate 1. Every one of these was a module constant in this file an
// hour ago; they're site data now. They keep their old local names on purpose
// — the builder below is ~500 lines of geometry that was already correct, and
// rebinding the names is a change of SOURCE, not of behaviour. That's what
// makes "backyard_01 is byte-identical" a structural claim rather than a hope.
const site = validateSite(
opts.site ?? (() => { throw new Error(
'world: createWorld now needs a site — `createWorld(scene, { wind, site: await loadSite("backyard_01") })`. '
+ 'The yard stopped being code in SPRINT10; data/sites/*.json is the source of truth.'); })(),
);
const YARD = site.yard;
const GARDEN_BED = site.gardenBed;
const SUN_DIR = sunDirOf(site);
const HOUSE = site.house;
const SHED = site.shed;
const SHED_TABLE = site.shedTable ? { ...site.shedTable, rotY: rad(site.shedTable.rotYDeg) } : null;
const GNOME = site.gnome ? { ...site.gnome, rotY: rad(site.gnome.rotYDeg) } : null;
const treeSpecs = site.trees ?? [];
const postSpecs = site.posts ?? [];
const root = new THREE.Group();
root.name = 'yard';
scene.add(root);
@ -194,18 +272,22 @@ export function createWorld(scene, opts = {}) {
solids.push(wall, roof);
graybox.house = house;
for (const [i, x] of [-5, 0, 5].entries()) {
anchors.push(makeStaticAnchor(`h${i + 1}`, 'house', new THREE.Vector3(x, 2.6, -9.9)));
// Graybox stand-ins at the graybox's own spacing. dress() moves every one of
// these onto the position Lane E baked, which is why the numbers here don't
// matter and the IDS do — the site names them, and everything downstream
// (Lane B's quads, D's prompts, the balance suite) refers to them by name.
for (const [i, a] of (HOUSE?.anchors ?? []).entries()) {
anchors.push(Object.assign(
makeStaticAnchor(a.id, a.type ?? 'house', new THREE.Vector3(-5 + i * 5, 2.6, -9.9)),
{ work: a.work },
));
}
// --- trees -------------------------------------------------------------
// Two, on opposite shoulders, mirroring the prototype's tree placement.
// Canopies are separate named nodes so they can be swayed independently —
// Lane E's tree_gum_01.glb must keep `trunk` / `canopy_*` / `branch_anchor_*`.
const treeSpecs = [
{ id: 't1', x: -9, z: 2, phase: 0.7, trunkH: 4.2, anchorY: 3.4 },
{ id: 't2', x: 8, z: -2, phase: 2.9, trunkH: 3.8, anchorY: 3.1 },
];
// (treeSpecs is site.trees — bound at the top of createWorld.)
for (const spec of treeSpecs) {
const y0 = heightAt(spec.x, spec.z);
const tree = new THREE.Group();
@ -243,13 +325,14 @@ export function createWorld(scene, opts = {}) {
root.add(tree);
graybox.trees.set(spec.id, tree);
// The anchor is at a branch fork, not the canopy centre.
anchors.push(makeSwayAnchor(
spec.id,
new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z),
spec.phase,
wind,
));
// The anchor is at a branch fork, not the canopy centre. One per entry in
// the site's list; dress() moves each onto its baked branch_anchor_* node.
for (const a of spec.anchors ?? []) {
anchors.push(Object.assign(
makeSwayAnchor(a.id, new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z), spec.phase, wind),
{ work: a.work },
));
}
}
// --- posts -------------------------------------------------------------
@ -271,32 +354,10 @@ export function createWorld(scene, opts = {}) {
// be the expensive answer; the small quads buy survival and pay in patchy
// shade. That IS the design (DESIGN.md, "big flat low vs small twisted steep").
// SPRINT6 gate 1: p4 is the ONE close anchor the balance pass allows, and its
// position is measured rather than guessed.
//
// The wild night had no winnable line because every anchor near the bed —
// p1/p2/p3 — is SOUTH of it, and nothing stands north short of the house 10 m
// away. So covering rigs had to span the yard and died, while rigs small
// enough to survive sat beside the bed instead of over it.
//
// p4 supplies the missing north-west corner. Swept its position against the
// smallest quad that covers 90% of the bed:
// (-2.2, -1.2) → 44.4 m² ← breaks the tradeoff (see below)
// (-3.2, -1.2) → 48.6 m² ← here
// (-3.2, -2.0) → 51.7 m²
// (-4.2, -3.0) → 60.0 m²
// beyond ~z=-4 → 63.5 m², i.e. too far away to matter at all
// Full coverage costs 63.5 m² without it. At (-3.2,-1.2) that drops to 48.6 —
// a real new option, 23% cheaper — while still costing you a bigger sail than
// the 23-38 m² rigs that survive on their own. Pulling it in to (-2.2,-1.2)
// would put full coverage at 44.4 m², INSIDE the survivable band, which
// collapses DESIGN.md's central tension: covering the bed has to cost risk.
// a.test.js asserts that >45 m² floor, so moving this post inward goes red.
const postSpecs = [
{ id: 'p1', x: -4.5, z: 5.5, h: 4.0 },
{ id: 'p2', x: 4.0, z: 6.0, h: 4.0 },
{ id: 'p3', x: 0, z: 7.0, h: 4.0 },
{ id: 'p4', x: -3.2, z: -1.2, h: 4.0 },
];
// position is measured rather than guessed — and the measurement moved with
// the data. p4's swept position table now lives in backyard_01.json's `_why`,
// beside the coordinate it justifies, because that is the number someone will
// be tempted to nudge. (postSpecs is site.posts, bound at the top.)
const RAKE = (8 * Math.PI) / 180;
for (const spec of postSpecs) {
const y0 = heightAt(spec.x, spec.z);
@ -330,7 +391,10 @@ export function createWorld(scene, opts = {}) {
footing.receiveShadow = true;
root.add(footing);
anchors.push(makeStaticAnchor(spec.id, 'post', top));
anchors.push(Object.assign(
makeStaticAnchor(spec.id, spec.type ?? 'post', top),
{ work: spec.work },
));
}
// --- garden bed (the thing you are protecting) -------------------------
@ -370,11 +434,16 @@ export function createWorld(scene, opts = {}) {
fence.name = 'fence';
const railMat = new THREE.MeshStandardMaterial({ color: 0x7a6a4f, roughness: 1 });
const hx = YARD.width / 2, hz = YARD.depth / 2;
const runs = [
{ from: [-hx, hz], to: [hx, hz] }, // south
{ from: [-hx, -hz], to: [-hx, hz] }, // west
{ from: [hx, -hz], to: [hx, hz] }, // east
];
const SIDES = {
south: { from: [-hx, hz], to: [hx, hz] },
west: { from: [-hx, -hz], to: [-hx, hz] },
east: { from: [hx, -hz], to: [hx, hz] },
north: { from: [-hx, -hz], to: [hx, -hz] },
};
// backyard_01 fences south/west/east — the house IS its north boundary. The
// corner block has a street on two sides and fences the other two, which is
// the whole reason this is a site field rather than three hardcoded runs.
const runs = (site.fence?.sides ?? ['south', 'west', 'east']).map((s) => SIDES[s]).filter(Boolean);
for (const run of runs) {
const [x0, z0] = run.from, [x1, z1] = run.to;
const len = Math.hypot(x1 - x0, z1 - z0);
@ -466,6 +535,7 @@ export function createWorld(scene, opts = {}) {
};
const load = async (name) => {
if (!name) return null; // the site simply doesn't have one
try {
const gltf = await loader.loadAsync(new URL(`../models/${name}.glb`, import.meta.url).href);
gltf.scene.traverse((o) => {
@ -482,11 +552,13 @@ export function createWorld(scene, opts = {}) {
}
};
const [shed, table, houseGlb, tree1, tree2, bedGlb, gnome] = await Promise.all([
load('shed_01_v1'), load('shed_table_v1'), load('house_yardside_v1'),
load('tree_gum_01_v1'), load('tree_gum_02_v1'), load('garden_bed_v1'),
load('garden_gnome_01_v1'),
// Model names come off the site now. `load(null)` resolves null, so a site
// that simply has no shed (site_02 has a carport) skips it without a branch.
const [shed, table, houseGlb, bedGlb, gnome] = await Promise.all([
load(SHED?.model), load(SHED_TABLE?.model), load(HOUSE?.model),
load(site.gardenBedModel ?? 'garden_bed_v1'), load(GNOME?.model),
]);
const treeGlbs = await Promise.all(treeSpecs.map((s) => load(s.model)));
// --- garden bed ------------------------------------------------------
if (bedGlb) {
@ -524,9 +596,7 @@ export function createWorld(scene, opts = {}) {
root.add(houseGlb);
solids.push(houseGlb);
houseGlb.updateWorldMatrix(true, true);
for (const [i, id] of ['h1', 'h2', 'h3'].entries()) {
adoptAnchor(houseGlb, `fascia_anchor_0${i + 1}`, id);
}
for (const a of HOUSE.anchors ?? []) adoptAnchor(houseGlb, a.node, a.id);
}
// --- trees -----------------------------------------------------------
@ -534,7 +604,8 @@ export function createWorld(scene, opts = {}) {
// the fork, 0.76 out where the limb is thin) — the intel DESIGN.md wants
// inspection to buy. branch_anchor_01 keeps the original t1/t2 id so
// nothing that already references them breaks; the rest are added.
for (const [glb, spec] of [[tree1, treeSpecs[0]], [tree2, treeSpecs[1]]]) {
for (const [i, spec] of treeSpecs.entries()) {
const glb = treeGlbs[i];
if (!glb) continue;
const old = graybox.trees.get(spec.id);
retire(old);
@ -552,20 +623,13 @@ export function createWorld(scene, opts = {}) {
canopies.push({ group: canopy.parent, phase: spec.phase, base: canopy.parent.rotation.clone() });
}
// Every branch anchor the site names now EXISTS before dress() runs (they
// were created off spec.anchors above), so this only has to move them onto
// the nodes Lane E baked. That deleted the old suffix-guessing loop, which
// built t1/t1b/t1c out of an array index and could only ever describe the
// two trees it was written for.
glb.updateWorldMatrix(true, true);
const suffix = ['', 'b', 'c'];
for (let i = 1; i <= 3; i++) {
const node = glb.getObjectByName(`branch_anchor_0${i}`);
if (!node) continue;
const id = spec.id + suffix[i - 1];
if (i === 1) adoptAnchor(glb, `branch_anchor_01`, id);
else {
const p = new THREE.Vector3().setFromMatrixPosition(node.matrixWorld);
const a = makeSwayAnchor(id, p, spec.phase, wind);
a.ratingHint = node.userData?.rating_hint ?? 1;
anchors.push(a);
}
}
for (const a of spec.anchors ?? []) adoptAnchor(glb, a.node, a.id);
}
if (shed) {