site_audit's headless false-negative is fixed by the browser front-end (A's loadSite/createWorld landed in main — that was all it waited on), and fixing it surfaced a worse bug underneath: the sweep built its wind from the STORM def alone and never called setVenturi, which main.js:424 does at every site load. The venturi is SITE data, so every site_02 audit ever run — including the numbers quoted at C in SPRINT10 — was of a corner block with no gap. The SPRINT6 p1=7.4kN failure inverted: there the tool called a good site unriggable, here it called a mean site cheap. - sweep.js: setVenturi on both winds (the gap doesn't switch off for the settle); both front-ends pass the site's funnel and print it, so a funnelled run is legible as one. - sweep.selftest.js (new, wired into b.test.js): the auditor gets an auditor. Funnel must strictly raise every corner; a funnel-less site must be byte-identical. Verified it fails when the setVenturi calls are deleted. - rigging.js: setWorld(world) + session.setBudget(n)/setAnchors() — A's two asks. Markers are rebuilt (the anchor set changes across sites) and disposed; stale picks die with the old yard. Deletes A's "one private touch" into this module. - dev_rigging.html: dead since SPRINT10 (called createWorld without a site, which now throws) and nothing noticed, because a dev page has no selftest to go red. Fixed, dressed, and given the site switch it should have had (N = next yard). Verdict for C/A, in THREADS: site_02 PASSES with the funnel on — 64 of 66 lines affordable, cheapest $20. The funnel kills nothing, so the gain does NOT need dropping and E's tree stays put. It's a reach problem, not a gain problem: the throat's radius is 5 m and the bed centre is 6.08 m away, so the gap screams over the carport at +50% and reaches the garden at +0.0%. selftest 300/0/0 (296 + 4 new).
224 lines
8.8 KiB
HTML
224 lines
8.8 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>SHADES — Lane B rigging harness</title>
|
|
<style>
|
|
html, body { margin: 0; height: 100%; background: #6f7f8c; overflow: hidden;
|
|
font: 12px/1.5 ui-monospace, Menlo, monospace; }
|
|
canvas { display: block; }
|
|
#dev { position: fixed; bottom: 8px; left: 8px; color: #fff; text-shadow: 0 1px 2px #000;
|
|
white-space: pre; pointer-events: none; }
|
|
#hint { position: fixed; bottom: 8px; right: 8px; color: #ffd27a; text-shadow: 0 1px 2px #000;
|
|
text-align: right; white-space: pre; pointer-events: none; }
|
|
</style>
|
|
<!-- Required: world.js/sail.js pull addons that import the bare 'three' specifier. -->
|
|
<script type="importmap">
|
|
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<canvas id="c"></canvas>
|
|
<div id="dev"></div>
|
|
<div id="hint">Lane B harness — the prep phase only.
|
|
ENTER commits the rig and starts a storm. R resets to prep. N switches yard.</div>
|
|
|
|
<script type="module">
|
|
/**
|
|
* Lane B harness — the prep-phase picking UI against Lane A's real yard.
|
|
*
|
|
* Same reason Lane C has weather_demo.html and Lane D has dev_player.html: the
|
|
* picking UI can't be asserted headless (it is clicks, raycasts and materials),
|
|
* and it can't be exercised in index.html until Lane A wires it in step 8. This
|
|
* boots the real world.js, the real anchors and the real cloth so the thing
|
|
* being verified is the thing that ships. Retire it once index.html hosts prep.
|
|
*/
|
|
import * as THREE from 'three';
|
|
import { createWorld, loadSite } from './js/world.js';
|
|
import { createCameraRig } from './js/camera.js';
|
|
import { createWind, loadStorm } from './js/weather.js';
|
|
import { SailRig, createSailView } from './js/sail.js';
|
|
import { createRiggingUI } from './js/rigging.js';
|
|
import { FIXED_DT } from './js/contracts.js';
|
|
|
|
const canvas = document.getElementById('c');
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
|
|
const scene = new THREE.Scene();
|
|
|
|
const [calmDef, wildDef] = await Promise.all([
|
|
loadStorm('storm_01_gentle'), loadStorm('storm_02_wildnight'),
|
|
]);
|
|
// main.js multiplexes these behind a private router; the harness only ever needs
|
|
// one at a time, so it just swaps the reference when the storm starts.
|
|
const calmWind = createWind(calmDef);
|
|
const wildWind = createWind(wildDef);
|
|
let wind = calmWind;
|
|
const windProxy = {
|
|
sample: (p, t, out) => wind.sample(p, t, out),
|
|
gustTelegraph: (t) => wind.gustTelegraph(t),
|
|
eventsBetween: (a, b) => wind.eventsBetween?.(a, b) ?? [],
|
|
setSheltersFromTrees: (trees) => { calmWind.setSheltersFromTrees?.(trees); wildWind.setSheltersFromTrees?.(trees); },
|
|
// The venturi is the SITE's, so it re-registers on every site switch — same as
|
|
// main.js:424. Both storms carry it: the gap doesn't switch off for the calm day.
|
|
setVenturi: (list) => { calmWind.setVenturi?.(list); wildWind.setVenturi?.(list); },
|
|
};
|
|
|
|
/**
|
|
* SPRINT11: this harness was DEAD and nothing noticed.
|
|
*
|
|
* SPRINT10 made the yard data — `createWorld` now requires a site and throws
|
|
* without one — and this page was still calling `createWorld(scene, {wind})`.
|
|
* It had been throwing on boot since that merge, silently, because a dev harness
|
|
* has no selftest to go red. (The same change is why `loadSite` appears above.)
|
|
* Two sprints of "the picking UI can't be asserted headless, so use the harness"
|
|
* with no harness. Fixed here, and given the site switch it should have had.
|
|
*/
|
|
// NB: createWorld does NOT hand the site back on the world object, so the def is
|
|
// kept here — the venturi has to come from somewhere on a switch. (main.js keeps
|
|
// its own `siteDef` for the same reason.)
|
|
const SITES = ['backyard_01', 'site_02_corner_block'];
|
|
let siteIdx = 0;
|
|
let siteDef = await loadSite(SITES[siteIdx]);
|
|
let world = createWorld(scene, { wind: windProxy, site: siteDef });
|
|
await world.dress();
|
|
const cameraRig = createCameraRig(canvas);
|
|
cameraRig.setSolids(world.solids);
|
|
cameraRig.setGround(world.heightAt);
|
|
windProxy.setVenturi(siteDef.wind?.venturi ?? []);
|
|
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
|
|
|
const rig = new SailRig({ anchors: world.anchors });
|
|
let sailView = null;
|
|
|
|
async function rigSail(anchorIds, hwChoices, tension) {
|
|
rig.attach(anchorIds, hwChoices, tension);
|
|
if (sailView) {
|
|
scene.remove(sailView);
|
|
sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
|
|
}
|
|
sailView = await createSailView(rig);
|
|
scene.add(sailView);
|
|
}
|
|
|
|
let msg = '';
|
|
let msgT = 0;
|
|
const ui = await createRiggingUI({
|
|
scene, camera: cameraRig.object, domElement: canvas, world,
|
|
onCommit: (ids, hw, tension) => { rigSail(ids, hw, tension); wind = wildWind; t = 0; phase = 'storm'; },
|
|
onMessage: (m) => { msg = m; msgT = 2; },
|
|
});
|
|
|
|
let phase = 'prep';
|
|
ui.setActive(true);
|
|
|
|
/**
|
|
* Swap the yard under the running UI — the harness for `rigging.setWorld`.
|
|
*
|
|
* Deliberately the same ORDER main.js's loadSiteInto uses: build the new world,
|
|
* dress it, re-register the site's wind (venturi + tree shelters), re-point the
|
|
* rig, then hand the UI the new world. Getting that order wrong here would prove
|
|
* nothing about the game.
|
|
*/
|
|
async function switchSite(idx) {
|
|
siteIdx = (idx + SITES.length) % SITES.length;
|
|
siteDef = await loadSite(SITES[siteIdx]);
|
|
if (sailView) { scene.remove(sailView); sailView = null; }
|
|
world.dispose(); // main.js does the same before rebuilding
|
|
world = createWorld(scene, { wind: windProxy, site: siteDef });
|
|
await world.dress();
|
|
cameraRig.setSolids(world.solids);
|
|
cameraRig.setGround(world.heightAt);
|
|
windProxy.setVenturi(siteDef.wind?.venturi ?? []);
|
|
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
|
rig.anchors = world.anchors;
|
|
ui.setWorld(world); // ← the API under test: markers + session follow
|
|
phase = 'prep';
|
|
wind = calmWind;
|
|
ui.setActive(true);
|
|
window.__laneB.world = world;
|
|
}
|
|
|
|
// harness-only handle, so a debugger (or an agent driving this page) can pick
|
|
// anchors without hunting for pixels. Never imported by the game.
|
|
window.__laneB = {
|
|
ui, rig, world, cameraRig, scene,
|
|
get phase() { return phase; },
|
|
get site() { return SITES[siteIdx]; },
|
|
get anchorIds() { return world.anchors.map((a) => a.id); },
|
|
/** rig by id, exactly as if you'd clicked it */
|
|
pick: (id) => ui.session.rig(id),
|
|
cycle: (id) => ui.session.cycleHardware(id),
|
|
switchSite,
|
|
/**
|
|
* What the MOUSE would actually hit, read off the live scene graph rather than
|
|
* asked of the UI — the honest proof that setWorld rebuilt the clickable
|
|
* markers, not just the panel text. Every marker carries userData.anchorId on
|
|
* its (invisible) pick sphere.
|
|
*/
|
|
pickTargetIds: () => {
|
|
const ids = [];
|
|
scene.traverse((o) => { if (o.userData?.anchorId) ids.push(o.userData.anchorId); });
|
|
return ids.sort();
|
|
},
|
|
};
|
|
|
|
addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && phase === 'prep') { if (ui.commit()) ui.setActive(false); }
|
|
if (e.key.toLowerCase() === 'n') { switchSite(siteIdx + 1); return; } // next yard
|
|
if (e.key.toLowerCase() === 'r') {
|
|
phase = 'prep';
|
|
wind = calmWind;
|
|
ui.setActive(true);
|
|
if (sailView) { scene.remove(sailView); sailView = null; }
|
|
}
|
|
});
|
|
|
|
// follow-cam target: the yard centre, so prep reads as a site walk-around
|
|
const focus = new THREE.Vector3(0, 1.2, 0);
|
|
const dev = document.getElementById('dev');
|
|
const clock = new THREE.Clock();
|
|
let t = 0, acc = 0;
|
|
|
|
renderer.setAnimationLoop(() => {
|
|
const raw = Math.min(0.25, clock.getDelta());
|
|
acc += raw;
|
|
while (acc >= FIXED_DT) {
|
|
t += FIXED_DT;
|
|
world.update(FIXED_DT, t);
|
|
if (phase === 'storm') rig.step(FIXED_DT, windProxy, t);
|
|
acc -= FIXED_DT;
|
|
if (msgT > 0) msgT -= FIXED_DT;
|
|
}
|
|
ui.update(raw, t);
|
|
sailView?.update();
|
|
cameraRig.update(raw, focus);
|
|
|
|
const s = ui.summary;
|
|
const loads = rig.rigged
|
|
? rig.corners.map((c) => `${c.anchorId}:${c.broken ? 'BLOWN' : (c.load / 1000).toFixed(1) + 'kN'}`).join(' ')
|
|
: '(not rigged)';
|
|
dev.textContent =
|
|
`${phase.toUpperCase()} t ${t.toFixed(1)}s wind ${windProxy.sample(focus, t).length().toFixed(1)} m/s\n` +
|
|
`budget $${s.budget} corners ${s.corners.length}/4 area ${s.area ? s.area.toFixed(0) + ' m2' : '—'}\n` +
|
|
`${loads}` + (msgT > 0 ? `\n!! ${msg}` : '');
|
|
|
|
renderer.render(scene, cameraRig.object);
|
|
});
|
|
|
|
addEventListener('resize', () => {
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
cameraRig.object.aspect = innerWidth / innerHeight;
|
|
cameraRig.object.updateProjectionMatrix();
|
|
});
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
cameraRig.object.aspect = innerWidth / innerHeight;
|
|
cameraRig.object.updateProjectionMatrix();
|
|
</script>
|
|
</body>
|
|
</html>
|