From 7b52d7877d06fdca2fb826e341d49a67b9936a97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 04:42:58 +0000 Subject: [PATCH 1/8] feat(cockpit): add /helix2, the v4-bake sibling of /helix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BodyHelix2.tsx is a verbatim #64-style fork of BodyHelix.tsx differing in one line of behaviour: it resolves `helix_v4_latest` from /body.manifest.json where /helix resolves `helix_latest`. Same loader, same BSO2 ver-6 decoder, same Signed360 shading, so a v4 bake is compared against v3 with the renderer held constant and any visible difference is the bake rather than the viewer. The CANONICAL-ONLY no-fallback rule is inherited deliberately: falling back to the v3 artifact would make /helix2 a copy of /helix that looks like a successful v4 render. Until a v4 bake is published it reports the missing manifest key. Nothing existing is touched — `helix_latest` keeps serving /helix, and the release asset medcare-rs SHA256-pins stays as published. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- cockpit/src/BodyHelix2.tsx | 881 +++++++++++++++++++++++++++++++++++++ cockpit/src/main.tsx | 7 + 2 files changed, 888 insertions(+) create mode 100644 cockpit/src/BodyHelix2.tsx diff --git a/cockpit/src/BodyHelix2.tsx b/cockpit/src/BodyHelix2.tsx new file mode 100644 index 000000000..e0bb3e869 --- /dev/null +++ b/cockpit/src/BodyHelix2.tsx @@ -0,0 +1,881 @@ +// BodyHelix2 — the V4-BAKE fork of BodyHelix, serving /helix2. A verbatim fork +// (like GeoHelix was forked from BodyHelix, and BodyHelix from BodyV3, #64) so +// FMA v4 bake work — re-addressed cascade, laterality on the edge, vessel radii — +// can NEVER regress the working /helix anatomy body. +// +// It differs from BodyHelix in exactly ONE line of behaviour: the manifest key. +// /helix reads `helix_latest`; /helix2 reads `helix_v4_latest`. Both resolve +// through the SAME loader, the SAME BSO2 ver-6 decoder and the SAME Signed360 +// shading, so a v4 bake is compared against v3 with the renderer held constant — +// any visible difference is the BAKE, never the viewer. +// +// Until a v4 bake exists and `helix_v4_latest` is set in /body.manifest.json, +// /helix2 says so verbatim (the CANONICAL-ONLY no-fallback rule below is +// deliberately inherited: silently falling back to the v3 artifact would make +// /helix2 a copy of /helix that LOOKS like a successful v4 render). +// +// Nothing here writes: `helix_latest` is untouched, so /helix keeps working and +// the release asset medcare-rs SHA256-pins stays exactly as published. +// +// (Original BodyHelix header retained below for the shared decode/shading detail.) +// /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'; + +// The scene name implied by the PATH alone (no `?scene=` override). This is the SINGLE +// source of truth for path→scene mapping — fetchSoa() (which bake to load) and isGeoScene +// (whether to disable server LOD + light the geo beautification) BOTH read it, so they can +// never disagree. `?scene=` still overrides in the callers. /geo → the OSM bake, +// /ice → the Iceland DEM bake; any other path → null (the anatomy body, helix_latest). +function pathScene(): string | null { + const p = window.location.pathname; + if (p === '/geo') return 'osm'; + if (p === '/ice') return 'iceland'; + // Mod-rewrite style: /garmin/ → scene "garmin:". The slug is + // resolved SERVER-side (/api/garmin/:location → manifest garmin_scenes → dist file), + // so a new scene is a manifest entry + a bake — no client change. + const m = p.match(/^\/garmin\/([a-z0-9-]+)$/); + if (m) return `garmin:${m[1]}`; + return null; +} + +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; vrow: Uint32Array; + concepts: number; conceptList: ConceptMeta[]; +} +interface ConceptMeta { row: number; name: string; layer: number; cx: number; cy: number; cz: number; } + +// ── ver-8 radix-grid decode ───────────────────────────────────────────────── +// The wire stores ONLY height (F16) + kind (u8 → header palette). Everything +// else is DETERMINISTIC from the address and reconstructed here: position by +// radix (i → (row, col) → x0 + col·dx, zrow[row]), the triangle index by the +// grid loop, normals by one gradient pass (true-scale, so display slope = +// real-world slope), and colour by palette[kind] × the CurveRuler golden-spiral +// residue (stride-4-over-17, bit-exact 64-bit integer — "phase is convention, +// not data"). 710 MB (ver-7) → ~50 MB raw for the same 16.5 M-vert Iceland. +const MIX_X = 0x9E3779B97F4A7C15n, MIX_Y = 0xC2B2AE3D27D4EB4Fn, MIX_Z = 0x165667B19E3779F9n; +const U64 = (v: bigint) => BigInt.asUintN(64, v); +/// mix(cell) % 17 — the only reading of the mix the ruler needs (start / k). +function mix17(cx: number, cy: number, cz: number): number { + const m = U64( + U64(BigInt.asUintN(64, BigInt(cx)) * MIX_X) + ^ U64(BigInt.asUintN(64, BigInt(cy)) * MIX_Y) + ^ U64(BigInt.asUintN(64, BigInt(cz)) * MIX_Z)); + return Number(m % 17n); +} +function decodeGrid(buf: ArrayBuffer): Decoded { + const dv = new DataView(buf); + const nC = dv.getUint32(6, true), W = dv.getUint32(10, true), H = dv.getUint32(14, true); + const nV = W * H, nT = (W - 1) * (H - 1) * 2; + let o = 18; + o += 16 * nC; // guid + o += nC; // material (unused) + const layerOff = o; o += nC; // LAYER u8 + const labelOff = o; o += 4 * nC; // label idx + const cenOff = o; o += 12 * nC; // centroid 3f (pre-remapped like ver-6/7) + o += 8 * nC; // vrange + const x0 = dv.getFloat32(o, true), dx = dv.getFloat32(o + 4, true), yscale = dv.getFloat32(o + 8, true); + o += 12; + const zrow = new Float32Array(buf.slice(o, o + 4 * H)); o += 4 * H; + const nK = dv.getUint8(o); o += 1; + const pal = new Uint8Array(buf.slice(o, o + 3 * nK)); o += 3 * nK; + const hf = new Uint16Array(buf.slice(o, o + 2 * nV)); o += 2 * nV; + const kinds = new Uint8Array(buf.slice(o, o + nV)); o += nV; + const labLen = dv.getUint32(o, true); o += 4; + let names: string[] = []; + try { const lj = JSON.parse(new TextDecoder().decode(new Uint8Array(buf.slice(o, o + labLen)))); names = lj.names ?? lj; } catch { /* names optional */ } + const cLayer = new Uint8Array(buf.slice(layerOff, layerOff + nC)); + + // positions: radix x, tabulated z, decoded F16 height. Display frame DIRECT — + // ver-8 synthesizes display coords; no (-x, z, y) source remap round-trip. + const heights = new Float32Array(nV); + for (let i = 0; i < nV; i++) heights[i] = HALF_LUT[hf[i]] * yscale; + const positions = new Float32Array(nV * 3); + const rowArr = new Uint32Array(nV); + const layer = new Float32Array(nV); + for (let r = 0, i = 0; r < H; r++) { + const z = zrow[r], cr = Math.min(r, nC - 1), li = cLayer[cr] || 8; + for (let c = 0; c < W; c++, i++) { + positions[i * 3] = x0 + c * dx; + positions[i * 3 + 1] = heights[i]; + positions[i * 3 + 2] = z; + rowArr[i] = cr; + layer[i] = li; + } + } + // normals: one central-difference gradient pass (one-sided at edges). True-scale + // heights → true-world slopes, byte-parity with the ver-7 baker's terrain_normals. + const normals = new Int8Array(nV * 3); + for (let r = 0; r < H; r++) { + const rm = Math.max(r - 1, 0), rp = Math.min(r + 1, H - 1); + const dzr = (zrow[rp] - zrow[rm]) || 1e-6; + for (let c = 0; c < W; c++) { + const i = r * W + c; + const cm = Math.max(c - 1, 0), cp = Math.min(c + 1, W - 1); + const gx = (heights[r * W + cp] - heights[r * W + cm]) / (((cp - cm) * dx) || 1e-6); + const gz = (heights[rp * W + c] - heights[rm * W + c]) / dzr; + const il = 127 / Math.hypot(gx, 1, gz); + normals[i * 3] = Math.round(-gx * il); + normals[i * 3 + 1] = Math.round(il); + normals[i * 3 + 2] = Math.round(-gz * il); + } + } + // colour: palette[kind] × the deterministic CurveRuler residue (±18% within-kind + // texture — the /helix surfel look). Lattice mixes cached per cell (≈ tens of + // thousands of distinct cells for 16.5 M verts → the BigInt cost is amortized). + const colors = new Uint8Array(nV * 3); + const DETAIL = 48; + const cache = new Map(); + const cell17 = (px: number, py: number, pz: number, d: number): number => { + const cx = Math.floor(px * d), cy = Math.floor(py * d), cz = Math.floor(pz * d); + const key = (cx + 512) + (cy + 512) * 1024 + (cz + 512) * 1048576; + let v = cache.get(key); + if (v === undefined) { v = mix17(cx, cy, cz); cache.set(key, v); } + return v; + }; + for (let i = 0; i < nV; i++) { + const px = positions[i * 3], py = positions[i * 3 + 1], pz = positions[i * 3 + 2]; + const start = cell17(px, py, pz, DETAIL); + const k = cell17(px, py, pz, DETAIL * 3); + const phase = (((start + 4 * k) % 17) / 16) * 2 - 1; + const sweet = 0.90 + 0.18 * phase; + const kb = kinds[i] * 3; + colors[i * 3] = Math.max(0, Math.min(255, Math.round(pal[kb] * sweet))); + colors[i * 3 + 1] = Math.max(0, Math.min(255, Math.round(pal[kb + 1] * sweet))); + colors[i * 3 + 2] = Math.max(0, Math.min(255, Math.round(pal[kb + 2] * sweet))); + } + // index: the grid loop — connectedness IS the address structure (baker winding). + const index = new Uint32Array(nT * 3); + let wI = 0; + for (let r = 0; r < H - 1; r++) { + for (let c = 0; c < W - 1; c++) { + const a = r * W + c, b = a + 1, d2 = a + W, e = d2 + 1; + index[wI++] = a; index[wI++] = b; index[wI++] = d2; + index[wI++] = b; index[wI++] = e; index[wI++] = d2; + } + } + 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: nT, positions, index, colors, normals, layer, vrow: rowArr, concepts: nC, conceptList }; +} + +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); + if (ver === 8) return decodeGrid(buf); // radix-grid wire: height + kind only + 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 + 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 + const rowOff = o; o += 4 * nV; + const colorOff = ver >= 7 ? o : -1; // ver-7: per-vertex RGB drape (real kind/imagery colour) + if (ver >= 7) o += 3 * 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); + // ver-7 carries a real per-vertex colour drape (the DEM baker's kind × imagery × helix texture); + // older bakes synthesize the colour from the concept layer. When present, the wire colour wins. + const wireColors = ver >= 7 ? new Uint8Array(buf.slice(colorOff, colorOff + nV * 3)) : null; + 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 = wireColors + ? ([wireColors[i * 3], wireColors[i * 3 + 1], wireColors[i * 3 + 2]] as [number, number, number]) + : 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; + } + // ── 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 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; +uniform float uGeo; // 1 = geo scene → height-profile terrain palette · 0 = anatomy → aColor (byte-identical) +uniform float uYMin; // decoded height range (display.y), measured once at load from the position buffer +uniform float uYMax; +uniform float uExag; // geo relief exaggeration: the Iceland bake is true-scale (span ~0.0074 in the + // [-1,1] frame), so raise the geometry to read as terrain. 1 for anatomy (untouched). +uniform float uTime; // retained-but-0: the Kurvenlineal residue is baked into the mesh, not animated. +uniform float uRuler; // retained-but-0: no shader ruler (the golden-spiral residue is baked in). +varying vec3 vColor; +// THE KURVENLINEAL is now baked into the mesh, not approximated here. The real +// helix::CurveRuler golden-spiral residue (stride-4-over-17) is applied at BAKE time in +// geo/src/bin/iceland_dem.rs (::ruler_phase) as per-vertex surface displacement + recomputed +// normals — so the residue is carried by the decoded position + Signed360 normal the same way +// the anatomy body carries it. The vertex shader below therefore only lifts the terrain by uExag +// and Gouraud-shades; the earlier GLSL float-hash approximation of the ruler has been removed. +// Height-profile terrain palette for the geo bakes (Iceland DEM, OSM). Elevation is display.y. +// In the Iceland bake it is TRUE-SCALE (not vertically exaggerated) → a tiny span (~[0, 0.0074]) +// with ~39% ocean at EXACTLY 0 and a heavily-quantized lowland plateau holding ~58% of verts, +// then a thin highland tail to the peaks/glaciers. Hence we normalize by the ACTUAL measured +// [uYMin,uYMax] (never the [-1,1] convention — that would flatten everything to water) and apply +// a sqrt curve to spread the skewed tail. Colour is driven by HEIGHT ONLY: the decoded Signed360 +// normal is BIMODAL for the geo bake (77% |n.y|≈0 / 23% ≈1), so it is NOT a usable continuous +// slope — classifying rock/scree/volcano from it would give a binary patchwork. The shade +// lighting term below still uses the normal for form. HONEST NOTE: true lava-field / glacier / +// volcano feature classification needs a DEM re-bake carrying feature layers; the warm "volcano" +// band here is a height-derived accent approximation, deliberately subtle. +vec3 terrainColor(float h){ + float hl = clamp((h - uYMin) / max(uYMax - uYMin, 1e-6), 0.0, 1.0); // linear normalized height + float hs = sqrt(hl); // spread the quantized tail + vec3 ocean = vec3(0.05, 0.15, 0.30); + vec3 coast = vec3(0.28, 0.42, 0.24); // lowest green coastal fringe + vec3 moss = vec3(0.34, 0.36, 0.22); // khaki moss/tundra — the dominant quantized lowland + vec3 rock = vec3(0.44, 0.38, 0.30); // brown highland rock + vec3 scree = vec3(0.56, 0.55, 0.55); // grey scree + vec3 ice = vec3(0.90, 0.93, 0.98); // snow / glacier cap + vec3 land = coast; + land = mix(land, moss, smoothstep(0.18, 0.30, hs)); + land = mix(land, rock, smoothstep(0.34, 0.48, hs)); + land = mix(land, scree, smoothstep(0.50, 0.64, hs)); + land = mix(land, ice, smoothstep(0.66, 0.82, hs)); + vec3 lava = vec3(0.42, 0.16, 0.10); // warm volcanic-highland accent (approximation — see note) + float volc = smoothstep(0.42, 0.52, hs) * (1.0 - smoothstep(0.60, 0.72, hs)); + land = mix(land, lava, volc * 0.28); + float water = 1.0 - smoothstep(0.002, 0.010, hl); // ocean is EXACTLY 0; first land ≈ 0.0155 normalized + return mix(land, ocean, water); +} +void main(){ + // 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. 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); + 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); + // Colour is ALWAYS aColor now: for the DEM terrain aColor is the baked kind × imagery × helix + // texture (real green / ice / volcano / rock), for buildings + anatomy it is the concept tint — + // the shader no longer guesses colour from height. Gouraud shade applies the 3D lighting form. + vColor = aColor * shade; + // Geo relief: raise the true-scale island by uExag. The Kurvenlineal golden-spiral residue is + // already in position + normal (baked by the DEM baker), so the shader adds no ruler of its own. + // Anatomy (uGeo==0): untouched. + vec3 dpos = position; + if (uGeo > 0.5) { + dpos.y = position.y * uExag; + } + gl_Position = projectionMatrix * modelViewMatrix * vec4(dpos, 1.0); +}`; +const FRAG = ` +precision mediump float; +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. + +// ── procedural sky dome (geo scenes only) ────────────────────────────────────────────── +// A large BackSide sphere with a vertical gradient (pale horizon → blue zenith), rendered +// first (renderOrder −1, depthWrite off) so terrain always draws over it. No external asset +// (CSP blocks CDNs) — the gradient is computed in the fragment shader from the view direction. +// Added to the scene ONLY for geo scenes; anatomy scenes keep the flat PAGE_BG background. +const SKY_HORIZON = new THREE.Color(0.74, 0.80, 0.85); +const SKY_ZENITH = new THREE.Color(0.24, 0.42, 0.64); +const SKY_VERT = ` +precision highp float; +varying vec3 vDir; +void main(){ vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`; +const SKY_FRAG = ` +precision mediump float; +varying vec3 vDir; +uniform vec3 uHorizon; uniform vec3 uZenith; +void main(){ + float t = clamp(normalize(vDir).y * 0.5 + 0.5, 0.0, 1.0); + gl_FragColor = vec4(mix(uHorizon, uZenith, smoothstep(0.12, 0.92, t)), 1.0); +}`; +type Focus = { x: number; y: number; z: number; d: number }; + +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); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(w, h); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + container.appendChild(renderer.domElement); + + // Geo scene? (scene=osm, scene=iceland, /geo, /ice, …). Resolved via pathScene() so this + // matches fetchSoa() EXACTLY (they share the one helper). Gates three things: server LOD is + // disabled (the /api/body/lod cascade runs over the BODY's block-bounds; a geo scene's + // concepts must render in full), the height-profile terrain palette is lit (uGeo), and the + // sky dome is added. An empty `?scene=` resolves falsy → anatomy body → all three stay off. + const sceneParam = new URLSearchParams(window.location.search).get('scene'); + const isGeoScene = Boolean(sceneParam ?? pathScene()); + // TERRAIN vs BUILDINGS. A terrain scene (Iceland DEM) is a dense, watertight heightfield — + // the same kind of surface as the anatomy body, so it earns the height-recolour + relief + // exaggeration + breathing that make the body read in 3D. A building scene (OSM/Berlin) is + // thin extruded footprints: exaggerating those 10x and recolouring them by height turns each + // building into a black needle (the #88 regression the operator flagged). So beautification is + // gated on TERRAIN, never on "any geo" — buildings render plain baked colours (uGeo/uExag/uRuler + // all neutral, exactly as anatomy), which is a solid city, not a needle field. + const sceneName = sceneParam ?? pathScene(); + const isTerrainScene = sceneName === 'iceland' || Boolean(sceneName?.startsWith('garmin:')); + + // Height range for the geo palette: measured ONCE from the decoded position buffer (display.y). + // The Iceland bake is true-scale so the span is tiny — normalizing against [-1,1] would flatten + // it to all-water; the actual [min,max] is what the shader needs. Only consumed when uGeo == 1. + let yMin = Infinity, yMax = -Infinity; + for (let i = 0; i < d.nVerts; i++) { const y = d.positions[i * 3 + 1]; if (y < yMin) yMin = y; if (y > yMax) yMax = y; } + if (!(yMax > yMin)) { yMin = 0; yMax = 1; } // degenerate/flat guard → avoid divide-by-zero + + 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 + + // 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()); + + // uGeo (height-recolour) + uExag (relief) are TERRAIN-only; buildings/anatomy keep uGeo=0 (baked + // aColor) + uExag=1. uRuler/uTime are retained-but-0: the Kurvenlineal golden-spiral residue is + // now baked into the mesh (geometry + normals), so the shader applies no ruler — the terrain is a + // static surface, not a shader-animated one. + const uniforms = { uAlpha: { value: 1 }, uGeo: { value: isTerrainScene ? 1 : 0 }, uYMin: { value: yMin }, uYMax: { value: yMax }, uExag: { value: isTerrainScene ? 15 : 1 }, uTime: { value: 0 }, uRuler: { value: 0 } }; + const mat = new THREE.ShaderMaterial({ uniforms, vertexShader: VERT, fragmentShader: FRAG, side: THREE.FrontSide }); + const mesh = new THREE.Mesh(geom, mat); scene.add(mesh); + + // Sky dome — geo scenes only (anatomy keeps the flat PAGE_BG set above, byte-identical). + let skyGeom: THREE.SphereGeometry | null = null; + let skyMat: THREE.ShaderMaterial | null = null; + if (isGeoScene) { + scene.background = SKY_HORIZON.clone(); // horizon tint behind the dome (any gap reads as sky) + skyGeom = new THREE.SphereGeometry(40, 32, 16); // radius 40 < camera far (100), surrounds the orbit + skyMat = new THREE.ShaderMaterial({ + side: THREE.BackSide, depthWrite: false, + uniforms: { uHorizon: { value: SKY_HORIZON.clone() }, uZenith: { value: SKY_ZENITH.clone() } }, + vertexShader: SKY_VERT, fragmentShader: SKY_FRAG, + }); + const sky = new THREE.Mesh(skyGeom, skyMat); + sky.renderOrder = -1; // draw first → terrain (with depth) always paints over it + scene.add(sky); + } + + // minimal orbit: drag = rotate, wheel = dolly. + // Geo scenes open on an AERIAL oblique view (el ~35°) so the terrain reads as a landscape under + // the sky dome, not edge-on; anatomy keeps the near-level body orbit. Target lifts slightly so + // the raised relief sits in frame. Drag/wheel still free-orbit from there. + let az = 0, el = isGeoScene ? 0.62 : 0.1, dist = isGeoScene ? 2.6 : 3.0, dragging = false, px = 0, py = 0; + const target = new THREE.Vector3(0, isGeoScene ? 0.08 : 0, 0); + 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; + 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 }); + + // 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; + // Any geo scene (scene=osm, scene=iceland, /geo, /ice, …) shares the /api/body/lod + // endpoint, but that cascade runs over the BODY's compile-time block-bounds — it would + // cull the geo concepts with anatomy bounds. Server LOD is therefore disabled for EVERY + // geo scene (full render); a geo LOD that reads the scene's own .blocks sidecar is future + // work. The gate is `isGeoScene`, computed once near the top of mount() via the SAME + // pathScene() helper fetchSoa() uses, so the artifact resolver and the LOD gate can never + // disagree (an empty `?scene=` → anatomy body → helix_latest → LOD stays ON). + const postLod = (now: number) => { + if (isGeoScene || 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; + }; + window.addEventListener('resize', onResize); + const tick = () => { + raf = requestAnimationFrame(tick); + // Every scene renders on-demand (dirty gating) — no idle redraw. The terrain's Kurvenlineal + // residue is baked into the mesh (static surface), so there is no per-frame shader animation. + // 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; } + 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); + // 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); + 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(); + if (skyGeom) skyGeom.dispose(); + if (skyMat) skyMat.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); + // /helix → anatomy body (helix_latest). /helix?scene= → that scene's + // bake (_latest, e.g. osm_latest, iceland_latest) through the SAME + // Signed360 decoder. /geo is shorthand for scene=osm, /ice for scene=iceland + // (via pathScene() — the SAME helper isGeoScene reads, so the bake choice and + // the LOD/beautification gate can never disagree). The body's data slot and the + // separate /osm slippy-map page are both untouched. + const scene = + new URLSearchParams(window.location.search).get('scene') ?? pathScene(); + // /garmin/ — mod-rewrite style: the SERVER resolves the slug through the + // manifest's garmin_scenes registry (/api/garmin/:location) and serves the bake. + // A 404 body lists the available slugs, so surface it verbatim. + if (scene?.startsWith('garmin:')) { + const loc = scene.slice('garmin:'.length); + const r = await fetch(`/api/garmin/${loc}`); + if (!r.ok) { + const detail = await r.json().then((j) => `${j.error}${j.available ? ` — available: ${j.available.join(', ')}` : ''}`).catch(() => `HTTP ${r.status}`); + throw new Error(`garmin scene "${loc}": ${detail}`); + } + return inflate(r); + } + const key = scene ? `${scene}_latest` : 'helix_v4_latest'; + const stamped: string | undefined = man?.[key]; + if (!stamped) { + throw new Error(`no bake for scene="${scene ?? 'body'}" — set ${key} in /body.manifest.json (soabake → helix::encode_signed; osm → geo/osm_helix)`); + } + 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 BodyHelix2() { + 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 [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; + 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(() => { 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(); + // Geo scenes: the terrain bake stamps a single layer — present it as "terrain" and drop the + // empty anatomy layers, so a map never reads as skin/muscle/skeleton (the layer *id* is kept, so + // the show/hide toggle still filters the real geometry). Anatomy keeps the full LAYERS taxonomy. + const geoScene = new URLSearchParams(window.location.search).get('scene') ?? pathScene(); + const geoUI = Boolean(geoScene); + // Geo bakes stamp a single layer; present it with a domain-true name (a MAP must never read as + // skin/muscle/skeleton) and drop the empty anatomy layers. Terrain vs buildings gets its own name. + const geoTerrain = geoScene === 'iceland' || Boolean(geoScene?.startsWith('garmin:')); + const geoLayerName = geoTerrain ? 'terrain' : 'buildings'; + const geoLayerColor = geoTerrain ? '#7c8f5c' : '#9aa7b4'; + const activeLayers = + geoUI && d + ? LAYERS.filter((l) => d.conceptList.some((c) => c.layer === l.id)).map((l) => ({ ...l, name: geoLayerName, color: geoLayerColor })) + : LAYERS; + const groups = activeLayers.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); + + // Scene label (same resolution as fetchSoa/isGeoScene). Anatomy keeps the exact original + // title/subtitle; geo scenes get a scene-appropriate heading so /ice doesn't read "anatomy". + const scene = new URLSearchParams(window.location.search).get('scene') ?? pathScene(); + const title = + scene === 'iceland' ? '/ice — Iceland height-profile terrain' + : scene === 'osm' ? '/geo — OSM buildings' + : scene?.startsWith('garmin:') ? `/garmin/${scene.slice(7)} — Garmin terrain` + : scene ? `/helix?scene=${scene}` + : '/helix2 — living anatomy browser (v4 bake)'; + const subtitle = d + ? scene + ? `${d.nVerts.toLocaleString()} verts · ${d.concepts.toLocaleString()} structures · ${scene === 'iceland' || scene.startsWith('garmin:') ? 'height-profile palette + sky' : 'baked colours + sky'}` + : `${d.nVerts.toLocaleString()} verts · ${d.concepts.toLocaleString()} structures · helix::Signed360 normals (Fisher-Z rim)` + : 'loading canonical helix bake…'; + // Scene menu — the "menu item" leg of the /garmin arc: built-ins + every slug the + // manifest's garmin_scenes registry names. Plain navigation (each scene is a page). + const [garminScenes, setGarminScenes] = useState([]); + useEffect(() => { + fetch('/body.manifest.json').then((r) => (r.ok ? r.json() : null)) + .then((m) => { if (m?.garmin_scenes) setGarminScenes(Object.keys(m.garmin_scenes)); }) + .catch(() => {}); + }, []); + const sceneOptions = [ + { label: 'body (/helix)', path: '/helix' }, + { label: 'berlin (/geo)', path: '/geo' }, + { label: 'iceland (/ice)', path: '/ice' }, + ...garminScenes.map((s) => ({ label: `garmin: ${s}`, path: `/garmin/${s}` })), + ]; + const herePath = window.location.pathname; + + return ( +
+
+
+
{title}
+
+ {error ? {error} : subtitle} +
+
+ {d && ( +
+ + + + {activeLayers.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/cockpit/src/main.tsx b/cockpit/src/main.tsx index 07c0c2bdf..117321e1c 100644 --- a/cockpit/src/main.tsx +++ b/cockpit/src/main.tsx @@ -15,6 +15,7 @@ import { TorsoMap } from './TorsoMap'; import { FmaBody } from './FmaBody'; import { BodyV3 } from './BodyV3'; import BodyHelix from './BodyHelix'; +import BodyHelix2 from './BodyHelix2'; import GeoHelix from './GeoHelix'; import GenomeHelix from './GenomeHelix'; import { CpicCockpit } from './CpicCockpit'; @@ -113,6 +114,12 @@ createRoot(document.getElementById('root')!).render( 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). */} } /> + {/* /helix2 — the V4-BAKE sibling of /helix. Identical viewer (verbatim + BodyHelix2.tsx fork, #64-style); the ONLY difference is the manifest key + it reads: helix_v4_latest instead of helix_latest. Renderer held constant + so any visible difference is the BAKE, not the viewer. Until a v4 bake is + published it reports the missing key rather than falling back to v3. */} + } /> {/* /geo, /ice, /garmin/:location — the MAP/TERRAIN scenes, served by the GeoHelix fork (verbatim copy of BodyHelix, #64-style) so map-shader work can never regress the /helix anatomy body. Same BSO2 decoder; From 76691f897f7dd381e8e8cfe224e5e13240133eb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 04:54:11 +0000 Subject: [PATCH 2/8] =?UTF-8?q?plan(fma-v4):=20record=20the=20measured=20z?= =?UTF-8?q?ipper=20addressing=20proposal=20as=20=C2=A710?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appends the 2026-09-07 measurements behind the operator's zipper proposal (L1/L2 = is_a:is_a, part_of as parent-of-last, mask -1, edges then groups). Marked MEASURED, TO BE RESEARCHED; §6 stays unresearched and nothing is ruled. The load-bearing figures: is_a max depth is exactly 24 (0.00% overflow at 24, 95.93% at today's 6), and is_a is a tree (2 multi-parent of 104,698) so mask -1 is valid there, while part_of is a DAG (536 of 1,522) so it is not. Sizing part_of at 12 makes the shortest-vs-deepest question moot. The uncle escape works only on a part_of-ordered address (0 unreachable, max climb 5) and fails on an is_a-ordered one (23 unreachable), so the ordering relation is the real decision. The many2many hubs already exist as FMA region and Set-of nodes. Adds four open items: the 203/281/301 baseline ambiguity, 23 empty-FMAID rows, the post-mint one-byte reachability falsifier, and that 24 has zero headroom until P-5 runs on 4.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- .../plans/2026-09-06-fma-bake-v4-plan.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md b/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md index 91868d9bb..a77f2c532 100644 --- a/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md +++ b/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md @@ -597,3 +597,163 @@ and is now the *only* unblocked item — with its sub-choice sharpened by the D-2 and D-4 remain blocked, and P-1 did not unblock them — it ran on 3.0, and it surfaced a **prior** question (shortest vs deepest ancestry) that must be ruled before D-4 can be sized at all. + +--- + +## 10. The zipper addressing proposal (measured 2026-09-07) + +> **STATUS: MEASURED, TO BE RESEARCHED.** Every figure below is a command +> output over the BodyParts3D **3.0** data on disk (`FMA.csv`, +> `conventional_part_of.txt`). Nothing here is ruled, and §6 remains +> unresearched. P-5 (4.0) is still blocked, so every bound is 3.0's. + +### 10.1 The proposal + +Operator, 2026-09-07. Replace the shared L1 rail with a same-relation zipper +spanning two tenants: + +``` +old L1 = 6 × (8:8) = part_of : is_a → 6 is_a slots, 6 part_of slots +new L1 = 6 × (8:8) = is_a : is_a ┐ + L2 = 6 × (8:8) = is_a : is_a ┘ → 24 is_a slots +``` + +with `part_of` read as **the parent of the last** occupied slot, **parent = +mask −1** (prefix truncation), extra `part_of` parents carried on the +`EdgeBlock`, and — where edges do not suffice — many2many group nodes. + +### 10.2 Why 24: it is the measured bound, not a round number + +is_a depth over all 104,698 parented nodes in `FMA.csv`: + +| is_a levels | overflow | share | +|---|---|---| +| 6 (today's L1 rail) | 100,439 | **95.93 %** | +| 12 (one tenant) | 50,885 | 48.60 % | +| **24 (two tenants)** | **0** | **0.00 %** | + +Max is_a depth is **exactly 24** — a coherent chain `owl#Thing` → +`Anatomical entity` → … → `Dorsal digital vein of left big toe`. At today's +6 slots the is_a rail does not merely overflow, it fails for **96 %** of +concepts. **24 has ZERO headroom**: any deepening in 4.0 breaks it. + +### 10.3 `parent = mask −1` is valid for is_a, and NOT for part_of + +| relation | source | nodes | multi-parent | shape | +|---|---|---|---|---| +| **is_a** | `FMA.csv` | 104,698 | **2** (0.002 %) | **tree** | +| **part_of** | `conventional_part_of.txt` | 1,522 | **536** (35 %) | **DAG** | + +Prefix truncation needs a tree. is_a is one (104,696/104,698 single-parent; +the exceptions are `Aortopulmonary septum` with two real parents, plus one +malformed row). part_of is not: 365 nodes have 2 parents, 83 have 3, 48 have +4, 39 have 5, and one — the sacrum — has 6. + +**§9.4's shortest-vs-deepest question (D-4) becomes MOOT** at these widths: +part_of max depth is 12 (longest path) / 9 (shortest), so a 12-slot part_of +lane overflows **0 either way**. The zipper does not answer D-4; it dissolves +it. + +### 10.4 Edges carry the DAG; group nodes are not needed for it + +Once the mask chain holds the primary parent, edges carry only the extras: +365 nodes need 1 slot, 83 need 2, 48 need 3, 39 need 4, **1 needs 5** — 836 +extra edges, worst case **5 against the EdgeBlock's 16**. + +**But a slot is a ONE-BYTE ref, and one byte is not a global address.** The +operator's two escapes — *"accumulate if they are global, or reference the +uncle"* — are decided by which relation orders the address: + +| ladder the address is ordered by | extras within uncle range (climb ≤ 2) | max climb | unreachable | +|---|---|---|---| +| **is_a-ordered** | 14.0 % | 16 | **23** | +| **part_of-ordered** | **45.8 %** (88.9 % at ≤ 3) | **5** | **0** | + +A one-byte relative ref (`climb` 3 bits, `sibling` 5 bits) fits the part_of +ladder: max climb 5, and only **1 of 493** internal nodes exceeds 32 children +(max 36, mean 3.1). + +**Consequence for this proposal:** with is_a in the 24, the address is +is_a-ordered, so the uncle escape does NOT apply (86 % out of range, 23 +unreachable) and the extras must be the **accumulating/global** kind. That is +affordable — the part_of graph is 1,523 nodes ≈ 11 bits, so ~2 bytes per ref, +worst node 5 × 2 = 10 of 16 EdgeBlock bytes. **The real decision is the +ordering relation, not the slot count**: is_a-ordered buys the clean 24-deep +taxonomy mask and pays 2-byte global edge refs; part_of-ordered buys one-byte +uncles and needs is_a represented some other way. + +### 10.5 The bone baseline, and hubs that already exist + +Operator: *"bones as the baseline"*, then *"bones > many2many > second hop +many2many (organs)"*. Rings outward from the 301 skeleton nodes, over +undirected part_of: + +| hop | nodes | composition | +|---|---|---| +| 0 | 301 | skeleton (baseline) | +| **1** | **54** | **100 % tissue-less ("other")** | +| 2 | 143 | muscle 72, other 65, **organ 6** | +| 3 | 263 | muscle 151, other 99, **organ 13** | +| 4 | 294 | muscle 207, other 68, vessel 11, **organ 5** | + +The hop-1 shell carrying **no tissue at all** is the signature of a grouping +layer — and its members are named as such: `Set of phalanges` (deg 57), +`Skeletal system of lower free limb` (44), `Foot` (33), `Anterior chest` (33), +`Abdomen` (24), `Thorax` (20), `Trunk` (19). + +**The many2many hubs do not need inserting — they need recognising.** FMA +already ships regions and sets as first-class nodes; they classify as "other" +only because `layer_of` keys on tissue, which a container has none of. The +same pattern dominates is_a: `Set of arteries` (224 subtypes), `Set of +systemic veins` (169), `Set of nerves` (141). + +**Correction to the hop count:** two hops reaches **6 of 24** organs; the mode +is three (13), and 5 need four. The ladder is right, one rung longer than +stated for most organs. + +Worked example (operator: *"sternum connective tissue(n) > lunge, herz +"*), traced in the data: + +``` +sternum → anterior chest → middle mediastinum → heart (3 hops) +sternum → anterior chest → thorax → right lung → lung (4 hops) +sternum → anterior chest → thorax → thoracic aorta → aorta (4 hops) +``` + +`anterior chest` (32 children, 1 parent) and `thorax` (19/1) are clean +fan-out hubs. `middle mediastinum` has 1 child and **2 parents** — a bridge, +i.e. one of the 536 edge-carried cases, not a fan-out hub. + +**The aorta is NOT a hub in either graph**: 0 is_a subtypes, 2 part_of parts — +effectively a leaf. An aorta-rooted vessel hub would have to be MINTED; what +FMA ships is the flat `Set of arteries`. Vessel *depth* comes instead from the +`Subdivision of…` / `Tributary of…` chain, which is the 24-deep ladder of +§10.2. + +### 10.6 Open items this section adds + +- **O-10a — the baseline's cardinality is ambiguous.** Three skeleton counts + are live: **203** bones (the original `bake_torso_splat.py` v4 census), + **281** (what `/helix` renders today), **301** (part_of under `layer_of`'s + `bone|cartilage`). If bones are the baseline, a 203/281/301 ambiguity in the + anchor propagates into every address derived from it. Pin it before minting. +- **O-10b — 23 rows in `FMA.csv` carry an EMPTY FMAID** (e.g. `Costal surface + of scapula`, `Oral orifice`). At mint time they either vanish or collide at + address 0. Decide before the bake, not after. +- **O-10c — the one-byte reachability gate (falsifier).** After minting, check + that all 836 extra part_of edges are one-byte-reachable from their child. If + yes, edges alone suffice and group nodes stay unused headroom. If no, that is + where group nodes earn their place — for the out-of-scope extras + specifically, never for multi-parenthood in general. +- **O-10d — 24 has zero headroom.** The bound is exact on 3.0. P-5 (4.0) must + run before 24 is locked. + +### 10.7 Reproducing these numbers + +All figures come from read-only passes over +`bodyparts3d/assets/BodyParts3D_data/{FMA.csv,conventional_part_of.txt,parts_list_e.txt}`; +`FMA.csv` ids are bare numerals and `conventional_part_of.txt` ids carry an +`FMA` prefix, so the join needs `removeprefix("FMA")` (1,496 of 1,522 part_of +children resolve into the is_a tree). Layer names follow +`crates/osint-bake/tools/body-soa-wire/src/main.rs::layer_of`. No producer was +run and no bake artifact was read or written. From 1f7836ebe7bb405c65fe1bb366d3eb3abd541a89 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 04:55:35 +0000 Subject: [PATCH 3/8] plan(fma-v4): separate the is_a fan-out spine from the part_of many2many hubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §10.5 as written invited reading `Set of…` / `Subdivision of…` as many2many markers. Measured, none of them is: `Subdivision of…` (537) and `Tributary of…` (146) have zero multi-parent nodes and are strictly one-to-many, and is_a as a whole has only 2 multi-parent nodes in 104,698 — so no is_a naming pattern can be the many2many layer. Those nodes are the depth spine that produces the 24-level chain and makes mask -1 valid, not a violation of it; the genuine many2many lives in part_of. Also records that `Set of…` is a poor hub marker: 79% are leaves with mean fan-out 1.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- .../plans/2026-09-06-fma-bake-v4-plan.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md b/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md index a77f2c532..57ecd1929 100644 --- a/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md +++ b/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md @@ -711,6 +711,38 @@ systemic veins` (169), `Set of nerves` (141). is three (13), and 5 need four. The ladder is right, one rung longer than stated for most organs. +**Do not read the `Set of…` / `Subdivision of…` names as many2many markers — +they are fan-out, and the distinction decides whether mask −1 survives.** +Many-to-many requires more than one parent; is_a has only **2** multi-parent +nodes in all 104,698, so **no is_a naming pattern can be the many2many layer**. +Measured: + +| pattern | nodes | fan-out mean/max | leaves | multi-parent | +|---|---|---|---|---| +| `Subdivision of…` | 537 | 5.0 / 40 | 3 % | **0** | +| `Tributary of…` | 146 | 3.1 / 22 | 31 % | **0** | +| `Region of…` | 489 | 5.6 / 150 | 2 % | **0** | +| `Segment of…` | 552 | 4.7 / 52 | 14 % | **0** | +| `Set of…` | 3 409 | **1.0** / 224 | **79 %** | **0** | + +`Subdivision of…` and `Tributary of…` are strictly ONE-to-many (522 and 101 +respectively are single-parent-with-children). They are the **depth spine** of +the is_a tree — the rungs that produce §10.2's 24-level chain (`Subdivision of +inferior systemic venous tree` → `Tributary of femoral vein` → … ). They BUILD +the tree that makes `parent = mask −1` valid; they do not violate it. + +So the two layers are in two different graphs and must not be conflated: + +- **depth spine** — `Subdivision of…` / `Tributary of…`, in **is_a**, a tree, + addressed by the mask chain; +- **hub shell** — the region/set nodes of §10.5's hop-1 ring, in **part_of**, + where the 536 genuine many2many nodes actually live, carried on edges. + +Also: `Set of…` is **not** a reliable hub marker — 79 % are leaves with mean +fan-out 1.0. Only a handful (`Set of arteries` 224, `Set of systemic veins` +169, `Set of nerves` 141) are real hubs, so keying on the name would be wrong +four times in five. + Worked example (operator: *"sternum connective tissue(n) > lunge, herz "*), traced in the data: From 57dfc0c2a5ad79f18a8da54b24f97fb70199c839 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 05:00:42 +0000 Subject: [PATCH 4/8] =?UTF-8?q?plan(fma-v4):=20add=20=C2=A711=20vessels-as?= =?UTF-8?q?-OSM-ways,=20and=20correct=20O-11b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §11 records that fill_body_soa.py reconstructs each vessel centerline from an unordered point cloud, and that all six of its constants are patches for that reconstruction failing — CAP because "a finger artery balloons to aorta size at a bend", PCTL because "two arms share one axial bin". An OSM way gives the ordered centerline instead of inferring it, which is the same intra-family failure kurvenlineal.rs already fixed for terrain: needle field, balloon field. O-11b is corrected after checking both directions. The aorta containment chain reads intact upward, so "arch of aorta has zero parts" was true downward but misleading — it is a leaf. What is missing is a KIND of relation, not a direction: the dataset carries taxonomy, containment and composition, and no connectivity edge anywhere. Nothing leaves the arch. A way network is defined by connectivity, so the centerline and the junction graph are two separate acquisitions and neither derives from the shipped relations. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- .../plans/2026-09-06-fma-bake-v4-plan.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md b/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md index 57ecd1929..9c183eca4 100644 --- a/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md +++ b/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md @@ -789,3 +789,113 @@ All figures come from read-only passes over children resolve into the is_a tree). Layer names follow `crates/osint-bake/tools/body-soa-wire/src/main.rs::layer_of`. No producer was run and no bake artifact was read or written. + +--- + +## 11. Vessels as OSM ways ("street nodes") — measured 2026-09-07 + +> **STATUS: MEASURED, TO BE RESEARCHED.** Operator proposal: *"the aorta then +> could be a street node as on OSM"*. What follows is what the code and data +> say about it; nothing is ruled. + +### 11.1 The current pipeline RECONSTRUCTS what a way would GIVE + +`crates/osint-bake/tools/fill_body_soa.py` derives each vessel's centerline +from an **unordered point cloud**: principal axis by power iteration on the +3×3 covariance, `BINS = 14` axial bins, then a per-bin radius from the +perpendicular distances. Six constants govern it, and each one's own comment +names the failure it patches: + +| const | value | the comment's justification | +|---|---|---| +| `CORE` | 0.62 | inner-core radius fraction under the wall | +| `RMAX` | 0.020 | *"ABSOLUTE diameter boundary … covers the aorta; clamps balloons"* | +| `RMIN` | 0.0008 | floor so capillaries keep a visible core | +| `CAP` | 2.0 | *"RMAX alone lets a finger artery balloon to aorta size at a bend"* | +| `PCTL` | 0.30 | *"at a strong bend two arms share one axial bin and the median perp-distance is ~half the gap (a balloon)"* | +| `CELL` | 0.015 | *"a continuous vessel keeps adjacent cells occupied … blobs farther apart split"* | + +**Every one is the absence of a way.** An OSM way is an ORDERED node list: +the centerline is given, never inferred; junctions are explicit SHARED nodes, +so "two arms share one axial bin" cannot arise; and calibre is an attribute +along the way, so a finger artery cannot inherit aortic radius. `RMAX` sized +to cover the aorta together with `CAP = 2.0` (twice the vessel's own calibre) +is the mechanism behind the operator's *"way too voluptuous"* observation on +the live `/helix` render. + +This corroborates §9.7 from the other direction: P-6 found the vessel +constants were empirical clamps from their first commit with no sequence ever +tried and replaced. They are not a tuning history — they are scaffolding +around a missing topology. + +### 11.2 It is the same bug class `kurvenlineal.rs` already fixed + +`geo/src/kurvenlineal.rs` documents the **intra-family needle bug**: seeding a +fresh `CurveRuler` per lattice cell made adjacent cells draw uncorrelated +phases, so the residue *"stepped discontinuously at every cell boundary … every +cell its own isolated spike, never connected to its neighbour"*. The fix was to +sample at lattice corners and interpolate — C1-continuous **across** boundaries +(inter-family), not a blur. + +Per-bin independent radius estimation is the same failure one domain over: +**needle field → balloon field.** Both are intra-family independence where the +feature is continuous; both are fixed by inter-family continuity along the +feature. + +### 11.3 What already exists to build on + +- `geo/src/osm_read.rs` **already reads OSM ways** — `RawWay`, ordered + `way.refs`, `Element::Way(w)`. It is filtered to `building=*` and closed into + rings (`refs[..len-1]` drops the repeat), but the way machinery is there. +- `geo/src/bin/osm_helix.rs` bakes those into the **same BSO2 `/helix` wire** + (`encode_bso2`) that the body uses — *"the geo counterpart of + `body-soa-wire`"*. +- `cockpit/src/GeoHelix.tsx` is a verbatim `BodyHelix` fork sharing the **same + BSO2 decoder**. Body and map are already one substrate; vessels-as-ways + closes the loop rather than opening a new one. + +### 11.4 Why this fits the §10 addressing, not fights it + +§10.5 measured that the venous depth spine is `Subdivision of…` / +`Tributary of…` — 537 + 146 nodes, single-parent, ~5 children each. Those names +are *linear* language: a tributary is a segment of a run, not a taxon. The is_a +tree is currently encoding a **linear structure as a hierarchy**, which is why +the deepest chain in the whole ontology (24, §10.2) is a venous one. A way +model carries that run natively and leaves is_a to carry type. + +### 11.5 Open items + +- **O-11a — no ordered centerline exists in the source.** BodyParts3D ships + meshes, not ways. The ordered node list has to come from somewhere: derived + once at bake time and STORED (so the reconstruction happens once, not per + render), or authored. Until it exists, this is a proposal, not a plan. +- **O-11b — the source ships NO connectivity relation at all.** This is the + hard item, and it is harder than a missing-direction problem. Checked in both + directions (operator, 2026-09-07): `conventional_part_of` rows carry whole AND + part, so the aorta chain reads intact upward — + `cardiovascular system → aorta → {thoracic aorta → {arch of aorta, ascending + aorta}, descending aorta}` (and `thoracic aorta` has TWO parents, `aorta` and + `thorax`, i.e. one of the §10.3 bridges). An earlier draft of this section + said *"arch of aorta has zero parts"*; that is true DOWNWARD but misleading — + the arch is simply a leaf of the containment tree. + + What is actually absent is a **kind** of relation, not a direction. All six + aorta rows are mereological. **No branch, junction, upstream or downstream + edge exists anywhere in the dataset**: nothing leaves the arch — no + brachiocephalic, no left common carotid, no left subclavian. BodyParts3D + ships taxonomy (`FMA.csv` is_a), containment (`conventional_part_of`) and + composition (`composite_parts`), and **zero connectivity**. Only 8 + `bifurcation` NODES exist in all of FMA (`Bifurcation of aorta`, the carotid + and iliac ones, trachea, pulmonary trunk, tooth root) — and a node named for a + junction is not an edge between two ways. + + A way network is DEFINED by connectivity; nesting cannot substitute for it. + So the ordered centerline of O-11a and the junction graph here are two + separate acquisitions, and neither can be derived from the shipped + relations — only from geometry, or from a source that carries flow. +- **O-11c — falsifier.** Replace the derived centerline with a stored ordered + way for ONE vessel (the aorta is the natural candidate: `Bifurcation of + aorta` exists, and `thoracic aorta` → `ascending aorta` + `arch of aorta` + gives a real segment chain) and check whether `CAP` and `PCTL` can be + removed without the balloon returning. If the clamps are still needed with a + given centerline, the diagnosis in §11.1 is wrong. From cf5041ee307f3e76340782a29ca19f0900f9ee46 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 05:05:38 +0000 Subject: [PATCH 5/8] =?UTF-8?q?plan(fma-v4):=20correct=20O-11b=20=E2=80=94?= =?UTF-8?q?=20connectivity=20IS=20recoverable=20from=20the=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O-11b claimed no connectivity could be derived from the shipped relations. Wrong: 74% of FMA labels (77,677 of 104,697) are " of ", and the object half resolves back to a real node by name — Subdivision of 95%, Tributary of 90%, Vasculature of 84%, Branch of 46%. That recovers ~490 vessel connectivity edges (359 branch_of + 131 tributary_of) with no new source, so the junction graph is partially derivable by a string join rather than unobtainable. Branch of at 46% is the weak spot and is what actually gates the way model. Still true: BodyParts3D ships one relation ("conventional inclusion") by design, and 8 bifurcation NODES are not edges between ways. Adds O-11d for the second half of the question: the same flattened predicates name type pairs the substrate does not model — periosteum/compact/trabecular bone on a bone, lumen of a tube, wall of an organ, tendon of a muscle, vasculature of an organ — all derivable today from labels already in FMA.csv. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- .../plans/2026-09-06-fma-bake-v4-plan.md | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md b/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md index 9c183eca4..0169d2dd9 100644 --- a/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md +++ b/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md @@ -890,9 +890,53 @@ model carries that run natively and leaves is_a to carry type. junction is not an edge between two ways. A way network is DEFINED by connectivity; nesting cannot substitute for it. - So the ordered centerline of O-11a and the junction graph here are two - separate acquisitions, and neither can be derived from the shipped - relations — only from geometry, or from a source that carries flow. + + **⊘ CORRECTED same-day (2026-09-07).** The paragraph above said connectivity + could not be derived from the shipped relations. That is WRONG as stated, and + the correction matters because it makes §11 cheaper, not more expensive. + Connectivity is not declared as a RELATION, but it is **flattened into the + labels** and is recoverable: **74 % of all FMA labels (77 677 of 104 697)** + have the form ` of `, and the object half resolves back to + a real node by name at high rates: + + | flattened predicate | nodes | object resolves | + |---|---|---| + | `Subdivision of` | 537 | **95 %** | + | `Periosteum of` | 1 598 | 93 % | + | `Segment of` | 552 | 91 % | + | **`Tributary of`** | 146 | **90 %** | + | `Wall of` | 1 296 | 89 % | + | `Lumen of` | 984 | 88 % | + | `Tendon of` | 649 | 87 % | + | `Trunk of` | 4 638 | 86 % | + | `Vasculature of` | 1 582 | 84 % | + | **`Branch of`** | 784 | 46 % | + + Recovered edges are real: `Branch of deep cervical artery` → `Deep cervical + artery`; `Tributary of femoral vein` → `femoral vein`. That yields roughly + **490 vessel connectivity edges** (359 `branch_of` + 131 `tributary_of`) + without any new source. + + What remains true: BodyParts3D itself ships only ONE relation — its README + calls it *"conventional inclusion"* — plus names, composite definitions and + meshes; it is a *"dictionary-type database"* of shapes and positions, so the + absence there is by design. And only **8** `bifurcation` NODES exist in FMA; a + node named for a junction is still not an edge between two ways. + + So the corrected position: the junction graph is **partially derivable by + label parsing** (a string join, no new data), not unobtainable. `Branch of` at + 46 % is the weak spot — most misses are compound objects — and that number, + not the existence of connectivity, is what gates the way model. + +- **O-11d — the predicates ask for other TYPES, and they are already counted.** + Each flattened predicate names a type pair the substrate does not yet model: + `Periosteum of ` and `Compact bone of` / `Trabecular bone of` (1 640 / + 1 711) are tissue layers ON a bone; `Lumen of ` (984) is the hollow; + `Wall of ` (1 296); `Tendon of ` (649); `Vasculature of + ` (1 582) is an organ's vessel set. These are the "other types" a v4 + bake would have to admit or deliberately collapse — and unlike the ordered + centerline, they need no acquisition: they are derivable today from labels + already in `FMA.csv`. - **O-11c — falsifier.** Replace the derived centerline with a stored ordered way for ONE vessel (the aorta is the natural candidate: `Bifurcation of aorta` exists, and `thoracic aorta` → `ascending aorta` + `arch of aorta` From 1af40c52833c76c036f614b8be645e409deb58eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 05:07:47 +0000 Subject: [PATCH 6/8] plan(fma-v4): record Set-of as a many2many trigger, not a container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured over all 3,409 "Set of" nodes: only 30 have any part_of members, only 57 appear in conventional_part_of at all, and 79% have no is_a children — they are edge-less declarations in both shipped relations. The extent is not stored because it is derivable: Set of of is the join { p in parts(O) : p is_a T }, verified against the heart's 10 parts (arteries -> the two coronaries, veins -> the two cardiac veins, organ components -> the six chambers/wall/ myocardium). Naive label matching recovers only 32% with ~1 member each, so the join is what works. Consequence: do not mint a hub per Set node (3,409 empty containers) and do not store the extent (a second source of truth for what the join yields). The set is many-to-one into the taxonomy, so it costs nothing in the mask chain; only its computed extent is many2many. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- .../plans/2026-09-06-fma-bake-v4-plan.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md b/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md index 0169d2dd9..29581a9fe 100644 --- a/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md +++ b/claude-notes/plans/2026-09-06-fma-bake-v4-plan.md @@ -743,6 +743,39 @@ fan-out 1.0. Only a handful (`Set of arteries` 224, `Set of systemic veins` 169, `Set of nerves` 141) are real hubs, so keying on the name would be wrong four times in five. +**What `Set of…` actually is (operator, 2026-09-07): a TRIGGER that spawns a +many2many, not a container that holds one.** Measured over all 3 409 `Set of` +nodes: **only 30 have any part_of members**, only 57 appear in +`conventional_part_of` at all, and 79 % have no is_a children. They are +edge-less declarations in BOTH shipped relations. + +Their extent is not stored because it is **derivable** — the name is a +specification, and the set is where part_of and is_a MEET: + +> **`Set of of ` ≡ `{ p ∈ parts(O) : p is_a T }`** + +Verified against the heart, whose 10 conventional parts filter cleanly: + +| set node (stores nothing) | computed extent | +|---|---| +| `Set of arteries of heart` | left coronary artery, right coronary artery | +| `Set of veins of heart` | small cardiac vein, cardiac vein | +| `Set of organ components of heart` | left/right atrium, left/right ventricle, wall of heart, myocardium | + +(A naive label-string query recovers only 32 % with ~1 member each — the JOIN +is what works, not name matching.) + +Consequences for the bake: + +- **Do NOT mint a hub node per `Set of`.** That yields 3 409 empty containers. + The node declares that a many2many is AVAILABLE at that point; materialise it + on demand by running the join. +- **Do not store the extent.** It would be a second source of truth for + something `parts(O) × is_a` already yields — the same reasoning that keeps + labels out of the payload (§2 slot-purity). +- The set node itself is many-to-ONE into the taxonomy (single parent), so it + costs nothing in the §10 mask chain; only its computed extent is many2many. + Worked example (operator: *"sternum connective tissue(n) > lunge, herz "*), traced in the data: From 351740cd096078ce0570ceb068a12c17ec91d365 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 05:10:19 +0000 Subject: [PATCH 7/8] fix(cockpit): /helix2 must not reuse the v3 LOD bounds or claim to be /helix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects from forking BodyHelix.tsx verbatim, both found by codex on #151. Server LOD is now off on this route. /api/body/lod cascades over the v3 body's compile-time block-bounds (include_bytes!("assets/body.blocks")) and returns actions indexed by v3 concept-row position. BodyHelix already disables it for geo scenes on exactly that argument; a v4 bake changes concept rows and geometry by construction, so folding v3 actions into d.vrow would hide visible structures and keep off-screen ones — silently, while looking like a successful v4 render, which is the failure mode this route exists to avoid. The inherited comment claiming "empty ?scene= -> helix_latest -> LOD stays ON" was wrong here, since an empty scene resolves helix_v4_latest. The toggle renders disabled with the reason rather than vanishing, so re-enabling is one line once a v4-matched .blocks sidecar exists. The scene selector now lists /helix2. Its value falls back to '/helix' for any path not + // in this list, so without it the selector claims the v3 body is selected while this + // component renders the v4 bake — and offers no way back to the comparison route. + { label: 'body v4 (/helix2)', path: '/helix2' }, { label: 'body (/helix)', path: '/helix' }, { label: 'berlin (/geo)', path: '/geo' }, { label: 'iceland (/ice)', path: '/ice' }, @@ -838,7 +849,11 @@ export default function BodyHelix2() { {sceneOptions.map((o) => )} - + {/* LOD is inert on /helix2 (see LOD_DISABLED): the endpoint's bounds describe the + v3 bake, not this one. Render it disabled rather than removing it, so the + control's absence is explained rather than mysterious — and so re-enabling it + is a one-line change once a v4-matched .blocks sidecar exists. */} + {activeLayers.map((l) => (