diff --git a/web/world/dev_rigging.html b/web/world/dev_rigging.html
new file mode 100644
index 0000000..fe8f9e1
--- /dev/null
+++ b/web/world/dev_rigging.html
@@ -0,0 +1,160 @@
+
+
+
+
+SHADES — Lane B rigging harness
+
+
+
+
+
+
+
+Lane B harness — the prep phase only.
+ENTER commits the rig and starts a storm. R resets to prep.
+
+
+
+
diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js
index 31a6273..ea42f68 100644
--- a/web/world/js/rigging.js
+++ b/web/world/js/rigging.js
@@ -147,11 +147,262 @@ export class RiggingSession {
/**
* Prep-phase picking UI.
*
- * Deliberately unimplemented: it needs Lane A's camera, renderer canvas and
- * anchor markers to raycast against, none of which exist yet. RiggingSession
- * above holds all the rules and is fully tested, so this stays a thin
- * click-to-session adapter once M0 lands. See THREADS.md.
+ * Everything above is the rules; this is only the hands. 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 rather than to the terrain.
+ *
+ * Controls: LMB an anchor to rig it, LMB again to cycle its hardware,
+ * shift-LMB 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 belongs to Lane
+ * A's phase machine, which calls commit() on the way out of prep.
+ *
+ * @param {object} o
+ * @param {object} o.scene THREE.Scene to hang markers in
+ * @param {object} o.camera cameraRig.object — what we raycast from
+ * @param {Element} o.domElement renderer.domElement — where clicks land
+ * @param {object} o.world needs world.anchors
+ * @param {function} o.onCommit (anchorIds, hwChoices, tension) => void — Lane A's rigSail
+ * @param {function} [o.onMessage] (text) => void — refusals, for the event ticker
+ * @param {boolean} [o.panel=true] draw the built-in prep panel; false if hud.js takes it over
*/
-export async function createRiggingUI() {
- throw new Error('rigging UI lands once Lane A has a camera and anchor markers — see THREADS.md');
+export async function createRiggingUI({
+ scene, camera, domElement, world,
+ onCommit, onMessage = () => {}, panel = true,
+} = {}) {
+ const THREE = await import('../vendor/three.module.js');
+ const session = new RiggingSession({ anchors: world.anchors });
+
+ // --- markers -----------------------------------------------------------
+ const group = new THREE.Group();
+ group.visible = false;
+ scene.add(group);
+
+ const DIM = 0x33424c;
+ const ringGeo = new THREE.TorusGeometry(0.28, 0.05, 8, 20);
+ const dotGeo = new THREE.SphereGeometry(0.1, 10, 8);
+ // What you click is NOT what you see: the ring's tube is 5 cm, which at yard
+ // distance is a couple of pixels and unhittable. Pick against an invisible
+ // sphere big enough to mean "that anchor" and let the ring just be the read.
+ const pickGeo = new THREE.SphereGeometry(0.45, 8, 6);
+ const pickMat = new THREE.MeshBasicMaterial({ visible: false });
+
+ const markers = world.anchors.map((a) => {
+ const mat = new THREE.MeshBasicMaterial({ color: DIM, transparent: true, opacity: 0.9 });
+ const ring = new THREE.Mesh(ringGeo, mat);
+ const dot = new THREE.Mesh(dotGeo, mat);
+ const hit = new THREE.Mesh(pickGeo, pickMat);
+ hit.userData.anchorId = a.id;
+ const holder = new THREE.Group();
+ holder.add(ring, dot, hit, makeLabel(THREE, a.id.toUpperCase()));
+ group.add(holder);
+ return { anchor: a, holder, ring, dot, hit, mat, label: holder.children[3] };
+ });
+ const pickTargets = markers.map((m) => m.hit);
+
+ // --- quad preview ------------------------------------------------------
+ // A closed loop through the ring-ordered picks: this is the shape you are
+ // about to build, drawn before you commit to it.
+ const previewGeo = new THREE.BufferGeometry();
+ previewGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(5 * 3), 3));
+ const preview = new THREE.Line(
+ previewGeo,
+ new THREE.LineDashedMaterial({ color: 0xffd27a, dashSize: 0.35, gapSize: 0.25 }),
+ );
+ preview.frustumCulled = false;
+ group.add(preview);
+
+ // --- panel -------------------------------------------------------------
+ const el = panel ? document.createElement('div') : null;
+ if (el) {
+ el.id = 'rigging-panel';
+ el.style.cssText = `position:fixed;top:12px;left:12px;z-index:20;display:none;
+ background:#0d1418e0;border:1px solid #2c3a44;border-radius:6px;padding:10px 12px;
+ font:12px/1.65 ui-monospace,Menlo,monospace;color:#dde5ea;min-width:280px;
+ white-space:pre;pointer-events:none`;
+ document.body.appendChild(el);
+ }
+
+ let active = false;
+ let hovered = null;
+
+ const ndc = new THREE.Vector2();
+ const ray = new THREE.Raycaster();
+ const scratch = new THREE.Vector3();
+
+ function pickAt(ev) {
+ const r = domElement.getBoundingClientRect();
+ ndc.x = ((ev.clientX - r.left) / r.width) * 2 - 1;
+ ndc.y = -((ev.clientY - r.top) / r.height) * 2 + 1;
+ ray.setFromCamera(ndc, camera);
+ return ray.intersectObjects(pickTargets, false)[0]?.object.userData.anchorId ?? null;
+ }
+
+ function say(result) {
+ if (result && result.ok === false) onMessage(result.reason);
+ return result;
+ }
+
+ function onPointerDown(ev) {
+ if (!active || ev.button !== 0) return; // RMB is the camera's
+ const id = pickAt(ev);
+ if (!id) return;
+ ev.preventDefault();
+ if (!session.isRigged(id)) say(session.rig(id));
+ else if (ev.shiftKey) say(session.unrig(id));
+ else say(session.cycleHardware(id));
+ refresh();
+ }
+
+ function onPointerMove(ev) {
+ if (!active) return;
+ hovered = pickAt(ev);
+ domElement.style.cursor = hovered ? 'pointer' : '';
+ }
+
+ function onKeyDown(ev) {
+ if (!active) return;
+ if (ev.key === '[') session.setTension(session.tension - 0.05);
+ else if (ev.key === ']') session.setTension(session.tension + 0.05);
+ else if (ev.key.toLowerCase() === 's') say(session.setSpares(session.spares ? 0 : 1));
+ else return;
+ ev.preventDefault();
+ refresh();
+ }
+
+ domElement.addEventListener('pointerdown', onPointerDown);
+ domElement.addEventListener('pointermove', onPointerMove);
+ addEventListener('keydown', onKeyDown);
+
+ /** Ground-plane area of the quad as picked, m² — the 70-192 m² problem, visible. */
+ function quadArea() {
+ if (session.picks.length !== MAX_CORNERS) return 0;
+ const p = session.picks.map((k) => world.anchors.find((a) => a.id === k.anchorId).pos);
+ const tri = (a, b, c) =>
+ new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)).length() * 0.5;
+ return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]);
+ }
+
+ function refresh() {
+ if (!el) return;
+ const s = session.summary;
+ const rows = world.anchors.map((a) => {
+ const pick = session.pickOf(a.id);
+ if (!pick) return ` ${a.id.padEnd(3)} ${a.type.padEnd(6)} —`;
+ const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
+ return ` ${a.id.padEnd(3)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
+ });
+ const area = quadArea();
+ el.textContent = [
+ `PREP — rig four corners $${s.budget} left`,
+ `tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
+ '',
+ ...rows,
+ '',
+ s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
+ 'click anchor: rig / cycle hw shift-click: remove',
+ '[ ] tension S spare RMB orbit',
+ ].join('\n');
+ }
+
+ const ui = {
+ session,
+ get summary() { return { ...session.summary, area: quadArea() }; },
+ get canStart() { return session.canStart; },
+ get active() { return active; },
+
+ /** Lane A: call on phaseChange — markers and clicks are prep-only. */
+ setActive(on) {
+ active = !!on;
+ group.visible = active;
+ if (el) el.style.display = active ? 'block' : 'none';
+ if (!active) domElement.style.cursor = '';
+ if (active) refresh();
+ return ui;
+ },
+
+ /** Markers ride the anchors, so a tree corner wanders before you even rig it. */
+ update(dt, t) {
+ if (!active) return;
+ for (const m of markers) {
+ const p = m.anchor.sway ? m.anchor.sway(t) : m.anchor.pos;
+ m.holder.position.set(p.x, p.y, p.z);
+ m.holder.quaternion.copy(camera.quaternion); // rings face the player
+ const pick = session.pickOf(m.anchor.id);
+ m.mat.color.setHex(pick ? pick.hw.color : DIM);
+ const s = (hovered === m.anchor.id ? 1.35 : 1) * (pick ? 1.15 : 1);
+ m.ring.scale.setScalar(s);
+ m.label.visible = !!pick || hovered === m.anchor.id;
+ }
+
+ const pos = previewGeo.attributes.position;
+ if (session.picks.length >= 2) {
+ preview.visible = true;
+ const n = session.picks.length;
+ for (let i = 0; i <= n; i++) {
+ const k = session.picks[i % n];
+ const a = world.anchors.find((x) => x.id === k.anchorId);
+ const p = a.sway ? a.sway(t) : a.pos;
+ scratch.set(p.x, p.y, p.z);
+ pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
+ }
+ // degenerate tail so a partial pick doesn't draw a stale segment
+ for (let i = session.picks.length + 1; i < 5; i++) pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
+ pos.needsUpdate = true;
+ previewGeo.setDrawRange(0, session.picks.length + 1);
+ preview.computeLineDistances();
+ } else {
+ preview.visible = false;
+ }
+ },
+
+ /** Hand the finished rig to Lane A's rigSail. Returns false if it isn't four corners. */
+ commit() {
+ if (!session.canStart) {
+ onMessage(`rig ${MAX_CORNERS - session.picks.length} more corner(s) first`);
+ return false;
+ }
+ onCommit(
+ session.picks.map((p) => p.anchorId),
+ session.picks.map((p) => p.hw),
+ session.tension,
+ );
+ return true;
+ },
+
+ dispose() {
+ domElement.removeEventListener('pointerdown', onPointerDown);
+ domElement.removeEventListener('pointermove', onPointerMove);
+ removeEventListener('keydown', onKeyDown);
+ scene.remove(group);
+ ringGeo.dispose(); dotGeo.dispose(); previewGeo.dispose();
+ preview.material.dispose();
+ for (const m of markers) { m.mat.dispose(); m.label.material.map?.dispose(); m.label.material.dispose(); }
+ el?.remove();
+ },
+ };
+
+ refresh();
+ return ui;
+}
+
+/** A cheap canvas-texture nameplate, so anchors read as h1/t2/p1 rather than dots. */
+function makeLabel(THREE, text) {
+ const c = document.createElement('canvas');
+ c.width = 128; c.height = 64;
+ const g = c.getContext('2d');
+ g.font = 'bold 40px ui-monospace, Menlo, monospace';
+ g.textAlign = 'center';
+ g.textBaseline = 'middle';
+ g.lineWidth = 6;
+ g.strokeStyle = '#0d1418';
+ g.strokeText(text, 64, 32);
+ g.fillStyle = '#dde5ea';
+ g.fillText(text, 64, 32);
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
+ map: new THREE.CanvasTexture(c), depthTest: false, transparent: true,
+ }));
+ sprite.position.set(0, 0.55, 0);
+ sprite.scale.set(0.8, 0.4, 1);
+ return sprite;
}