Compare commits

..

3 Commits

Author SHA1 Message Date
m3ultra
efc5852891 THREADS: SPRINT10 Lane B — answer C (porosity wired+proven), flag dress-source site JSON to A, tree offer to E
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:53:10 +10:00
m3ultra
e439e7c22a site_audit: refuse dress-source site JSON instead of false-unwinnable
A's committed site schema (SPRINT10 world.js loadSite/createWorld) is
dress-source, and my loadSite assumed resolved positions. Run head-on against
A's real data/sites/backyard_01.json the tool reported:

    ✗ FAIL — no quad in the band shades the bed at all. The site cannot be rigged.

That is a lie, and the dangerous kind: the site is fine; the TOOL couldn't read
it. It is the SPRINT6 unwinnable-site trap in reverse — a false negative that,
in a "every site runs the audit before it ships" workflow, condemns a good site.

Two things make the schema unreadable headless, both real:
  · posts are pre-rake. JSON carries {id,x,z,h}; world.js:361 leans each post 8°
    off centre. p4's spec (-3.2,-1.2) dresses to (-3.72,-1.40) — the exact 0.56 m
    error this tool's own snapshot shipped in SPRINT9. Reading x/z raw re-makes it.
  · house/tree anchors have no coordinates — only `node`, a GLB empty's name.
    Their position exists only after dress() reads matrixWorld, and dress() needs
    GLTFLoader + fetch, neither of which runs in node. Branch anchors are exactly
    where the dangerous quads live (t2b at 10 kN), so dropping them silently is
    the worst possible failure.

The correct source of dressed positions is createWorld(site).anchors, which is
browser-only. That path lands once A's data-driven createWorld is in main; until
then loadSite REFUSES dress-source JSON with the reason and the plan, and exits 2
(distinct from the winnability FAIL's exit 1). The built-in DRESSED snapshot and
a resolved { anchors:[{id,type,pos}] } export both still audit. Raised to A in
THREADS: audit in-browser off createWorld(site).anchors, or export resolved anchors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:52:35 +10:00
m3ultra
b79aa28d52 Prove the porosity->gardenHailExposure leak end-to-end (answer C's seam)
C asked in THREADS whether to land a one-liner making gardenHailExposure read
sail.porosity. Answer: it is already wired, in merged main — I did it in SPRINT9
(e576f5c): skyfx.step refreshes sailPorosity from world.sail.porosity, and
gardenHailExposure folds hailBlockFor(size, sailPorosity) into what it returns.
No one-liner needed.

But "already wired" was untested end-to-end. The only test of the hail leak,
weather.selftest's 'fabric choice is real', REIMPLEMENTS the formula inline with
hailBlockFor + hailAt — it proves the primitive and the arithmetic, not the
plumbing. A regression in the plumbing (the :738 refresh dropped, the
wind.def.hail.size lookup resolving wrong, hailBlockFor no longer folded in)
would leave that test green while the game quietly stopped leaking hail. That is
the measure-a-copy pattern this whole browser suite exists to avoid.

New assert 'porous cloth leaks pea hail into the garden, membrane blocks it'
drives the real sky.gardenHailExposure. The isolation is exact: ONE settled,
intact rig, sampled at ONE hail instant, porosity flipped 0.30<->0 between reads.
sky.step(0, t, {sail}) refreshes sailPorosity and rebuilds the shadow grid at the
same instant without advancing physics, so shadow geometry is identical across
the two reads and `block` is the only thing that moved. Measured:

    pea (size 0.7):  cloth 0.346 vs membrane 0.292  -> porous leaks +18%
    ice (size 1.4):  cloth 0.458 vs membrane 0.458  -> identical, the no-op

No storm flight, no second rig — deliberately. Two rigs whose corners diverge
share no shadow, and that difference is a cascade not a fabric: membrane cascades
on the ice night, so a naive two-flight version reads a FALSE ice-night
difference. The same-rig swap removes it.

And it can fail: if porosity stops reaching the exposure, both reads collapse to
the membrane value (measured: 0.2917 == 0.2917), cloth > membrane*1.05 is false,
red. The ice check is exact equality — if porous ever starts leaking big ice,
that goes red too.

Selftest 288 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:42:43 +10:00
6 changed files with 96 additions and 267 deletions

View File

@ -91,12 +91,6 @@ const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']);
/**
* Re-derive the posts from live world.js and fail loudly if the dump drifted.
* The one guard-rail a hand-typed yard can actually have.
*
* SPRINT10: world.js stopped being code createWorld now REQUIRES a site and
* throws without one. It reads the site from data/sites/backyard_01.json, which
* is exactly the extraction A shipped, so this cross-check now compares the dump
* against the DATA's posts (raked in createWorld, no dress needed). loadSite()
* itself fetch()es and can't run in node, so read the JSON and hand it over raw.
*/
async function verifyPosts(site) {
const THREE = await import('../../web/world/vendor/three.module.js');
@ -106,8 +100,7 @@ async function verifyPosts(site) {
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
};
const siteData = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8'));
const world = createWorld(new THREE.Scene(), { wind: stub, site: siteData }); // graybox: fine, posts are raked in createWorld
const world = createWorld(new THREE.Scene(), { wind: stub }); // graybox: fine, posts are graybox
const live = new Map(world.anchors.map((a) => [a.id, a.pos]));
const drift = [];
for (const a of site.anchors) {

View File

@ -1,86 +0,0 @@
{
"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, loadSite } from './world.js';
import { createWorld } from './world.js';
import { createCameraRig } from './camera.js';
import { loadStorm, createWind } from './weather.js';
import { SailRig, createSailView } from './sail.js';
@ -376,13 +376,7 @@ export async function boot(opts = {}) {
const wind = createWindRouter(Object.values(winds));
// --- world & camera -----------------------------------------------------
// 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 ?? []);
const world = createWorld(scene, { wind });
// 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, loadSite, validateSite } from '../world.js';
import { createWorld, heightAt } from '../world.js';
import { createCameraRig } from '../camera.js';
import { createGame, createWindRouter, verdictFor } from '../main.js';
import { orderRing } from '../sail.js';
@ -16,10 +16,7 @@ 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();
// 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 });
const world = createWorld(scene, { wind: createStubWind({ calm: true }) });
// 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,12 +64,7 @@ async function buildYard() {
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
};
// 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') });
const world = createWorld(scene, { wind: calm });
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,10 +12,7 @@
*/
import * as THREE from '../vendor/three.module.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;
import { YARD, createStubWind } from './contracts.js';
/**
* Ground height in meters at a world XZ. Pure and cheap this is called by the
@ -36,87 +33,32 @@ export function heightAt(x, z) {
);
}
const SITE_DIR = new URL('../data/sites', import.meta.url).href;
const GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 };
/**
* 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);
}
// 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 };
/**
* 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;
}
// 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 };
/** 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();
}
// 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();
const COLORS = {
sky: 0x9fc4dd,
@ -147,26 +89,6 @@ 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);
@ -272,22 +194,18 @@ export function createWorld(scene, opts = {}) {
solids.push(wall, roof);
graybox.house = house;
// Graybox stand-ins at the graybox's own spacing. dress() moves every one of
// these onto the position Lane E baked, which is why the numbers here don't
// 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 },
));
for (const [i, x] of [-5, 0, 5].entries()) {
anchors.push(makeStaticAnchor(`h${i + 1}`, 'house', new THREE.Vector3(x, 2.6, -9.9)));
}
// --- 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_*`.
// (treeSpecs is site.trees — bound at the top of createWorld.)
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 },
];
for (const spec of treeSpecs) {
const y0 = heightAt(spec.x, spec.z);
const tree = new THREE.Group();
@ -325,14 +243,13 @@ 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. 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 },
));
}
// 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,
));
}
// --- posts -------------------------------------------------------------
@ -354,10 +271,32 @@ 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 — 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.)
// 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 },
];
const RAKE = (8 * Math.PI) / 180;
for (const spec of postSpecs) {
const y0 = heightAt(spec.x, spec.z);
@ -391,10 +330,7 @@ export function createWorld(scene, opts = {}) {
footing.receiveShadow = true;
root.add(footing);
anchors.push(Object.assign(
makeStaticAnchor(spec.id, spec.type ?? 'post', top),
{ work: spec.work },
));
anchors.push(makeStaticAnchor(spec.id, 'post', top));
}
// --- garden bed (the thing you are protecting) -------------------------
@ -434,16 +370,11 @@ 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 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);
const runs = [
{ from: [-hx, hz], to: [hx, hz] }, // south
{ from: [-hx, -hz], to: [-hx, hz] }, // west
{ from: [hx, -hz], to: [hx, hz] }, // east
];
for (const run of runs) {
const [x0, z0] = run.from, [x1, z1] = run.to;
const len = Math.hypot(x1 - x0, z1 - z0);
@ -535,7 +466,6 @@ 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) => {
@ -552,13 +482,11 @@ export function createWorld(scene, opts = {}) {
}
};
// 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 [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'),
]);
const treeGlbs = await Promise.all(treeSpecs.map((s) => load(s.model)));
// --- garden bed ------------------------------------------------------
if (bedGlb) {
@ -596,7 +524,9 @@ export function createWorld(scene, opts = {}) {
root.add(houseGlb);
solids.push(houseGlb);
houseGlb.updateWorldMatrix(true, true);
for (const a of HOUSE.anchors ?? []) adoptAnchor(houseGlb, a.node, a.id);
for (const [i, id] of ['h1', 'h2', 'h3'].entries()) {
adoptAnchor(houseGlb, `fascia_anchor_0${i + 1}`, id);
}
}
// --- trees -----------------------------------------------------------
@ -604,8 +534,7 @@ 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 [i, spec] of treeSpecs.entries()) {
const glb = treeGlbs[i];
for (const [glb, spec] of [[tree1, treeSpecs[0]], [tree2, treeSpecs[1]]]) {
if (!glb) continue;
const old = graybox.trees.get(spec.id);
retire(old);
@ -623,13 +552,20 @@ 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);
for (const a of spec.anchors ?? []) adoptAnchor(glb, a.node, a.id);
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);
}
}
}
if (shed) {