HardYards/web/world/dev_rigging.html
m3ultra cb557a1d6f Add prep-phase picking UI over RiggingSession (unblocks A step 8)
RiggingSession already held every rule and was tested headless; this is
only the hands. Click an anchor to rig it, click again to cycle its
hardware, shift-click to pull it off for a full refund, [ and ] for
tension, S for the spare. RMB is left alone — that's the camera's orbit.
Enter stays Lane A's: the phase machine calls commit(), which goes
through main.js's rigSail() door as A asked.

It renders its own anchor markers because the yard has none to raycast
against — world.js builds posts and trunks, not pick targets — and
marker styling is prep-phase UI, so it belongs to this lane.

What you click is not what you see: the marker ring's tube is 5 cm, which
at yard distance is a couple of pixels and genuinely unhittable. Picking
goes against an invisible 0.45 m sphere and the ring is just the read.

The panel shows live sail AREA, which SPRINT2 doesn't ask for but
decision 2 implies: the 70-192 m² problem is invisible to a player who
can't see what they're about to build. Picking the obvious quad
(h1/h3/p1/p2) now says "191 m2" before you commit to it.

dev_rigging.html follows the house pattern (C's weather_demo, D's
dev_player): the picking UI can't be asserted headless — it is clicks,
raycasts and materials — and can't run in index.html until A wires step
8. It boots the real world.js, real anchors and real cloth, so what's
verified is what ships. Retire it once index.html hosts prep.

Verified by hand in it: rig h2 by click, cycle carabiner -> shackle,
budget $80 -> $65, weak link flagged, dashed quad preview, commit ->
sail in scene (100 verts, 162 tris, casting a real shadow over the
garden bed) -> storm with corner loads reading 1.0-1.2 kN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:14:34 +10:00

161 lines
5.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.</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 } 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); },
};
const world = createWorld(scene, { wind: windProxy });
const cameraRig = createCameraRig(canvas);
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
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);
// 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; },
/** rig by id, exactly as if you'd clicked it */
pick: (id) => ui.session.rig(id),
cycle: (id) => ui.session.cycleHardware(id),
};
addEventListener('keydown', (e) => {
if (e.key === 'Enter' && phase === 'prep') { if (ui.commit()) ui.setActive(false); }
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>