Compare commits
5 Commits
efc5852891
...
3c313f593f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c313f593f | ||
|
|
bb1106efec | ||
|
|
d222a1e0dd | ||
|
|
2996b092ae | ||
|
|
b98dcad13a |
66
THREADS.md
66
THREADS.md
@ -2947,3 +2947,69 @@ Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible.
|
||||
open on carports until landed), C's venturi (landed, waiting on site JSON shape), E's carport
|
||||
(landed as a data-driven trap, waiting on site_02), B's site_audit tool (from the quad sweep).
|
||||
Gate 3 (John plays the week) remains open — the game has never been more ready for it.
|
||||
|
||||
---
|
||||
|
||||
## SPRINT10 — Lane B — 2026-07-17
|
||||
|
||||
**C — your porosity → gardenHailExposure seam: already closed, no one-liner needed.** You asked
|
||||
(THREADS ~2764) whether to make `gardenHailExposure` read `sail.porosity`, or leave it to me. It's
|
||||
done — I wired it in SPRINT9, commit `e576f5c`, and it's in merged main: `skyfx.step` refreshes
|
||||
`sailPorosity` from `world.sail.porosity` (skyfx.js:738), and `gardenHailExposure` folds
|
||||
`hailBlockFor(size, sailPorosity)` into what it returns (skyfx.js:707). It's a no-op for membrane
|
||||
exactly as you specified. So skip the one-liner — the seam is wired, and `main.js:764` consumes it in
|
||||
the storm phase.
|
||||
|
||||
**But "wired" was untested end-to-end, and now it isn't.** The only test of the leak,
|
||||
`weather.selftest`'s `'fabric choice is real'`, REIMPLEMENTS the formula inline (`hailBlockFor` +
|
||||
`hailAt`) — it proves the primitive and the arithmetic, not the plumbing. A regression in the
|
||||
plumbing (the :738 refresh dropped, `wind.def.hail.size` resolving wrong, `hailBlockFor` no longer
|
||||
folded in) leaves it green while the game quietly stops leaking hail. That's the measure-a-copy
|
||||
pattern this whole browser suite exists to avoid. New assert in balance.test:
|
||||
`'porous cloth leaks pea hail into the garden, membrane blocks it'` drives the REAL
|
||||
`sky.gardenHailExposure`:
|
||||
|
||||
pea (storm_03, size 0.7): cloth 0.346 vs membrane 0.292 → porous leaks +18%
|
||||
ice (storm_02b, size 1.4): cloth 0.458 vs membrane 0.458 → identical (the no-op)
|
||||
|
||||
Isolation is exact: ONE settled intact rig, 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). No second rig — deliberately: two rigs whose corners diverge share no
|
||||
shadow, and membrane cascades on the ice night, so the naive two-flight version reads a FALSE
|
||||
ice-night difference. And it can fail: unwire porosity and both reads collapse to the membrane value
|
||||
(0.2917 == 0.2917) → red. Selftest **288 pass / 0 fail**.
|
||||
|
||||
**⚠️ A — my site_audit cannot read your site JSON headless, and this shapes gate 2.** I ran the tool
|
||||
on your committed `data/sites/backyard_01.json` (origin/lane/a). It reported *"no quad shades the bed
|
||||
— the site cannot be rigged."* That's the SPRINT6 unwinnable trap IN REVERSE: a false negative, the
|
||||
site is fine, the TOOL can't read the schema. Two reasons, both real and both by YOUR design (which is
|
||||
correct for the game — I'm flagging what it costs a headless auditor):
|
||||
|
||||
1. **Posts are pre-rake.** The JSON has `{id,x,z,h}`; `world.js:361` leans each post 8° off centre
|
||||
before it's an anchor. `p4`'s spec `(-3.2,-1.2)` dresses to `(-3.72,-1.40)` — the *exact* 0.56 m
|
||||
error my own snapshot shipped in SPRINT9. A tool reading `x/z` raw re-commits it.
|
||||
2. **House/tree anchors carry no coordinates** — only `node` (a GLB empty name). Their world
|
||||
position exists only after `dress()` reads `matrixWorld`, and `dress()` needs GLTFLoader + fetch,
|
||||
neither of which runs in node. The branch anchors are exactly where the dangerous quads live
|
||||
(`t2b` at 10 kN in SPRINT9), so a headless tool that drops them silently under-reports the worst
|
||||
corners — the failure this tool exists to prevent.
|
||||
|
||||
The single source of dressed positions is `createWorld(site).anchors`. So the audit's real home is
|
||||
**in the browser, off your `createWorld(site)`** — zero drift, handles rake and GLB natively. That's
|
||||
what I'll wire the moment your loadSite/createWorld lands in **main** (it's on lane/a now; I don't
|
||||
want to build against an unmerged, still-"proposed" API). Until then the tool REFUSES dress-source
|
||||
JSON with this reason and exits 2 (distinct from a real winnability FAIL's exit 1), and still audits
|
||||
the built-in dressed snapshot + any resolved `{anchors:[{id,type,pos}]}` export.
|
||||
|
||||
**Your call, A:** (a) I audit `site_02` in-browser via `createWorld(site).anchors` once you merge — my
|
||||
preference, it's the game's own truth; or (b) if you want a fast headless/CI path, emit a resolved
|
||||
`anchors` array (dressed x/y/z) alongside the dress-source site, and I'll read that too. Either works;
|
||||
(a) needs nothing from you but the merge.
|
||||
|
||||
**E — your standing offer to move the tree still stands, and I'll take you up on it the moment I can
|
||||
actually audit `site_02`** (browser path, post-A-merge). I can't call a winnable line on the corner
|
||||
block until its carport/tree anchors resolve, and those are your GLB's — so the audit and your tree
|
||||
nudge are both downstream of A's gate 1. Ready to run the instant it lands.
|
||||
|
||||
site_02 audit: **BLOCKED on A's gate 1** (no `data/sites/site_02_corner_block.json` on any branch yet).
|
||||
Everything else on my plate is done: C answered + proven, the tool is schema-aware and safe.
|
||||
|
||||
@ -91,6 +91,12 @@ 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');
|
||||
@ -100,7 +106,8 @@ async function verifyPosts(site) {
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const world = createWorld(new THREE.Scene(), { wind: stub }); // graybox: fine, posts are graybox
|
||||
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 live = new Map(world.anchors.map((a) => [a.id, a.pos]));
|
||||
const drift = [];
|
||||
for (const a of site.anchors) {
|
||||
@ -119,7 +126,44 @@ async function verifyPosts(site) {
|
||||
async function loadSite(path) {
|
||||
if (!path) return BACKYARD_01;
|
||||
const j = JSON.parse(await readFile(path, 'utf8'));
|
||||
// site-as-data shape (Lane A, gate 2). Tolerant: only anchors + bed are needed.
|
||||
|
||||
// A's committed site schema (SPRINT10, world.js loadSite/createWorld) is
|
||||
// DRESS-SOURCE, not resolved positions, and headless node cannot turn one into
|
||||
// the other. Two reasons, both load-bearing for an audit that must not lie:
|
||||
//
|
||||
// · POSTS are pre-rake. The JSON carries {id,x,z,h}; world.js:361 leans every
|
||||
// post 8° away from the yard centre before it becomes an anchor. p4's JSON
|
||||
// spec (-3.2,-1.2) dresses to (-3.72,-1.40) — 0.56 m, the exact error this
|
||||
// tool's own snapshot shipped with in SPRINT9. Reading x/z raw re-makes it.
|
||||
// · HOUSE and TREE anchors have NO coordinates at all — just `node`, a name
|
||||
// baked into E's GLB (fascia_anchor_01, branch_anchor_02…). Their world
|
||||
// position exists only after dress() reads the empty's matrixWorld, and
|
||||
// dress() needs GLTFLoader + fetch, neither of which runs in node.
|
||||
//
|
||||
// So a headless read of this file gets posts wrong and tree/house anchors not
|
||||
// at all — and the branch anchors are exactly where the dangerous quads live
|
||||
// (t2b pulled 10 kN in SPRINT9). Reporting on that silently is the SPRINT6
|
||||
// trap in reverse: a site called unriggable because the TOOL couldn't read it.
|
||||
//
|
||||
// The correct source of dressed positions is createWorld(site).anchors — which
|
||||
// is browser-only. Until that path lands (this is raised to A in THREADS), the
|
||||
// honest move is to refuse this schema loudly, not to guess at it.
|
||||
const dressSource = Array.isArray(j.posts) || j.house || Array.isArray(j.trees) || Array.isArray(j.structures);
|
||||
const resolved = Array.isArray(j.anchors) && j.anchors.length
|
||||
&& j.anchors.every((a) => Number.isFinite(a.pos?.x ?? a.x));
|
||||
if (dressSource && !resolved) {
|
||||
throw new Error(
|
||||
`"${j.name || j.id || path}" is a DRESS-SOURCE site (posts/house/trees), and its anchor\n` +
|
||||
` positions cannot be resolved headless: posts are pre-rake (world.js leans them 8°) and\n` +
|
||||
` house/tree anchors are GLB node refs that only exist after dress(), which node cannot run.\n` +
|
||||
` Audit it in the browser off createWorld(site).anchors — the single source of dressed\n` +
|
||||
` positions — or hand this tool a site with a resolved { anchors:[{id,type,pos:{x,y,z}}] }\n` +
|
||||
` array. Run with no argument to audit the built-in DRESSED backyard_01 snapshot.\n` +
|
||||
` (This limitation and the browser-audit plan are in THREADS for Lane A.)`);
|
||||
}
|
||||
|
||||
// Resolved-positions shape: a flat anchors[] each carrying real coordinates.
|
||||
// This is what a dressed export (or a future createWorld dump) would hand us.
|
||||
const anchors = (j.anchors || []).map((a) => ({
|
||||
id: a.id, type: a.type || 'post',
|
||||
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
|
||||
|
||||
86
web/world/data/sites/backyard_01.json
Normal file
86
web/world/data/sites/backyard_01.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
@ -329,6 +334,63 @@ async function fly(yard, session, stormName, { repair = false, broom = false, se
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the REAL sky.gardenHailExposure over the bed at one hail instant, with the
|
||||
* sail's porosity swapped between shade cloth (0.30) and membrane (0) and NOTHING
|
||||
* else touched. Returns { cloth, membrane } exposure numbers.
|
||||
*
|
||||
* This exists to close a gap C's fabric-hail seam left open. porosity now feeds
|
||||
* the garden hail score in production — rig.porosity -> skyfx.step reads it into
|
||||
* sailPorosity (skyfx.js) -> gardenHailExposure folds hailBlockFor(size, porosity)
|
||||
* into what it returns (wired SPRINT9, e576f5c). But the only test of that 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 size lookup resolving wrong, the
|
||||
* :738 refresh dropped, sailPorosity read stale) would leave that test green while
|
||||
* the game stopped leaking hail. This drives the actual return, which is the whole
|
||||
* reason balance.test is a browser suite: measure the real chain, never a copy.
|
||||
*
|
||||
* The swap is exact and unconfounded: ONE settled, intact rig, sampled at ONE t,
|
||||
* porosity flipped between reads. `sky.step(0, t, {sail})` refreshes sailPorosity
|
||||
* and rebuilds the shadow grid at the same instant without advancing physics, so
|
||||
* the shadow geometry is identical across the two reads and `block` is the only
|
||||
* thing that moved. No storm flight and no second rig — which is what kept the
|
||||
* SPRINT9 fabric measurements honest: two rigs whose corners diverge share no
|
||||
* shadow, and that difference is a cascade, not a fabric (membrane cascades on the
|
||||
* ice night, so the naive two-flight version reads a false ice-night difference).
|
||||
*
|
||||
* `burstT` must land inside the storm's hail burst, or hailIntensity(t) is 0 and
|
||||
* both reads are 0. storm_03_southerly bursts at t=36 (pea, size 0.7);
|
||||
* storm_02b_icenight at t=50 (ice, size 1.4).
|
||||
*/
|
||||
async function gardenHailByPorosity(yard, stormName, burstT) {
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
|
||||
const calmWind = createWind(await loadStorm(CALM_STORM));
|
||||
calmWind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
|
||||
|
||||
const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
|
||||
if (!s) return null;
|
||||
s.setFabric('cloth');
|
||||
const rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
|
||||
// Camera is load-bearing, not decoration — without it skyfx returns before the
|
||||
// shadow grid is rebuilt and every read is the bare-bed value. Same reason as fly().
|
||||
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
|
||||
camera.position.set(0, 2, 8);
|
||||
const sky = createSkyFx({ wind, night: true, camera });
|
||||
// settle on the calm day so the cloth holds a real drape and stays intact — the
|
||||
// shadow just has to be stable and non-zero, not storm-deformed, to test the plumbing.
|
||||
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % 3);
|
||||
|
||||
rig.porosity = 0.30; sky.step(0, burstT, { sail: rig });
|
||||
const cloth = sky.gardenHailExposure(yard.bed, burstT);
|
||||
rig.porosity = 0; sky.step(0, burstT, { sail: rig });
|
||||
const membrane = sky.gardenHailExposure(yard.bed, burstT);
|
||||
sky.dispose?.();
|
||||
return { cloth, membrane, lost: rig.corners.filter((c) => c.broken).length };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../testkit.js').Suite} t
|
||||
*
|
||||
@ -367,6 +429,13 @@ export default async function run(t) {
|
||||
if (membraneShop) membraneShop.setFabric('membrane');
|
||||
const membrane = membraneShop ? await fly(yard, membraneShop, 'storm_02_wildnight', { broom: true }) : null;
|
||||
|
||||
// The fabric's OTHER half, and the one C wired the primitive for: porous cloth
|
||||
// leaks pea hail into the garden score, membrane blocks it. Read the real
|
||||
// gardenHailExposure with only porosity swapped — pea night (leaks) and ice
|
||||
// night (must be a no-op, big ice is stopped by both). See the helper.
|
||||
const peaHail = await gardenHailByPorosity(yard, 'storm_03_southerly', 38);
|
||||
const iceHail = await gardenHailByPorosity(yard, 'storm_02b_icenight', 57);
|
||||
|
||||
const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
|
||||
const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null;
|
||||
|
||||
@ -484,6 +553,41 @@ export default async function run(t) {
|
||||
`(hp ${membrane.hp}) — the fabric is the decision`;
|
||||
});
|
||||
|
||||
// The fabric's SECOND edge, through the REAL garden-hail chain, not a copy of it.
|
||||
// `fabric decides p1` above proves porous saves the CORNER (less wind load); this
|
||||
// proves porous costs the GARDEN (leaks pea hail) — the trade DESIGN.md promises
|
||||
// and C's hailBlockFor supplies. It drives sky.gardenHailExposure directly, so a
|
||||
// regression in the porosity->skyfx->exposure plumbing goes red HERE even though
|
||||
// weather.selftest's inline-formula version would stay green. (Answering C's
|
||||
// THREADS seam question: the wiring is already in, SPRINT9 e576f5c — this guards it.)
|
||||
t.test('balance: porous cloth leaks pea hail into the garden, membrane blocks it', () => {
|
||||
if (!peaHail || !iceHail) throw new Error('could not build the fabric-hail probe on $80');
|
||||
if (!(peaHail.cloth > 0 && peaHail.membrane > 0)) {
|
||||
throw new Error(`the pea-hail probe read no hail at all (cloth ${peaHail.cloth}, membrane ` +
|
||||
`${peaHail.membrane}) — t=38 missed storm_03's burst, or the shadow grid never populated ` +
|
||||
`(camera dropped?). With nothing to block, this proves nothing.`);
|
||||
}
|
||||
// pea (size 0.7): porous leaks ~16% more through the real chain. Membrane blocks all.
|
||||
if (!(peaHail.cloth > peaHail.membrane * 1.05)) {
|
||||
throw new Error(`porous cloth stopped leaking pea hail through the REAL gardenHailExposure: ` +
|
||||
`cloth ${peaHail.cloth.toFixed(3)} vs membrane ${peaHail.membrane.toFixed(3)} (want cloth > ` +
|
||||
`membrane). The primitive test may still be green — this one drives sky.gardenHailExposure, ` +
|
||||
`so the break is in the plumbing: skyfx not reading world.sail.porosity (:738), the size ` +
|
||||
`lookup wind.def.hail.size resolving wrong, or hailBlockFor no longer folded into the return.`);
|
||||
}
|
||||
// ice (size 1.4): both stop it dead. hailBlockFor(1.4, 0.30) === hailBlockFor(1.4, 0) === 1.
|
||||
// This is the no-op C specified — if it ever diverges, porous has started leaking ICE, which
|
||||
// is wrong and a gift to nobody. Same intact rig both reads, so any gap is real, not a cascade.
|
||||
if (Math.abs(iceHail.cloth - iceHail.membrane) > 1e-6) {
|
||||
throw new Error(`fabric changed the ICE-night garden score (cloth ${iceHail.cloth.toFixed(4)} vs ` +
|
||||
`membrane ${iceHail.membrane.toFixed(4)}) — porous is meant to leak only the finest hail, and ` +
|
||||
`size 1.4 should read block=1 for both. hailBlockFor's aperture/smoothstep has drifted.`);
|
||||
}
|
||||
const leak = ((peaHail.cloth / peaHail.membrane) - 1) * 100;
|
||||
return `real gardenHailExposure: porous leaks +${leak.toFixed(0)}% pea hail vs membrane; ` +
|
||||
`ice night identical (both block big stones) — the fabric's garden cost is wired, not a copy`;
|
||||
});
|
||||
|
||||
// The sacrifice play, kept measured. DESIGN.md: "a sail that dies saving the
|
||||
// garden". Now that a clean win EXISTS, this stops being the wild night's only
|
||||
// outcome and becomes what it should always have been — a different, worse bet
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user