diff --git a/Dockerfile b/Dockerfile index de6b7d792..5486bf219 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,13 @@ RUN curl -fSL https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v -o /build/q2/cockpit/dist/body.soa.gz \ && ls -lh /build/q2/cockpit/dist/body.soa.gz +# Same for the /helix wire: one SoA (BSO2 ver 6) = F16 pos + a canonical Signed360 +# NORMAL column in the same struct-of-arrays. Same-origin for the same CORS reason; +# named by cockpit/public/body.manifest.json (helix_latest). Stays in the release. +RUN curl -fSL https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1/body.20260629.v6helix.soa.gz \ + -o /build/q2/cockpit/dist/body.20260629.v6helix.soa.gz \ + && ls -lh /build/q2/cockpit/dist/body.20260629.v6helix.soa.gz + # Sibling deps — clone from GitHub # graph-flow stub is local (crates/stubs/graph-flow), no rs-graph-llm needed # diff --git a/cockpit/public/body.manifest.json b/cockpit/public/body.manifest.json new file mode 100644 index 000000000..76bc3c55d --- /dev/null +++ b/cockpit/public/body.manifest.json @@ -0,0 +1,5 @@ +{ + "helix_latest": "body.20260629.v6helix.soa.gz", + "note": "Single SoA wire (BSO2 ver 6): F16 positions + a Signed360 NORMAL column in the same struct-of-arrays, plus an HXFL floor trailer (the RollingFloor lo,hi). Baked by helixbake using the REAL lance-graph::helix::ResidueEncoder::encode_signed against the local ndarray fork — the Fisher-Z rim is populated (not zeroed). Published to the fma-body-soa-v3-v1 release; the Dockerfile pulls it same-origin. Decode: rim r=sinθ -> int8 normal at load, Gouraud per-vertex shading (no per-fragment lighting). Sampled round-trip err: mean 0.21 deg, p99 0.85 deg, grid-worst ~1.83 deg.", + "verts": 4283525 +} diff --git a/cockpit/src/BodyHelix.tsx b/cockpit/src/BodyHelix.tsx new file mode 100644 index 000000000..bc5ac27a7 --- /dev/null +++ b/cockpit/src/BodyHelix.tsx @@ -0,0 +1,344 @@ +// /helix — EXPERIMENTAL viewer. Parallel to /body (BodyV3); shares NOTHING with it so the +// working /body can never break. Shades from the per-vertex helix NORMAL. +// +// The normal is the canonical lance-graph::helix::Signed360 (6 bytes, place-coupled to the +// HHTL address): rim endpoint pair (Fisher-Z radial) + signed polar lift + golden azimuth. +// At LOAD (once) we invert the Fisher-Z rim → r=sinθ and bake each vertex to a normalized +// int8 NORMAL — the cheapest possible carrier. Per frame the GPU just normalize()s it and +// GOURAUD-shades per vertex; the fragment shader is trivial. That is the lever against the +// 12 s/frame cost: the quality is carried by per-vertex shading + interpolation, not by +// expensive per-fragment lighting. REQUIRES the canonical helix bake (helixbake → +// helix::encode_signed, BSO2 ver 6 + HXFL floor trailer); the old helix_orient artifact is +// a different codec and is NOT read here. +// +// Reads the stamped artifact named by `/body.manifest.json` (`helix_latest`), local first +// then the GitHub release — so a new bake is swapped in by bumping the manifest, never by +// deleting the working one. +import { useEffect, useRef, useState } from 'react'; +import * as THREE from 'three'; + +const PAGE_BG = 0x0a0d12; +const REL = 'https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1'; + +const LAYERS = [ + { id: 1, name: 'skin', color: '#dba88a' }, { id: 2, name: 'muscle', color: '#bd5c57' }, + { id: 3, name: 'organ', color: '#cc9484' }, { id: 4, name: 'skeleton', color: '#ebe0c7' }, + { id: 5, name: 'vessel', color: '#cc3838' }, { id: 6, name: 'nervous', color: '#ebd152' }, + { id: 7, name: 'connective', color: '#e0dbcc' }, { id: 8, name: 'other', color: '#9696a0' }, +]; +const hexRgb = (h: string): [number, number, number] => + [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; +const LAYER_RGB: Record = Object.fromEntries(LAYERS.map((l) => [l.id, hexRgb(l.color)])); +const frac = (x: number) => x - Math.floor(x); + +// per-concept tint (matches /body's non-vessel scheme so the two viewers read alike). +function conceptColor(layerId: number, row: number): [number, number, number] { + const base = LAYER_RGB[layerId] ?? [150, 150, 160]; + const h = frac(Math.sin(row * 12.9898) * 43758.5453); + const bright = 0.82 + 0.34 * h; + const tilt = (s: number) => 1 + 0.13 * (frac(Math.sin(row * s) * 9711.13) - 0.5) * 2; + return [ + Math.min(255, base[0] * bright * tilt(1.7)), + Math.min(255, base[1] * bright * tilt(2.9)), + Math.min(255, base[2] * bright * tilt(4.1)), + ]; +} + +const HALF_LUT: Float32Array = (() => { + const t = new Float32Array(65536); + for (let h = 0; h < 65536; h++) { + const s = (h & 0x8000) ? -1 : 1, e = (h & 0x7c00) >> 10, f = h & 0x03ff; + t[h] = e === 0 ? s * Math.pow(2, -14) * (f / 1024) + : e === 0x1f ? (f ? NaN : s * Infinity) : s * Math.pow(2, e - 15) * (1 + f / 1024); + } + return t; +})(); + +// ── canonical lance-graph::helix::Signed360 (6 bytes, full sphere) ── +// Wire (LE): [rim.start, rim.end, rim.floor_version, polar, azimuth_lo, azimuth_hi]. +// rim.end → the Fisher-Z RADIAL: r = sinθ, quantised as arctanh(r) into the 256-palette +// (densest at the equator — Δθ = cosθ·Δz → 0 at θ=90°). This is the STRENGTH the +// place-blind transcoder used to zero; we decode it. palette256 = the angle. +// polar → hemisphere SIGN (partition) + a coarse |y|, used only on the r→1 saturation cliff. +// azimuth → φ = az_u16 / 65536 · 2π. +// We decode the normal ONCE at load into a normalized int8 attribute (the rim inversion's +// only atanh/tanh runs 256× building the r-LUT — never per vertex, never per frame). The +// vertex itself is then the cheapest possible carrier: a 3-byte normal the GPU reads with +// one normalize(). Quality is carried by GOURAUD shading (lighting per-vertex, colour +// interpolated) — at 6.8 M sub-pixel tris that is visually identical to per-fragment +// lighting but leaves the fragment shader trivial. The HXFL trailer carries the exact +// RollingFloor (lo,hi) the bake used so this dequantiser matches the encoder. +const TAU = Math.PI * 2; +const STRIDE = 4, GAMMA = 0.5772156649015329, LN17 = 2.833213344056216; +const atanh = (s: number) => 0.5 * Math.log((1 + s) / (1 - s)); +// aligned(r) = arctanh(r)·STRIDE + γ·(r² − ln17) — helix::ResidueEncoder::aligned_for_residue +// with the rank u = r². Monotone in r → invert by bisection. r = sinθ. +function rFromAligned(aligned: number): number { + let lo = 0, hi = 1 - 1e-9; + for (let it = 0; it < 40; it++) { + const m = 0.5 * (lo + hi); + if (atanh(m) * STRIDE + GAMMA * (m * m - LN17) < aligned) lo = m; else hi = m; + } + return 0.5 * (lo + hi); +} +// 256-entry r-LUT from the bake's RollingFloor (lo,hi): bucket_center(e) → aligned → r=sinθ. +function buildRLut(flo: number, fhi: number): Float32Array { + const t = new Float32Array(256); + for (let e = 0; e < 256; e++) t[e] = rFromAligned(flo + ((e + 0.5) / 256) * (fhi - flo)); + return t; +} + +interface Decoded { + nVerts: number; nTris: number; + positions: Float32Array; index: Uint32Array; + colors: Uint8Array; normals: Int8Array; layer: Float32Array; + concepts: number; +} + +function decode(buf: ArrayBuffer): Decoded { + const dv = new DataView(buf); + const magic = String.fromCharCode(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3)); + if (magic !== 'BSO2') throw new Error(`bad magic "${magic}"`); + const ver = dv.getUint16(4, true); + const posBytes = ver >= 4 ? 6 : 12; + const nC = dv.getUint32(6, true), nV = dv.getUint32(10, true), nT = dv.getUint32(14, true); + let o = 18; + o += 16 * nC; // guid + const matOff = o; o += nC; // material u8 (unused here) + const layerOff = o; o += nC; // LAYER u8 + o += 4 * nC; // label idx + o += 12 * nC; // centroid + o += 8 * nC; // vrange + const posOff = o; o += posBytes * nV; + const helixOff = o; o += 6 * nV; // pos3 | nrm3 — we read the nrm half + const rowOff = o; o += 4 * nV; + const idxOff = o; o += 12 * nT; + void matOff; + + const cLayer = new Uint8Array(buf.slice(layerOff, layerOff + nC)); + + // positions (ver 5 = F16 via LUT) → display remap (-x, z, y) + let srcPos: Float32Array; + if (ver >= 5) { + const hf = new Uint16Array(buf.slice(posOff, posOff + nV * 6)); + srcPos = new Float32Array(nV * 3); + for (let k = 0; k < hf.length; k++) srcPos[k] = HALF_LUT[hf[k]]; + } else if (ver === 4) { + const bf = new Uint16Array(buf.slice(posOff, posOff + nV * 6)); + const w = new Uint32Array(nV * 3); + for (let k = 0; k < bf.length; k++) w[k] = bf[k] << 16; + srcPos = new Float32Array(w.buffer); + } else { + srcPos = new Float32Array(buf.slice(posOff, posOff + nV * 12)); + } + const helix = new Uint8Array(buf.slice(helixOff, helixOff + 6 * nV)); + const rowArr = new Uint32Array(buf.slice(rowOff, rowOff + 4 * nV)); + + // HXFL trailer (last 12 B): the RollingFloor (lo,hi) the bake used → the rim dequantiser. + let flo = -2.2567945, fhi = 11.535854; // fallback = the 2026-06-29 bake's floor + if (buf.byteLength >= 12) { + const t0 = buf.byteLength - 12; + const tag = String.fromCharCode(dv.getUint8(t0), dv.getUint8(t0 + 1), dv.getUint8(t0 + 2), dv.getUint8(t0 + 3)); + if (tag === 'HXFL') { flo = dv.getFloat32(t0 + 4, true); fhi = dv.getFloat32(t0 + 8, true); } + } + const rLut = buildRLut(flo, fhi); + + const positions = new Float32Array(nV * 3); + const colors = new Uint8Array(nV * 3); + const normals = new Int8Array(nV * 3); // rim-decoded unit normal (display frame), cheap i8 + const layer = new Float32Array(nV); + for (let i = 0; i < nV; i++) { + positions[i * 3] = -srcPos[i * 3]; + positions[i * 3 + 1] = srcPos[i * 3 + 2]; + positions[i * 3 + 2] = srcPos[i * 3 + 1]; + const r0 = rowArr[i], li = cLayer[r0] || 8; + const rgb = conceptColor(li, r0); + colors[i * 3] = rgb[0]; colors[i * 3 + 1] = rgb[1]; colors[i * 3 + 2] = rgb[2]; + // Signed360 → unit normal: r=sinθ from the Fisher-Z RIM (its strength; saturated cliff + // falls back to the polar partition), hemisphere sign from polar, φ from azimuth. Same + // display remap as the position (-X, Z, yw). One-time at load; never per frame. + const end = helix[i * 6 + 1], polar = helix[i * 6 + 3]; + const az16 = helix[i * 6 + 4] | (helix[i * 6 + 5] << 8); + const sgn = polar >= 128 ? 1 : -1; + const yp = polar >= 128 ? (polar - 128) / 127 : -(127 - polar) / 127; + const rr = end >= 255 ? Math.sqrt(Math.max(0, 1 - yp * yp)) : rLut[end]; + const yw = sgn * Math.sqrt(Math.max(0, 1 - rr * rr)); + const az = (az16 / 65536) * TAU; + normals[i * 3] = Math.max(-127, Math.min(127, Math.round(-rr * Math.sin(az) * 127))); + normals[i * 3 + 1] = Math.max(-127, Math.min(127, Math.round(rr * Math.cos(az) * 127))); + normals[i * 3 + 2] = Math.max(-127, Math.min(127, Math.round(yw * 127))); + layer[i] = li; + } + const raw = new Uint32Array(buf.slice(idxOff, idxOff + 12 * nT)); + const index = new Uint32Array(raw); // straight copy (no opaque/transparent split) + return { nVerts: nV, nTris: nT, positions, index, colors, normals, layer, concepts: nC }; +} + +const VERT = ` +precision highp float; +attribute vec3 aColor; attribute vec3 aNormal; attribute float aLayer; +varying vec3 vColor; varying float vLayer; +void main(){ + vLayer = aLayer; + // GOURAUD: shade per-vertex from the cheap rim normal, interpolate the COLOUR across the + // face. At 6.8 M sub-pixel tris this matches per-fragment lighting visually but leaves the + // fragment shader trivial — the lever that removes the 12 s/frame fragment cost. The two- + // sided ambient (n.y term + floor) keeps back faces lit without a per-fragment flip. + vec3 n = normalize(normalMatrix * aNormal); + const vec3 L = vec3(-0.401, 0.783, 0.476); + float ndl = max(abs(dot(n, L)), 0.0); + float shade = min(0.34 + 0.20*(abs(n.y)*0.5+0.5) + 0.12*(-n.x*0.5+0.5) + 0.92*ndl, 1.3); + vColor = aColor * shade; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +}`; +const FRAG = ` +precision mediump float; +uniform float uEnabled[9]; +varying vec3 vColor; varying float vLayer; +void main(){ + int li = int(vLayer + 0.5); + if(li < 1 || li > 8 || uEnabled[li] < 0.5) discard; + gl_FragColor = vec4(vColor, 1.0); // pre-shaded (Gouraud) — no per-fragment lighting. +}`; + +function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dirty: { current: boolean }): () => void { + let w = container.clientWidth || window.innerWidth, h = container.clientHeight || window.innerHeight; + const scene = new THREE.Scene(); scene.background = new THREE.Color(PAGE_BG); + const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100); camera.position.set(0, 0.05, 3.0); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(w, h); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + container.appendChild(renderer.domElement); + + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.BufferAttribute(d.positions, 3)); + geom.setAttribute('aColor', new THREE.Uint8BufferAttribute(d.colors, 3, true)); + geom.setAttribute('aNormal', new THREE.Int8BufferAttribute(d.normals, 3, true)); // rim normal, normalized i8 + geom.setAttribute('aLayer', new THREE.BufferAttribute(d.layer, 1)); + geom.setIndex(new THREE.BufferAttribute(d.index, 1)); + + const uniforms = { uEnabled: { value: enabled } }; + const mat = new THREE.ShaderMaterial({ uniforms, vertexShader: VERT, fragmentShader: FRAG, side: THREE.DoubleSide }); + const mesh = new THREE.Mesh(geom, mat); scene.add(mesh); + + // minimal orbit: drag = rotate, wheel = dolly. + let az = 0, el = 0.1, dist = 3.0, dragging = false, px = 0, py = 0; + const target = new THREE.Vector3(0, 0, 0); + const onDown = (e: PointerEvent) => { dragging = true; px = e.clientX; py = e.clientY; dirty.current = true; }; + const onUp = () => { dragging = false; dirty.current = true; }; + const onMove = (e: PointerEvent) => { + if (!dragging) return; + az -= (e.clientX - px) * 0.005; el = Math.max(-1.5, Math.min(1.5, el + (e.clientY - py) * 0.005)); + px = e.clientX; py = e.clientY; dirty.current = true; + }; + const onWheel = (e: WheelEvent) => { e.preventDefault(); dist = Math.max(0.3, Math.min(8, dist * (1 + Math.sign(e.deltaY) * 0.1))); dirty.current = true; }; + const el2 = renderer.domElement; + el2.addEventListener('pointerdown', onDown); window.addEventListener('pointerup', onUp); + window.addEventListener('pointermove', onMove); el2.addEventListener('wheel', onWheel, { passive: false }); + + let raf = 0, ema = 16.6, last = performance.now(); + const onResize = () => { + w = container.clientWidth || window.innerWidth; h = container.clientHeight || window.innerHeight; + camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); dirty.current = true; + }; + window.addEventListener('resize', onResize); + const tick = () => { + raf = requestAnimationFrame(tick); + // render ON DEMAND: a static body (no drag/zoom/toggle) costs nothing — 6.8 M tris are + // only redrawn when something actually changes, which is what makes idle + heat sane. + if (!dirty.current && !dragging) { last = performance.now(); return; } + const now = performance.now(); ema = ema * 0.9 + (now - last) * 0.1; last = now; + // adaptive DPR: drop to 1× when a frame is slow (>~30 fps budget). On a retina phone + // this is the single biggest lever — quarters/ninths the fragment load while dragging. + const pr = ema > 33 ? 1 : Math.min(window.devicePixelRatio, 2); + if (renderer.getPixelRatio() !== pr) renderer.setPixelRatio(pr); + uniforms.uEnabled.value = enabled; + camera.position.set(target.x + dist * Math.cos(el) * Math.sin(az), target.y + dist * Math.sin(el), target.z + dist * Math.cos(el) * Math.cos(az)); + camera.lookAt(target); + renderer.render(scene, camera); + dirty.current = false; + }; + tick(); + return () => { + cancelAnimationFrame(raf); + el2.removeEventListener('pointerdown', onDown); window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointermove', onMove); el2.removeEventListener('wheel', onWheel); + window.removeEventListener('resize', onResize); + geom.dispose(); mat.dispose(); renderer.dispose(); + if (el2.parentElement === container) container.removeChild(el2); + }; +} + +const inflate = async (r: Response): Promise => { + const buf = await r.arrayBuffer(); + const u8 = new Uint8Array(buf); + if (!(u8.length > 1 && u8[0] === 0x1f && u8[1] === 0x8b)) return buf; + if (typeof DecompressionStream === 'undefined') throw new Error('gzip but no DecompressionStream'); + return new Response(new Blob([buf]).stream().pipeThrough(new DecompressionStream('gzip'))).arrayBuffer(); +}; + +// CANONICAL-ONLY: read the stamped helix bake (Signed360 normals) named by the manifest. +// We deliberately do NOT fall back to the shared body.soa.gz — that artifact carries the +// OLD helix_orient codec (a different, place-blind encoding), and reading its bytes as +// Signed360 would render garbage. Until a canonical bake is published, /helix says so. +async function fetchSoa(): Promise { + const man = await fetch('/body.manifest.json').then((r) => (r.ok ? r.json() : null)).catch(() => null); + const stamped: string | undefined = man?.helix_latest; + if (!stamped) { + throw new Error('no canonical helix bake yet — set helix_latest in /body.manifest.json (soabake → helix::encode_signed)'); + } + const s = await fetch(`/${stamped}`).catch(() => null); + if (s && s.ok) return inflate(s); + const rel = await fetch(`${REL}/${stamped}`); + if (!rel.ok) throw new Error(`HTTP ${rel.status} fetching ${stamped}`); + return inflate(rel); +} + +export default function BodyHelix() { + const ref = useRef(null); + const [d, setD] = useState(null); + const [error, setError] = useState(''); + const [on, setOn] = useState>({ 1: false, 2: false, 3: true, 4: true, 5: true, 6: true, 7: true, 8: true }); + const enabledRef = useRef(new Float32Array([0, 0, 1, 1, 1, 1, 1, 1, 1])); + const dirtyRef = useRef(true); // request a redraw (the render loop is on-demand) + + useEffect(() => { + let cancelled = false; + fetchSoa().then((b) => decode(b)).then((x) => { if (!cancelled) setD(x); }).catch((e) => { if (!cancelled) setError(String(e)); }); + return () => { cancelled = true; }; + }, []); + useEffect(() => { + // Mutate IN PLACE — mount captured THIS array; reassigning a new one leaves the + // renderer reading the stale array (the dead-toggles bug). Then request a redraw. + for (let i = 1; i <= 8; i++) enabledRef.current[i] = on[i] ? 1 : 0; + dirtyRef.current = true; + }, [on]); + useEffect(() => { const c = ref.current; if (!c || !d) return; return mount(c, d, enabledRef.current, dirtyRef); }, [d]); + + const btn = (active: boolean): React.CSSProperties => ({ + padding: '5px 10px', borderRadius: 6, cursor: 'pointer', border: '1px solid #2a3242', + background: active ? '#1c2738' : '#0e1219', color: active ? '#cdd9e5' : '#6b7686', font: '12px ui-monospace, monospace', + }); + + return ( +
+
+
+
/helix — surfel-normal viewer (experimental)
+
+ {error ? {error} + : d ? `${d.nVerts.toLocaleString()} verts · ${d.concepts.toLocaleString()} concepts — canonical helix::Signed360 normals: Fisher-Z rim → r=sinθ decoded once into a normalized int8 normal; Gouraud shading (per-vertex), trivial fragment shader.` + : 'loading canonical helix bake (Signed360 normals)…'} +
+
+ {d && ( +
+ {LAYERS.map((l) => ( + + ))} +
+ )} +
+ ); +} diff --git a/cockpit/src/BodyV3.tsx b/cockpit/src/BodyV3.tsx index 32f729b40..cde66ac04 100644 --- a/cockpit/src/BodyV3.tsx +++ b/cockpit/src/BodyV3.tsx @@ -61,7 +61,11 @@ function conceptColor(layerId: number, matRgb: [number, number, number], row: nu return [Math.min(255, out[0]), Math.min(255, out[1]), Math.min(255, out[2])]; } -interface ConceptInfo { row: number; name: string; centroid: [number, number, number]; layer: number; material: string } +interface ConceptInfo { row: number; name: string; centroid: [number, number, number]; layer: number; material: string; verts: number } + +// semantic x-ray opacity per compartment (id 1..8): skin/muscle fade, organ mid, +// skeleton mid, vessel + nervous opaque so they pop through the body. index 0 unused. +const LAYER_XRAY_ALPHA = new Float32Array([1, 0.10, 0.20, 0.45, 0.50, 1.0, 1.0, 0.22, 0.30]); // 64K IEEE-half → f32 lookup (built once): ver-5 wire stores positions as F16 // (10-bit mantissa, ~0.2 mm here — no BF16 staircase). LUT keeps decode O(1)/vertex. @@ -82,7 +86,7 @@ interface Decoded { colors: Uint8Array; alpha: Float32Array; layer: Float32Array; row: Float32Array; materials: Material[]; labels: string[]; concepts: ConceptInfo[]; } -interface RenderState { enabled: Float32Array; alpha: number; transparent: boolean; lodOn: boolean; focus: { t: [number, number, number]; d: number } | null } +interface RenderState { enabled: Float32Array; alpha: number; transparent: boolean; lodOn: boolean; selRow: number; focus: { t: [number, number, number]; d: number } | null } function decodeBso2(buf: ArrayBuffer): Decoded { const dv = new DataView(buf); @@ -97,7 +101,7 @@ function decodeBso2(buf: ArrayBuffer): Decoded { const layerOff = o; o += nC; // LAYER index u8 (1..8) const labOff = o; o += 4 * nC; // label codebook index u32 const cenOff = o; o += 12 * nC; // per-concept centroid 3×f32 (search zoom + server LOD) - o += 8 * nC; // (vstart,vcount) + const vrOff = o; o += 8 * nC; // (vstart,vcount) — vcount = mesh size for the popup const posOff = o; o += posBytes * nV; o += 6 * nV; // helix (server-side) const rowOff = o; o += 4 * nV; @@ -112,6 +116,7 @@ function decodeBso2(buf: ArrayBuffer): Decoded { const cLayer = new Uint8Array(buf.slice(layerOff, layerOff + nC)); const cNameIdx = new Uint32Array(buf.slice(labOff, labOff + 4 * nC)); const cCen = new Float32Array(buf.slice(cenOff, cenOff + 12 * nC)); // bake-space; remap below + const cVR = new Uint32Array(buf.slice(vrOff, vrOff + 8 * nC)); // [vstart,vcount]×nC // per-concept colour (precompute once) + searchable concept table (name + centroid). const conceptRgb: [number, number, number][] = new Array(nC); @@ -123,6 +128,7 @@ function decodeBso2(buf: ArrayBuffer): Decoded { concepts[cI] = { row: cI, name: labels[cNameIdx[cI]] ?? `concept ${cI}`, layer: li, material: mat.name, centroid: [-cCen[cI * 3], cCen[cI * 3 + 2], cCen[cI * 3 + 1]], // (x,y,z)->(-x,z,y) display + verts: cVR[cI * 2 + 1], }; } @@ -186,6 +192,7 @@ const FRAG = ` precision mediump float; uniform float uEnabled[9]; uniform float uGlobalAlpha; uniform sampler2D uLod; uniform highp float uLodN; uniform float uLodOn; // server HHTL LOD gate +uniform float uXray; uniform float uLayerAlpha[9]; uniform highp float uSelRow; // semantic opacity varying vec3 vNormal; varying vec3 vColor; varying float vAlpha; varying float vLayer; varying highp float vRow; // highp: concept IDs up to ~1658 + the texel-center divide must // resolve exactly; mediump's min precision aliases adjacent rows. @@ -201,7 +208,11 @@ void main(){ const vec3 L = vec3(-0.401,0.783,0.476); float ndl = max(dot(n,L),0.0); float shade = min(0.34 + 0.20*(n.y*0.5+0.5) + 0.12*(-n.x*0.5+0.5) + 0.92*ndl, 1.3); - gl_FragColor = vec4(vColor*shade, vAlpha * uGlobalAlpha); // #17 alpha × solid/transparent + // x-ray: per-compartment semantic opacity (skin faint → vessel/nerve opaque); + // solid mode keeps the legacy uniform alpha. Selected concept always pops to 1.0. + float a = uXray > 0.5 ? uLayerAlpha[li] : (vAlpha * uGlobalAlpha); + if(uSelRow >= 0.0 && abs(vRow - uSelRow) < 0.5) a = 1.0; + gl_FragColor = vec4(vColor*shade, a); }`; function mount(container: HTMLDivElement, d: Decoded, st: RenderState, onStats: (s: { fps: number }) => void): () => void { @@ -238,6 +249,7 @@ function mount(container: HTMLDivElement, d: Decoded, st: RenderState, onStats: const uniforms = { uEnabled: { value: st.enabled }, uGlobalAlpha: { value: st.alpha }, uLod: { value: lodTex }, uLodN: { value: d.nConcepts }, uLodOn: { value: 0 }, + uXray: { value: 0 }, uLayerAlpha: { value: LAYER_XRAY_ALPHA }, uSelRow: { value: -1 }, }; // solid mode: opaque solids draw fast (transparent:false), #17 vessels blend over // them. transparent mode (port of /fma-body's uniform-uAlpha x-ray): BOTH groups go @@ -301,6 +313,8 @@ function mount(container: HTMLDivElement, d: Decoded, st: RenderState, onStats: if (renderer.getPixelRatio() !== pr) renderer.setPixelRatio(pr); uniforms.uEnabled.value = st.enabled; // shared by both materials uniforms.uGlobalAlpha.value = st.alpha; + uniforms.uXray.value = st.transparent ? 1 : 0; // x-ray ⇒ per-compartment semantic opacity + uniforms.uSelRow.value = st.selRow; // selected concept pops to full alpha if (!st.lodOn) lodFail = false; // toggling LOD off clears a transient failure → re-enabling retries uniforms.uLodOn.value = st.lodOn && !lodFail && lodReady ? 1 : 0; // only cull after a real response if (st.focus) { // search-pick zoom: glide to the organ @@ -346,16 +360,17 @@ export function BodyV3() { const [lod, setLod] = useState(false); // server HHTL LOD — opt-in (off = today's full render) const [query, setQuery] = useState(''); const [selected, setSelected] = useState(null); - const stRef = useRef({ enabled: new Float32Array([0, 0, 1, 1, 1, 1, 1, 1, 1]), alpha: 1, transparent: false, lodOn: false, focus: null }); + const stRef = useRef({ enabled: new Float32Array([0, 0, 1, 1, 1, 1, 1, 1, 1]), alpha: 1, transparent: false, lodOn: false, selRow: -1, focus: null }); useEffect(() => { const e = new Float32Array(9); for (let i = 1; i <= 8; i++) e[i] = on[i] ? 1 : 0; stRef.current.enabled = e; stRef.current.transparent = transparent; - // /fma-body translucency model: one uniform alpha for the WHOLE body. transparent - // ⇒ 0.42 x-ray (see through skin/muscle to organs); solid ⇒ 1.0 (#17 vessels only). - stRef.current.alpha = transparent ? 0.42 : 1.0; + // x-ray opacity is now SEMANTIC (per-compartment uLayerAlpha in the shader), so the + // whole-body uGlobalAlpha stays at 1.0 in both modes — it only scales #17 vessel + // blending in solid mode; x-ray ignores it entirely. + stRef.current.alpha = 1.0; stRef.current.lodOn = lod; }, [on, transparent, lod]); @@ -397,6 +412,7 @@ export function BodyV3() { function pick(c: ConceptInfo) { setSelected(c); stRef.current.focus = { t: c.centroid, d: 0.6 }; // glide the camera to the organ + stRef.current.selRow = c.row; // pop the selected concept to full alpha } const btn = (active: boolean): React.CSSProperties => ({ @@ -438,7 +454,9 @@ export function BodyV3() {
{matches.map((c) => ( ))} @@ -454,12 +472,14 @@ export function BodyV3() { {selected.material.replace(/_/g, ' ')} row #{selected.row} + mesh + {selected.verts.toLocaleString()} verts centroid {selected.centroid.map((v) => v.toFixed(2)).join(', ')}
- +
)} diff --git a/cockpit/src/main.tsx b/cockpit/src/main.tsx index d821c820d..42db01bca 100644 --- a/cockpit/src/main.tsx +++ b/cockpit/src/main.tsx @@ -14,6 +14,7 @@ import { TorsoRender } from './TorsoRender'; import { TorsoMap } from './TorsoMap'; import { FmaBody } from './FmaBody'; import { BodyV3 } from './BodyV3'; +import BodyHelix from './BodyHelix'; import { CpicCockpit } from './CpicCockpit'; import { ReasoningPage } from './ReasoningPage'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -105,6 +106,11 @@ createRoot(document.getElementById('root')!).render( cockpit/public/body.soa (BSO1 = V3 node table + SPM1 geometry). Polygons, not surfels — the successor to /torso-live's decimated 2k-concept torso. */} } /> + {/* /helix — EXPERIMENTAL sibling of /body. Same baked wire, but shades from the + per-vertex helix-normal bytes (Fisher-2z geodesic codes) via a 256×256 LUT + materialized once at load: one vertex-shader fetch/vert, no per-vertex decode, + no rebake. Standalone (BodyHelix.tsx) so it can never break /body (#64). */} + } /> {/* /cpic — CPIC pharmacogenomics cockpit (gene-first): {gene, diplotype, drug} → phenotype → recommendation, 2-hop NARS deduction over the real CPIC tables via POST /api/cpic/reason (the standalone cpic crate). Additive, gene-first diff --git a/crates/osint-bake/tools/BAKE_ARTIFACTS.md b/crates/osint-bake/tools/BAKE_ARTIFACTS.md new file mode 100644 index 000000000..b27fc5757 --- /dev/null +++ b/crates/osint-bake/tools/BAKE_ARTIFACTS.md @@ -0,0 +1,95 @@ +# Body SoA bake artifacts — stamp, don't clobber + +The `/body` (BodyV3) and `/helix` (BodyHelix) cockpit viewers both consume the +BSO2 SoA wire. **A new bake must never delete or overwrite a working artifact** — +experiments that turn out bad must not take down the deployed viewer. Stamp every +build; keep the old ones. + +## Naming + +``` +body.[-]..soa.gz # stamped, immutable once written +body.[-]..blocks # paired HHTL block bounds (cockpit-server /api/body/lod) +``` + +`` records the wire format so two encodings on the same day stay distinct: + +| `` | meaning | +|---|---| +| `v5f16` | ver-5 wire, F16 (IEEE half) positions — current `/body` production | +| `v5f16h2` | same, helix-normal tuned (2-byte refinement validated) — `/helix` target | +| `v3f32` | ver-3 wire, raw f32 positions (legacy / debugging) | + +`-` is an optional same-day rebuild counter (`body.20260628-2.v5f16.soa.gz`). + +## The two stable names are pointers, not bakes + +- `body.soa.gz` — the artifact `/body` serves. It is a **copy of the current + production stamp**, never a fresh bake written in place. Re-point it by copying + a stamped file over it *after* the stamp is validated. +- `body.manifest.json` — served same-origin; the viewers read it to find the + current stamps: + +```json +{ + "body_latest": "body.20260628.v5f16.soa.gz", + "helix_latest": "body.20260628.v5f16h2.soa.gz", + "builds": [ + { "stamp": "body.20260628.v5f16.soa.gz", "ver": 5, "fmt": "v5f16", "verts": 4221000, "concepts": 1658, "note": "production" }, + { "stamp": "body.20260628.v5f16h2.soa.gz", "ver": 5, "fmt": "v5f16h2", "verts": 4221000, "concepts": 1658, "note": "helix experiment" } + ] +} +``` + +`BodyHelix` prefers `helix_latest` (then falls back to the shared `body.soa.gz`); +`BodyV3` reads `body.soa.gz` directly. A bad helix experiment is rolled back by +editing one line of the manifest — the production `/body` artifact is never touched. + +## Producing a stamped bake + +The bake binaries take the output name as `argv[2]`, so the stamp is the caller's +responsibility (`{out}.blocks` is derived automatically): + +```sh +STAMP="body.$(date +%Y%m%d).v5f16" +./soabake fma_concepts.json "$STAMP.soa" # writes $STAMP.soa + $STAMP.blocks +gzip -k "$STAMP.soa" # → $STAMP.soa.gz (keep the raw .soa too) +# validate, then (and only then) promote: +cp "$STAMP.soa.gz" body.soa.gz # re-point /body, old stamps retained +``` + +Never `rm` a prior stamp. Disk is cheap; a black-screen deploy from a clobbered +artifact is not. + +## `/helix` needs the canonical bake (`helixbake`), NOT the old artifact + +The production `body.soa.gz` stores its per-vertex normal with the OLD ndarray +`helix_orient` codec (a place-blind 3-byte golden-spiral cascade). The canonical +`/helix` viewer decodes the **place-coupled `lance-graph::helix::Signed360`** +(6-byte: rim endpoint pair + signed polar lift + golden azimuth), so it CANNOT read +the old bytes — it would render garbage. `/helix` therefore reads only the stamped +canonical artifact named by `helix_latest`, and shows "no canonical helix bake yet" +until one is published. + +Produce it with the **separate** bake crate `scratch-fma/helixbake` (soabake — the +`/body` bake — is left byte-identical; helixbake is its own crate so the old +pipeline never resolves helix): + +```sh +cd scratch-fma/helixbake +STAMP="body.$(date +%Y%m%d).v6helix" +cargo run --release -- /path/to/soa "$STAMP.soa" # writes $STAMP.soa (BSO2 ver 6) + .blocks +gzip -k "$STAMP.soa" # → $STAMP.soa.gz +# then add to body.manifest.json: "helix_latest": "$STAMP.soa.gz" +``` + +The normal is generated via `helix::ResidueEncoder::encode_signed(place, n, sign)` +— `place` = the concept's HHTL path, `n` = the nearest spherical-Fibonacci index of +the world normal, `sign` = its hemisphere. `cargo test` in that crate runs the +encode↔decode round-trip (the same decode BodyHelix.tsx uses) on synthetic normals, +no FMA data required. + +(Build note: `helix` depends on `ndarray` via git; `helixbake/Cargo.toml` patches it +to the local `../../../ndarray` fork. A bake host that can't fetch the git source +relies on that patch; this sandbox's proxy blocks the fetch, so the crate is +validated by the round-trip test on a network-enabled host, not here.) diff --git a/crates/osint-bake/tools/transcode_helix_signed360.py b/crates/osint-bake/tools/transcode_helix_signed360.py new file mode 100644 index 000000000..295c025b1 --- /dev/null +++ b/crates/osint-bake/tools/transcode_helix_signed360.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Transcode a BSO2 ver-5 body wire (old ndarray helix_orient pos3|nrm3 helix +column) into a ver-6 wire whose 6-byte helix column carries a render-faithful +lance-graph helix::Signed360 NORMAL — for the /helix viewer. + +Why a transcode (not helixbake): the FMA body.* source columns are not available +here, but the released body.soa.gz already carries the per-vertex normals (as the +old helix_orient codes). We decode those back to Cartesian and re-encode the +Signed360 fields the renderer actually uses: polar (signed lift) + azimuth. The +rim endpoint pair (the Fisher-Z metric carrier) is NOT used by the renderer, so it +is left zero here; the full metric-coupled artifact is helixbake's job once the FMA +columns + a helix build are available. This makes /helix RENDER, faithfully. + +Output is ONE SoA wire (ver 6): the Signed360 normals are a COLUMN of the same +struct-of-arrays as the F16 positions and indices — never a separate sidecar file. +The artifact is published to a GitHub release (not committed); the Dockerfile pulls +it same-origin like body.soa.gz. + +Usage: transcode_helix_signed360.py in.soa.gz out.soa (writes out.soa + out.soa.gz) +""" +import sys, gzip, math +import numpy as np + +GA = math.pi * 0.7639320225002104 # helix_orient golden angle = pi*(3-sqrt5) + +def codebook(half): # helix_orient cap: [r*cos a, r*sin a, y] + n = np.arange(256, dtype=np.float64) + ymin = math.cos(half) + y = 1.0 - (1.0 - ymin) * (n + 0.5) / 256.0 + r = np.sqrt(np.clip(1.0 - y * y, 0.0, None)) + a = n * GA + return np.stack([r * np.cos(a), r * np.sin(a), y], axis=1) # (256,3) + +CAP0, CAP1, CAP2 = codebook(math.pi), codebook(0.40), codebook(0.03) + +def align(a): # a: (N,3) -> axis k (N,3), angle t (N,) + az = np.clip(a[:, 2], -1.0, 1.0) + v0, v1 = a[:, 1], -a[:, 0] + s = np.hypot(v0, v1) + small = s < 1e-9 + inv = np.where(small, 1.0, 1.0 / np.where(small, 1.0, s)) + k = np.stack([np.where(small, 1.0, v0 * inv), np.where(small, 0.0, v1 * inv), np.zeros_like(s)], axis=1) + t = np.where(small, np.where(az > 0, 0.0, math.pi), np.arccos(az)) + return k, t + +def rot(p, k, t): # Rodrigues, vectorized + c, s = np.cos(t)[:, None], np.sin(t)[:, None] + kxp = np.cross(k, p) + kd = np.sum(k * p, axis=1)[:, None] + return p * c + kxp * s + k * (kd * (1.0 - c)) + +def helix_orient_decode(b0, b1, b2): # 3-byte cap-cascade -> unit Cartesian (N,3) + d = CAP2[b2] + k, t = align(CAP1[b1]); d = rot(d, k, -t) + k, t = align(CAP0[b0]); d = rot(d, k, -t) + return d / np.linalg.norm(d, axis=1, keepdims=True).clip(1e-12) + +def main(): + src, out = sys.argv[1], sys.argv[2] + with gzip.open(src, "rb") if src.endswith(".gz") else open(src, "rb") as f: + raw = bytearray(f.read()) + assert raw[:4] == b"BSO2", "bad magic" + ver = int.from_bytes(raw[4:6], "little") + nC = int.from_bytes(raw[6:10], "little") + nV = int.from_bytes(raw[10:14], "little") + nT = int.from_bytes(raw[14:18], "little") + assert ver == 5, f"expected ver 5, got {ver}" + pos_bytes = 6 + helix_off = 18 + nC * (16 + 1 + 1 + 4 + 12 + 8) + pos_bytes * nV + helix = np.frombuffer(bytes(raw[helix_off:helix_off + 6 * nV]), dtype=np.uint8).reshape(nV, 6) + nrm = helix[:, 3:6] # the self-orientation half (pos half = helix[:, 0:3], dropped) + + # decode old normals -> Cartesian (bake world frame) + d = helix_orient_decode(nrm[:, 0].astype(np.intp), nrm[:, 1].astype(np.intp), nrm[:, 2].astype(np.intp)) + nx, ny, nz = d[:, 0], d[:, 1], d[:, 2] + + # encode render-faithful Signed360 (polar partition + golden-frame azimuth). + # decode side (BodyHelix): y from polar partition, az->phi, r=sqrt(1-y^2), + # world (r*sin phi, y, r*cos phi). So phi = atan2(nx, nz), y = ny. + mag = np.rint(np.abs(ny) * 127.0).astype(np.int32).clip(0, 127) + polar = np.where(ny >= 0, 128 + mag, 127 - mag).astype(np.uint8) + phi = np.mod(np.arctan2(nx, nz), 2 * math.pi) + az16 = np.rint(phi / (2 * math.pi) * 65536.0).astype(np.int64) & 0xFFFF + s360 = np.zeros((nV, 6), dtype=np.uint8) # rim bytes [0:3] = 0 (metric, unused) + s360[:, 3] = polar + s360[:, 4] = (az16 & 0xFF).astype(np.uint8) + s360[:, 5] = ((az16 >> 8) & 0xFF).astype(np.uint8) + + # ── verify: decode back exactly as BodyHelix does, vs the decoded Cartesian ── + y = np.where(polar >= 128, (polar.astype(np.float64) - 128) / 127.0, -((127 - polar.astype(np.float64)) / 127.0)) + azf = (az16.astype(np.float64) / 65536.0) * 2 * math.pi + r = np.sqrt(np.clip(1 - y * y, 0, None)) + recon = np.stack([r * np.sin(azf), y, r * np.cos(azf)], axis=1) + dot = np.clip(np.sum(recon * d, axis=1), -1, 1) + err = np.degrees(np.arccos(dot)) + print(f"verts {nV} decode-back err: mean {err.mean():.3f}° p99 {np.percentile(err,99):.3f}° max {err.max():.3f}°") + + # ── reassemble ONE SoA wire: ver 5->6, splice the Signed360 helix column in place, + # everything else (concept cols, F16 pos, row, idx, json) verbatim. The normals stay a + # COLUMN of the same struct-of-arrays as the positions — not a separate file. ── + raw[4:6] = (6).to_bytes(2, "little") # ver 6 = F16 pos + Signed360 helix column + raw[helix_off:helix_off + 6 * nV] = s360.tobytes() + import os + with open(out, "wb") as f: + f.write(raw) + with gzip.open(out + ".gz", "wb", compresslevel=6) as f: + f.write(raw) + print(f"wrote {out} ({len(raw)} B) + {out}.gz ({os.path.getsize(out+'.gz')} B)") + +if __name__ == "__main__": + main()