From cb557a1d6f48a24a4ca75dfd0e2242d04df08280 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 00:14:34 +1000 Subject: [PATCH] Add prep-phase picking UI over RiggingSession (unblocks A step 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/world/dev_rigging.html | 160 ++++++++++++++++++++++ web/world/js/rigging.js | 263 ++++++++++++++++++++++++++++++++++++- 2 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 web/world/dev_rigging.html 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; }