diff --git a/Dockerfile b/Dockerfile index 5486bf219..7bc46efd2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,16 +55,19 @@ COPY --from=frontend /build/dist/ /build/q2/cockpit/dist/ # fetch the release URL directly (github.com/.../releases/download sends no CORS # header on its redirect → "TypeError: Failed to fetch"), so /body fetches the # same-origin copy. The asset stays in the release (downloaded at build), never git. -RUN curl -fSL https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1/body.soa.gz \ +# 20260629b re-bake: teeth → skeleton + per-vessel diameter boundary (no stray fat +# branches). Pulled under its stamped name, served same-origin AS body.soa.gz so /body +# picks it up; the old body.soa.gz stays in the release untouched. +RUN curl -fSL https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1/body.20260629b.soa.gz \ -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 +RUN curl -fSL https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1/body.20260629b.v6helix.soa.gz \ + -o /build/q2/cockpit/dist/body.20260629b.v6helix.soa.gz \ + && ls -lh /build/q2/cockpit/dist/body.20260629b.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/claude-notes/plans/2026-06-29-cesium-hhtl-splat-viewer.md b/claude-notes/plans/2026-06-29-cesium-hhtl-splat-viewer.md new file mode 100644 index 000000000..326e4c532 --- /dev/null +++ b/claude-notes/plans/2026-06-29-cesium-hhtl-splat-viewer.md @@ -0,0 +1,104 @@ +# /cesium — HHTL-tiled 3D-Gaussian-splat viewer (activation scope) + +> Scoping doc, 2026-06-29. The third viewer alongside `/body` (BodyV3 mesh) +> and `/helix` (Signed360-normal mesh). `/cesium` is the **splat** path: +> 3D-Gaussian splatting, HHTL-tiled, with the **helix arc as the place-relative +> orientation (`quat_wxyz`) carrier**. No code uncommented yet — this is the +> ordered plan; cesium modules stay review-gated (Opus + CodeRabbit) per the +> crate's own rule before any `//`-scaffold goes live. + +## The insight that drives it (corrected 2026-06-29) + +helix `Signed360`'s rim is the **`(start, end)` endpoint pair**, each a Fisher-Z +point on the φ-spiral. Two sphere points = a great-circle **arc** = a **rotation** +(3-DOF, a 4D unit quaternion's worth). The crate frames it exactly so: `start` = +"where the arc begins" (the HHTL place anchor, `CurveRuler::from_hhtl(path,depth)`), +`end` = the residue. So the 2 Fisher-Z values do **3D/4D work** — a rotation +**relative to the HHTL tile frame**. + +That is precisely a 3DGS gaussian's orientation: `GaussianBatch.quat_wxyz`, +consumed by `Spd3::from_scale_quat` → Σ = R·diag(s²)·Rᵀ. So **helix is the splat +orientation carrier** (place-relative), not sidelined by the quaternion +requirement — it satisfies it. The shading normal `/helix` decodes is just one +2-DOF projection of this fuller frame. + +## What already exists (don't rebuild) + +`ndarray/crates/cesium` — optional parity **oracle** (not in `default-members`, +deps commented, modules `//`-scaffold). Module map already covers the spine: +- `tileset` / `implicit_tiling` — OGC 3D Tiles 1.1 implicit tiling: subtree + availability bitstreams + **Morton/Z-order** = the HHTL nibble-interleave + ("Morton in centroid space"). The HHTL↔Cesium tile bridge is *already designed*. +- `khr_gs` / `spz` / `arcgis_pbf` / `esri_crs` — cold-boundary ingest (reverse- + engineer → CAM SoA; never depend on / emit source; no JSON in hotpath). +- `to_cam_soa` — parsed splats → `splat3d::GaussianBatch` + `cam_pq` `[u8;6]`. +- `sse` (screen-space-error LOD) / `hlod` (refine ADD·REPLACE) — LOD machinery. +- `point_fallback` — explicitly "coarse-tier **HHTL** preview" (points before splats). +- `oracle` / `fixtures` — SSIM/PSNR diff vs a reference render; golden gates. + +`ndarray/.../splat3d` — `GaussianBatch` (SoA: mean/scale/quat/opacity + 48 SH), +`covariance_x16` (SIMD Σ), `project`, `ply` reader. The renderer compute. + +## Phases (each gated by its falsification joint) + +### P0 — green skeleton + deps +Uncomment `ndarray = { workspace = true }` in cesium; wire `to_cam_soa` to the +real `GaussianBatch` + `cam_pq` types (drop the `UNVERIFIED` placeholders). +Build stays green; nothing rendered yet. **Respect the review gate** — propose +the uncomment, don't ship it unilaterally. + +### P1 — one real scene → GaussianBatch (cold ingest) +Start with the simplest reference: Inria binary `.ply` via `splat3d::ply` → +`GaussianBatch` (ground-truth gaussians). `khr_gs` (glTF KHR_gaussian_splatting) +second. This gives the oracle something to diff against. + +### P2 — HHTL tiling (implicit_tiling ↔ HHTL) +Quantize each gaussian's `mean_xyz` → Morton/Z-order → HEEL/HIP/TWIG place; +build the LOD tree from `implicit_tiling` subtree availability. `point_fallback` +renders the coarse HHTL tier (points) before splats stream in. +- **J2 (gate):** the HHTL Morton tile address == the `implicit_tiling` subtree- + local index for the same `(level,x,y[,z])`. Same address, both directions. + +### P3 — helix arc → `quat_wxyz` (the insight, made falsifiable) +In `to_cam_soa`, carry each gaussian's orientation as a **place-relative helix +`Signed360`**: `start = CurveRuler::from_hhtl(tile_path, depth)`, `end` = the +residue orientation; decode arc→quaternion (axis = P_start × P_end, angle = arc +length; absolute frame pinned by polar+azimuth). Store the 6-byte Signed360, not +a raw `[f32;4]` quat — the place-relative, HHTL-coupled form. +- **J1 (gate — proves "2-Z does 3D/4D"):** round-trip `quat → Signed360 → quat` + must preserve the **covariance Σ** (`from_scale_quat`) within tol, and the + splat render SSIM/PSNR (vs raw-quat) ≥ threshold. If Σ drifts, the arc↔quat + bijection is wrong and this is a KILL for the place-relative-quat claim + (fall back: store raw quat, keep helix for the normal/metric only). + +### P4 — render path (this is "WebGL ndarray") +Compile `splat3d` (project + depth-sort + `covariance_x16` + SH eval) to wasm32; +`/cesium` cockpit viewer streams HHTL tiles and rasterizes gaussians via +WebGL2/WebGPU. `sse` selects tile LOD per frame. (Contrast `/helix`: a mesh path +that needs *no* ndarray in the browser. `/cesium` is where ndarray→wasm pays off +because 3DGS rasterization is heavy SoA compute.) +- **J3 (gate):** wasm `splat3d` render == native `splat3d` within precision tol + (no drift crossing the wasm boundary). + +### P5 — oracle + parity +`oracle` diffs our render vs an external reference (Inria/Cesium); `fixtures` +holds golden scenes + thresholds. Keeps "parity / better" honest by measurement, +which is the crate's whole charter. + +## Cross-repo & rules +- **cesium crate** lives in `ndarray` (activate there, review-gated; relocates to + `lance-graph/crates/cesium` once flawless). RULE 3: no serde/JSON in hotpath, + ArcGIS **PBF** over `f=json`. Reverse-engineer only — never depend on / emit a + source format; output is always CAM SoA. +- **helix** in lance-graph: the arc→quat decode mirrors `/helix`'s rim→normal + decode. Ideal convergence — ship the helix codec as **one wasm** used by both + `/helix` (normal) and `/cesium` (quat), killing TS reimplementation drift. +- **/cesium viewer** in `q2/cockpit` (like `/helix`). + +## First concrete step (smallest provable slice) +Not the whole pipeline — **J1 only, native, no viewer**: a Rust probe that takes +a real `.ply` GaussianBatch, encodes each quat as a place-relative `Signed360`, +decodes back, and reports Σ-error + per-gaussian rotation error (deg). Green J1 = +"the 2 Fisher-Z carry the 3D/4D orientation" is proven on real data before any +tiling, wasm, or cockpit work. Same shape as the `helixbake` round-trip that +proved the normal path (mean 0.26°). If J1 fails, we learn it cheaply. diff --git a/cockpit/public/body.manifest.json b/cockpit/public/body.manifest.json index 76bc3c55d..6d0d11398 100644 --- a/cockpit/public/body.manifest.json +++ b/cockpit/public/body.manifest.json @@ -1,5 +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.", + "helix_latest": "body.20260629b.v6helix.soa.gz", + "note": "20260629b re-bake from soa_v2: teeth reclassified into the skeleton (layer 4) + per-vessel slicer-fill diameter boundary (no stray fat branches at bends). BSO2 ver 6 = F16 positions + Signed360 NORMAL column + HXFL floor trailer; helixbake (real lance-graph::helix::encode_signed). Decode: rim r=sinθ -> int8 normal at load, Gouraud per-vertex shading. Published to fma-body-soa-v3-v1; Dockerfile pulls same-origin.", "verts": 4283525 } diff --git a/cockpit/src/BodyHelix.tsx b/cockpit/src/BodyHelix.tsx index bc5ac27a7..6bdaf01aa 100644 --- a/cockpit/src/BodyHelix.tsx +++ b/cockpit/src/BodyHelix.tsx @@ -91,9 +91,10 @@ function buildRLut(flo: number, fhi: number): Float32Array { interface Decoded { nVerts: number; nTris: number; positions: Float32Array; index: Uint32Array; - colors: Uint8Array; normals: Int8Array; layer: Float32Array; - concepts: number; + colors: Uint8Array; normals: Int8Array; layer: Float32Array; vrow: Uint32Array; + concepts: number; conceptList: ConceptMeta[]; } +interface ConceptMeta { row: number; name: string; layer: number; cx: number; cy: number; cz: number; } function decode(buf: ArrayBuffer): Decoded { const dv = new DataView(buf); @@ -106,8 +107,8 @@ function decode(buf: ArrayBuffer): Decoded { 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 + const labelOff = o; o += 4 * nC; // label idx (u32 → name in labels_json) + const cenOff = o; o += 12 * nC; // centroid 3f o += 8 * nC; // vrange const posOff = o; o += posBytes * nV; const helixOff = o; o += 6 * nV; // pos3 | nrm3 — we read the nrm half @@ -169,21 +170,66 @@ function decode(buf: ArrayBuffer): Decoded { normals[i * 3 + 2] = Math.max(-127, Math.min(127, Math.round(yw * 127))); layer[i] = li; } + // ── per-concept "maximum diameter" clamp (parity with /body's vessel sizing) ── + // Decimation can orphan a few triangles far outside a concept's real extent — the + // classic tell is an aorta splat that lands under the soles. /body hides such bulk + // behind its translucent vessel pass; /helix draws everything opaque, so it shows. + // Robust per concept: component-median centre + p95 radius × margin; any triangle + // touching an out-of-bounds vertex is dropped. Adapts to each concept's true size. + const byC: number[][] = Array.from({ length: nC }, () => []); + for (let i = 0; i < nV; i++) { const c = rowArr[i]; if (c < nC) byC[c].push(i); } + const median = (a: number[]) => { const s = a.slice().sort((x, y) => x - y); return s[s.length >> 1]; }; + const outlier = new Uint8Array(nV); + let nOut = 0, worst = 0; + for (let c = 0; c < nC; c++) { + const vs = byC[c]; + if (vs.length < 8) continue; + const cx = median(vs.map((i) => positions[i * 3])); + const cy = median(vs.map((i) => positions[i * 3 + 1])); + const cz = median(vs.map((i) => positions[i * 3 + 2])); + const dist = vs.map((i) => Math.hypot(positions[i * 3] - cx, positions[i * 3 + 1] - cy, positions[i * 3 + 2] - cz)); + const ds = dist.slice().sort((a, b) => a - b); + const p95 = ds[Math.min(ds.length - 1, Math.floor(ds.length * 0.95))]; + const thr = Math.max(p95 * 1.8, 1e-3); // generous margin → only true far strays drop + for (let k = 0; k < vs.length; k++) { + if (dist[k] > thr) { outlier[vs[k]] = 1; nOut++; worst = Math.max(worst, dist[k] / Math.max(p95, 1e-4)); } + } + } + // index: drop triangles touching an out-of-bounds vertex 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 kept = new Uint32Array(raw.length); + let w = 0; + for (let t = 0; t < nT; t++) { + const a = raw[t * 3], b = raw[t * 3 + 1], cc = raw[t * 3 + 2]; + if (outlier[a] || outlier[b] || outlier[cc]) continue; + kept[w++] = a; kept[w++] = b; kept[w++] = cc; + } + const index = kept.slice(0, w); + if (nOut) console.log(`/helix max-diameter clamp: ${nOut} stray verts (worst ${worst.toFixed(1)}× p95), dropped ${(nT - w / 3).toLocaleString()} tris`); + + // per-concept metadata for the browser: name (label→labels_json), layer, display centroid. + let to = idxOff + 12 * nT; + const labLen = dv.getUint32(to, true); to += 4; + let names: string[] = []; + try { const lj = JSON.parse(new TextDecoder().decode(new Uint8Array(buf.slice(to, to + labLen)))); names = lj.names ?? lj; } catch { /* names optional */ } + const labelIdx = new Uint32Array(buf.slice(labelOff, labelOff + 4 * nC)); + const cen = new Float32Array(buf.slice(cenOff, cenOff + 12 * nC)); + const conceptList: ConceptMeta[] = []; + for (let c = 0; c < nC; c++) { + conceptList.push({ row: c, name: names[labelIdx[c]] ?? `concept ${c}`, layer: cLayer[c] || 8, + cx: -cen[c * 3], cy: cen[c * 3 + 2], cz: cen[c * 3 + 1] }); // source → display (-x,z,y) + } + return { nVerts: nV, nTris: w / 3, positions, index, colors, normals, layer, vrow: rowArr, concepts: nC, conceptList }; } const VERT = ` precision highp float; -attribute vec3 aColor; attribute vec3 aNormal; attribute float aLayer; -varying vec3 vColor; varying float vLayer; +attribute vec3 aColor; attribute vec3 aNormal; +varying vec3 vColor; 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. + // fragment shader trivial. Two-sided ambient keeps any back faces lit without a 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); @@ -193,15 +239,13 @@ void main(){ }`; 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. -}`; +uniform float uAlpha; // 1 = solid · <1 = x-ray (whole-body translucent) +varying vec3 vColor; +void main(){ gl_FragColor = vec4(vColor, uAlpha); }`; // visible layers are pre-filtered into the +// draw range (NOT a discard) → early-Z survives; the GPU never touches hidden triangles. +type Focus = { x: number; y: number; z: number; d: number }; -function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dirty: { current: boolean }): () => void { +function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dirty: { current: boolean }, focus: { current: Focus | null }, xray: { current: boolean }, lod: { 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); @@ -213,17 +257,42 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir 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 }); + // Draw ONLY enabled layers, as GEOMETRY (rebuild the index on toggle) — never a + // fragment discard. A discard still rasterises every triangle, then throws the pixels + // away (kills early-Z); excluding them from the index means the GPU never touches them. + // Default skin+muscle-off removes the body's largest surfaces — the real lever against + // "won't rotate", with backface culling (FrontSide) halving the rest. + const fullIdx = d.index; + const nTriAll = fullIdx.length / 3; + const triLayer = new Uint8Array(nTriAll); + const triConcept = new Uint32Array(nTriAll); // concept (row) of each triangle → server-LOD gate + for (let t = 0; t < nTriAll; t++) { triLayer[t] = d.layer[fullIdx[t * 3]]; triConcept[t] = d.vrow[fullIdx[t * 3]]; } + // server-LOD action per concept: 255 = show (the default until the cascade answers), 0 = the + // HHTL depth-cascade rejected this concept as off-frustum. Folded into the index rebuild below. + const lodAction = new Uint8Array(d.concepts).fill(255); + const active = new Uint32Array(fullIdx.length); + const rebuild = (): number => { + let n = 0; + for (let t = 0; t < nTriAll; t++) { + if (enabled[triLayer[t]] >= 0.5 && lodAction[triConcept[t]] !== 0) { const o = t * 3; active[n++] = fullIdx[o]; active[n++] = fullIdx[o + 1]; active[n++] = fullIdx[o + 2]; } + } + return n; + }; + const idxAttr = new THREE.BufferAttribute(active, 1); + idxAttr.setUsage(THREE.DynamicDrawUsage); + geom.setIndex(idxAttr); + const applyIndex = () => { geom.setDrawRange(0, rebuild()); idxAttr.needsUpdate = true; }; + geom.setDrawRange(0, rebuild()); + + const uniforms = { uAlpha: { value: 1 } }; + const mat = new THREE.ShaderMaterial({ uniforms, vertexShader: VERT, fragmentShader: FRAG, side: THREE.FrontSide }); 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 onDown = (e: PointerEvent) => { dragging = true; px = e.clientX; py = e.clientY; focus.current = null; dirty.current = true; }; const onUp = () => { dragging = false; dirty.current = true; }; const onMove = (e: PointerEvent) => { if (!dragging) return; @@ -235,7 +304,37 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir 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(); + // server HHTL LOD (opt-in): post the live camera to /api/body/lod; the depth-cascade returns + // a per-concept action (0 = off-frustum reject). We fold the cull into the SAME geometry index + // rebuild as the layer toggles — NOT a fragment discard — so early-Z survives and the GPU draws + // strictly fewer triangles when zoomed in (the mobile lever, working WITH the database). Absent + // endpoint (old deploy) → silently keep the full render. This is the living DB reasoning the view. + let lodNext = 0, lodInflight = false, lodFail = false, lodDirty = false, lodWasOn = false; + const postLod = (now: number) => { + if (lodFail || lodInflight || now < lodNext) return; + lodInflight = true; lodNext = now + 220; + camera.updateMatrixWorld(); + const e = camera.matrixWorldInverse.elements; // column-major → row-major view rows + const view = [ + [e[0], e[4], e[8], e[12]], [e[1], e[5], e[9], e[13]], + [e[2], e[6], e[10], e[14]], [e[3], e[7], e[11], e[15]], + ]; + const fy = (h / 2) / Math.tan((camera.fov * Math.PI) / 360); + const body = { view, fx: fy, fy, cx: w / 2, cy: h / 2, near: camera.near, far: camera.far, width: w, height: h, position: [camera.position.x, camera.position.y, camera.position.z] }; + fetch('/api/body/lod', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) + .then((j: { actions: number[]; n_concepts?: number; tally?: number[] }) => { + const a = j.actions; + const visible = (j.n_concepts ?? a.length) - (j.tally?.[0] ?? 0); + const degenerate = visible <= Math.max(1, a.length * 0.02); // cascade culled ~all ⇒ camera map suspect → show all + for (let i = 0; i < d.concepts && i < a.length; i++) lodAction[i] = degenerate ? 255 : a[i]; + lodDirty = true; dirty.current = true; + }) + .catch(() => { lodFail = true; }) // endpoint absent (old deploy) → keep full render + .finally(() => { lodInflight = false; }); + }; + + let raf = 0, ema = 16.6, last = performance.now(), sig = enabled.join(','); 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; @@ -243,6 +342,11 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir window.addEventListener('resize', onResize); const tick = () => { raf = requestAnimationFrame(tick); + // server-LOD lifecycle runs even on idle frames (the cascade tracks the static view too); + // turning LOD off restores the full geometry. Both are cheap and bounded by the 220 ms poll. + const tnow = performance.now(); + if (lod.current) { postLod(tnow); lodWasOn = true; } + else if (lodWasOn) { lodWasOn = false; lodFail = false; lodAction.fill(255); lodDirty = true; dirty.current = true; } // 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; } @@ -251,7 +355,22 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir // 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; + // layer toggled OR server-LOD answered → rebuild the active index (geometry exclusion, not + // discard): one linear pass folds both the layer mask and the per-concept LOD action. + const ns = enabled.join(','); + if (ns !== sig || lodDirty) { sig = ns; lodDirty = false; applyIndex(); } + // x-ray: whole-body translucency (depthWrite off; cheap, unsorted-blend is fine here) + const wantX = xray.current; + if (mat.transparent !== wantX) { mat.transparent = wantX; mat.depthWrite = !wantX; mat.needsUpdate = true; } + uniforms.uAlpha.value = wantX ? 0.4 : 1.0; + // browser pick → glide the orbit target + dolly onto the chosen concept + if (focus.current) { + const f = focus.current; + target.lerp(new THREE.Vector3(f.x, f.y, f.z), 0.12); + dist += (f.d - dist) * 0.12; + if (Math.abs(dist - f.d) < 0.02) focus.current = null; + dirty.current = true; + } 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); @@ -298,8 +417,15 @@ export default function BodyHelix() { 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 [xray, setXray] = useState(false); + const [lod, setLod] = useState(false); // server HHTL LOD — opt-in (off = full render) + const [query, setQuery] = useState(''); + const [open, setOpen] = useState>({ 4: true }); // expanded layer groups 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) + const focusRef = useRef(null); + const xrayRef = useRef(false); + const lodRef = useRef(false); useEffect(() => { let cancelled = false; @@ -312,26 +438,40 @@ export default function BodyHelix() { 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]); + useEffect(() => { xrayRef.current = xray; dirtyRef.current = true; }, [xray]); + useEffect(() => { lodRef.current = lod; dirtyRef.current = true; }, [lod]); + useEffect(() => { const c = ref.current; if (!c || !d) return; return mount(c, d, enabledRef.current, dirtyRef, focusRef, xrayRef, lodRef); }, [d]); + + const focusOn = (c: ConceptMeta) => { + focusRef.current = { x: c.cx, y: c.cy, z: c.cz, d: 0.6 }; + if (!enabledRef.current[c.layer]) setOn((p) => ({ ...p, [c.layer]: true })); // reveal its layer + dirtyRef.current = true; + }; 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', }); + const q = query.trim().toLowerCase(); + const groups = LAYERS.map((l) => ({ + l, items: d ? d.conceptList.filter((c) => c.layer === l.id && (!q || c.name.toLowerCase().includes(q))) : [], + })).filter((g) => g.items.length > 0 || !q); return (
-
-
/helix — surfel-normal viewer (experimental)
-
+
+
/helix — living anatomy browser
+
{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 ? `${d.nVerts.toLocaleString()} verts · ${d.concepts.toLocaleString()} structures · helix::Signed360 normals (Fisher-Z rim)` + : 'loading canonical helix bake…'}
{d && ( -
+
+ + {LAYERS.map((l) => (
)} + {d && ( +
+ setQuery(e.target.value)} placeholder={`search ${d.concepts.toLocaleString()} structures…`} + style={{ margin: 10, padding: '8px 10px', borderRadius: 7, border: '1px solid #243244', background: '#0e1219', color: '#cdd9e5', font: '13px ui-monospace, monospace', outline: 'none' }} /> +
+ {groups.map(({ l, items }) => { + const expanded = !!open[l.id] || !!q; + return ( +
+
setOpen((p) => ({ ...p, [l.id]: !expanded }))} + style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '7px 8px', cursor: 'pointer', color: '#cdd9e5', font: '12px ui-monospace, monospace', userSelect: 'none' }}> + {expanded ? '▾' : '▸'} + + {l.name} + {items.length} +
+ {expanded && items.slice(0, 500).map((c) => ( +
focusOn(c)} title={c.name} + style={{ padding: '4px 8px 4px 30px', cursor: 'pointer', color: '#9fb0c2', font: '12px ui-monospace, monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', borderRadius: 5 }} + onMouseEnter={(e) => { e.currentTarget.style.background = '#152030'; e.currentTarget.style.color = '#dce6f0'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#9fb0c2'; }}> + {c.name} +
+ ))} +
+ ); + })} +
+
+ )}
); } diff --git a/crates/osint-bake/tools/bake_torso_splat.py b/crates/osint-bake/tools/bake_torso_splat.py index 9477c52cc..8956f86be 100644 --- a/crates/osint-bake/tools/bake_torso_splat.py +++ b/crates/osint-bake/tools/bake_torso_splat.py @@ -85,7 +85,11 @@ ("muscle", ["muscle", "pectoralis", "oblique", "transversus", "diaphragm", "sphincter", "psoas", "piriformis", "obturator"]), ("bone", ["vertebra", "rib", "sternum", "clavicle", "scapula", "ilium", - "ischium", "pubis", "sacrum", "manubrium", "xiphoid"]), + "ischium", "pubis", "sacrum", "manubrium", "xiphoid", + # teeth are not is_a bone in FMA (dentin/enamel, not skeletal element), so + # they fell through to "flesh" → layer 1 (skin), hidden in the skeleton view + # (toothless skull). Bucket them with the skeleton; gingiva stays flesh. + "tooth", "molar", "incisor", "canine", "premolar", "cuspid"]), ("artery", ["aorta", "artery", "arterial", "trunk"]), ("vein", ["vein", "vena", "venous"]), ("nerve", ["nerve", "plexus", "ganglion"]), diff --git a/crates/osint-bake/tools/fill_body_soa.py b/crates/osint-bake/tools/fill_body_soa.py index 31ca44dcd..5b8e6974b 100644 --- a/crates/osint-bake/tools/fill_body_soa.py +++ b/crates/osint-bake/tools/fill_body_soa.py @@ -35,6 +35,12 @@ RMAX = 0.020 # ABSOLUTE diameter boundary: max cross-section radius in normalized # [-1,1] body units (~34 mm dia — covers the aorta; clamps balloons). RMIN = 0.0008 # floor so capillaries still get a visible core +CAP = 2.0 # PER-VESSEL diameter boundary: a ring may not exceed this vessel's own + # caliber × CAP. RMAX alone lets a finger artery balloon to aorta size at + # a bend; this keeps a capillary a capillary through its bends. +PCTL = 0.30 # per-bin radius percentile (NOT the median): at a strong bend two arms + # share one axial bin and the median perp-distance is ~half the gap (a + # balloon); the low percentile picks the near wall = the true radius. CELL = 0.015 # connected-component grid cell (~13 mm). A continuous vessel keeps # adjacent cells occupied (26-neighbour reach ~26 mm bridges sampling # gaps); blobs farther apart (hands ~300 mm, thigh→toe >100 mm) split. @@ -112,7 +118,9 @@ def fill_one(pts, crow, fpx, fnx, frow, ftri, base): for p, t in zip(pts, ts): b = min(BINS-1, int((t - tmin)/span*BINS)) binned[b].append(p) - rings = [] + # pass 1: per-bin centroid + a LOW-percentile perpendicular radius (resists the bend + # failure mode where two arms share one axial bin and the median balloons). + raw = [] for bp in binned: if len(bp) < 1: continue @@ -125,10 +133,17 @@ def fill_one(pts, crow, fpx, fnx, frow, ftri, base): perp2 = (dx*dx + dy*dy + dz*dz) - axial*axial dists.append(math.sqrt(perp2) if perp2 > 0.0 else 0.0) dists.sort() - rad = min(RMAX, max(RMIN, dists[len(dists)//2] * CORE)) - rings.append((cen, rad)) - if len(rings) < 2: + raw.append((cen, dists[int(len(dists)*PCTL)] * CORE)) + if len(raw) < 2: return 0 + # pass 2: PER-VESSEL diameter boundary. Clamp every ring to this vessel's OWN caliber + # (robust median of the bin radii) × CAP, then the absolute RMAX. A capillary stays a + # capillary through its bends; the aorta still reaches RMAX. This is what stops the + # "stray fat children" — a ballooned bend bin can no longer exceed the vessel's caliber. + rr = sorted(r for (_, r) in raw if r > RMIN) + caliber = rr[len(rr)//2] if rr else RMIN + cap = min(RMAX, caliber * CAP) + rings = [(cen, min(cap, max(RMIN, r))) for (cen, r) in raw] ring_start = [] for (cen, rad) in rings: ring_start.append(base + len(fpx)//3)