Skip to content

Commit 7896f76

Browse files
committed
/helix: living-anatomy browser — search, expandable groups, x-ray, server LOD
The /helix viewer had only the layer toggles. Add the rest of the cockpit so it reads as a living, browsable database rather than a static mesh: - Left-side browser: the 1658 FMA structures grouped by layer (skin / muscle / organ / skeleton / vessel / nervous / connective / other), each group expandable (▾/▸), with a live per-group count. Concept names + display centroids are parsed from the BSO2 labels_json + centroid columns at decode. - Search: filters every group as you type; matching groups auto-expand. Click a result to glide the orbit camera + dolly onto that structure's centroid (revealing its layer if it was hidden). - x-ray: a uAlpha uniform makes the whole body translucent so deeper structures show through (depthWrite off; the fragment shader stays trivial). - LOD: opt-in server HHTL depth-cascade. Posts the live camera to the existing /api/body/lod endpoint (same protocol /body uses) and folds the per-concept reject byte 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. Absent endpoint (old deploy) silently keeps the full render; a near-total cull is treated as a camera-mapping miss and shows all. All additive: the decode/normal/Gouraud hot path is unchanged; the LOD cull and the layer mask share one linear index pass, so the mobile geometry-exclusion lever still holds. tsc + vite build clean. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhpwkHGgia2TuDFvdnuQdE
1 parent 1c24630 commit 7896f76

1 file changed

Lines changed: 141 additions & 20 deletions

File tree

cockpit/src/BodyHelix.tsx

Lines changed: 141 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,10 @@ function buildRLut(flo: number, fhi: number): Float32Array {
9191
interface Decoded {
9292
nVerts: number; nTris: number;
9393
positions: Float32Array; index: Uint32Array;
94-
colors: Uint8Array; normals: Int8Array; layer: Float32Array;
95-
concepts: number;
94+
colors: Uint8Array; normals: Int8Array; layer: Float32Array; vrow: Uint32Array;
95+
concepts: number; conceptList: ConceptMeta[];
9696
}
97+
interface ConceptMeta { row: number; name: string; layer: number; cx: number; cy: number; cz: number; }
9798

9899
function decode(buf: ArrayBuffer): Decoded {
99100
const dv = new DataView(buf);
@@ -106,8 +107,8 @@ function decode(buf: ArrayBuffer): Decoded {
106107
o += 16 * nC; // guid
107108
const matOff = o; o += nC; // material u8 (unused here)
108109
const layerOff = o; o += nC; // LAYER u8
109-
o += 4 * nC; // label idx
110-
o += 12 * nC; // centroid
110+
const labelOff = o; o += 4 * nC; // label idx (u32 → name in labels_json)
111+
const cenOff = o; o += 12 * nC; // centroid 3f
111112
o += 8 * nC; // vrange
112113
const posOff = o; o += posBytes * nV;
113114
const helixOff = o; o += 6 * nV; // pos3 | nrm3 — we read the nrm half
@@ -205,7 +206,20 @@ function decode(buf: ArrayBuffer): Decoded {
205206
}
206207
const index = kept.slice(0, w);
207208
if (nOut) console.log(`/helix max-diameter clamp: ${nOut} stray verts (worst ${worst.toFixed(1)}× p95), dropped ${(nT - w / 3).toLocaleString()} tris`);
208-
return { nVerts: nV, nTris: w / 3, positions, index, colors, normals, layer, concepts: nC };
209+
210+
// per-concept metadata for the browser: name (label→labels_json), layer, display centroid.
211+
let to = idxOff + 12 * nT;
212+
const labLen = dv.getUint32(to, true); to += 4;
213+
let names: string[] = [];
214+
try { const lj = JSON.parse(new TextDecoder().decode(new Uint8Array(buf.slice(to, to + labLen)))); names = lj.names ?? lj; } catch { /* names optional */ }
215+
const labelIdx = new Uint32Array(buf.slice(labelOff, labelOff + 4 * nC));
216+
const cen = new Float32Array(buf.slice(cenOff, cenOff + 12 * nC));
217+
const conceptList: ConceptMeta[] = [];
218+
for (let c = 0; c < nC; c++) {
219+
conceptList.push({ row: c, name: names[labelIdx[c]] ?? `concept ${c}`, layer: cLayer[c] || 8,
220+
cx: -cen[c * 3], cy: cen[c * 3 + 2], cz: cen[c * 3 + 1] }); // source → display (-x,z,y)
221+
}
222+
return { nVerts: nV, nTris: w / 3, positions, index, colors, normals, layer, vrow: rowArr, concepts: nC, conceptList };
209223
}
210224

211225
const VERT = `
@@ -225,11 +239,13 @@ void main(){
225239
}`;
226240
const FRAG = `
227241
precision mediump float;
242+
uniform float uAlpha; // 1 = solid · <1 = x-ray (whole-body translucent)
228243
varying vec3 vColor;
229-
void main(){ gl_FragColor = vec4(vColor, 1.0); }`; // visible layers are pre-filtered into the
244+
void main(){ gl_FragColor = vec4(vColor, uAlpha); }`; // visible layers are pre-filtered into the
230245
// draw range (NOT a discard) → early-Z survives; the GPU never touches hidden triangles.
246+
type Focus = { x: number; y: number; z: number; d: number };
231247

232-
function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dirty: { current: boolean }): () => void {
248+
function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dirty: { current: boolean }, focus: { current: Focus | null }, xray: { current: boolean }, lod: { current: boolean }): () => void {
233249
let w = container.clientWidth || window.innerWidth, h = container.clientHeight || window.innerHeight;
234250
const scene = new THREE.Scene(); scene.background = new THREE.Color(PAGE_BG);
235251
const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100); camera.position.set(0, 0.05, 3.0);
@@ -250,27 +266,33 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir
250266
const fullIdx = d.index;
251267
const nTriAll = fullIdx.length / 3;
252268
const triLayer = new Uint8Array(nTriAll);
253-
for (let t = 0; t < nTriAll; t++) triLayer[t] = d.layer[fullIdx[t * 3]];
269+
const triConcept = new Uint32Array(nTriAll); // concept (row) of each triangle → server-LOD gate
270+
for (let t = 0; t < nTriAll; t++) { triLayer[t] = d.layer[fullIdx[t * 3]]; triConcept[t] = d.vrow[fullIdx[t * 3]]; }
271+
// server-LOD action per concept: 255 = show (the default until the cascade answers), 0 = the
272+
// HHTL depth-cascade rejected this concept as off-frustum. Folded into the index rebuild below.
273+
const lodAction = new Uint8Array(d.concepts).fill(255);
254274
const active = new Uint32Array(fullIdx.length);
255275
const rebuild = (): number => {
256276
let n = 0;
257277
for (let t = 0; t < nTriAll; t++) {
258-
if (enabled[triLayer[t]] >= 0.5) { const o = t * 3; active[n++] = fullIdx[o]; active[n++] = fullIdx[o + 1]; active[n++] = fullIdx[o + 2]; }
278+
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]; }
259279
}
260280
return n;
261281
};
262282
const idxAttr = new THREE.BufferAttribute(active, 1);
263283
idxAttr.setUsage(THREE.DynamicDrawUsage);
264284
geom.setIndex(idxAttr);
285+
const applyIndex = () => { geom.setDrawRange(0, rebuild()); idxAttr.needsUpdate = true; };
265286
geom.setDrawRange(0, rebuild());
266287

267-
const mat = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, side: THREE.FrontSide });
288+
const uniforms = { uAlpha: { value: 1 } };
289+
const mat = new THREE.ShaderMaterial({ uniforms, vertexShader: VERT, fragmentShader: FRAG, side: THREE.FrontSide });
268290
const mesh = new THREE.Mesh(geom, mat); scene.add(mesh);
269291

270292
// minimal orbit: drag = rotate, wheel = dolly.
271293
let az = 0, el = 0.1, dist = 3.0, dragging = false, px = 0, py = 0;
272294
const target = new THREE.Vector3(0, 0, 0);
273-
const onDown = (e: PointerEvent) => { dragging = true; px = e.clientX; py = e.clientY; dirty.current = true; };
295+
const onDown = (e: PointerEvent) => { dragging = true; px = e.clientX; py = e.clientY; focus.current = null; dirty.current = true; };
274296
const onUp = () => { dragging = false; dirty.current = true; };
275297
const onMove = (e: PointerEvent) => {
276298
if (!dragging) return;
@@ -282,6 +304,36 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir
282304
el2.addEventListener('pointerdown', onDown); window.addEventListener('pointerup', onUp);
283305
window.addEventListener('pointermove', onMove); el2.addEventListener('wheel', onWheel, { passive: false });
284306

307+
// server HHTL LOD (opt-in): post the live camera to /api/body/lod; the depth-cascade returns
308+
// a per-concept action (0 = off-frustum reject). We fold the cull into the SAME geometry index
309+
// rebuild as the layer toggles — NOT a fragment discard — so early-Z survives and the GPU draws
310+
// strictly fewer triangles when zoomed in (the mobile lever, working WITH the database). Absent
311+
// endpoint (old deploy) → silently keep the full render. This is the living DB reasoning the view.
312+
let lodNext = 0, lodInflight = false, lodFail = false, lodDirty = false, lodWasOn = false;
313+
const postLod = (now: number) => {
314+
if (lodFail || lodInflight || now < lodNext) return;
315+
lodInflight = true; lodNext = now + 220;
316+
camera.updateMatrixWorld();
317+
const e = camera.matrixWorldInverse.elements; // column-major → row-major view rows
318+
const view = [
319+
[e[0], e[4], e[8], e[12]], [e[1], e[5], e[9], e[13]],
320+
[e[2], e[6], e[10], e[14]], [e[3], e[7], e[11], e[15]],
321+
];
322+
const fy = (h / 2) / Math.tan((camera.fov * Math.PI) / 360);
323+
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] };
324+
fetch('/api/body/lod', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
325+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
326+
.then((j: { actions: number[]; n_concepts?: number; tally?: number[] }) => {
327+
const a = j.actions;
328+
const visible = (j.n_concepts ?? a.length) - (j.tally?.[0] ?? 0);
329+
const degenerate = visible <= Math.max(1, a.length * 0.02); // cascade culled ~all ⇒ camera map suspect → show all
330+
for (let i = 0; i < d.concepts && i < a.length; i++) lodAction[i] = degenerate ? 255 : a[i];
331+
lodDirty = true; dirty.current = true;
332+
})
333+
.catch(() => { lodFail = true; }) // endpoint absent (old deploy) → keep full render
334+
.finally(() => { lodInflight = false; });
335+
};
336+
285337
let raf = 0, ema = 16.6, last = performance.now(), sig = enabled.join(',');
286338
const onResize = () => {
287339
w = container.clientWidth || window.innerWidth; h = container.clientHeight || window.innerHeight;
@@ -290,6 +342,11 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir
290342
window.addEventListener('resize', onResize);
291343
const tick = () => {
292344
raf = requestAnimationFrame(tick);
345+
// server-LOD lifecycle runs even on idle frames (the cascade tracks the static view too);
346+
// turning LOD off restores the full geometry. Both are cheap and bounded by the 220 ms poll.
347+
const tnow = performance.now();
348+
if (lod.current) { postLod(tnow); lodWasOn = true; }
349+
else if (lodWasOn) { lodWasOn = false; lodFail = false; lodAction.fill(255); lodDirty = true; dirty.current = true; }
293350
// render ON DEMAND: a static body (no drag/zoom/toggle) costs nothing — 6.8 M tris are
294351
// only redrawn when something actually changes, which is what makes idle + heat sane.
295352
if (!dirty.current && !dragging) { last = performance.now(); return; }
@@ -298,9 +355,22 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir
298355
// this is the single biggest lever — quarters/ninths the fragment load while dragging.
299356
const pr = ema > 33 ? 1 : Math.min(window.devicePixelRatio, 2);
300357
if (renderer.getPixelRatio() !== pr) renderer.setPixelRatio(pr);
301-
// layer toggled → rebuild the active index (geometry exclusion, not discard)
358+
// layer toggled OR server-LOD answered → rebuild the active index (geometry exclusion, not
359+
// discard): one linear pass folds both the layer mask and the per-concept LOD action.
302360
const ns = enabled.join(',');
303-
if (ns !== sig) { sig = ns; geom.setDrawRange(0, rebuild()); idxAttr.needsUpdate = true; }
361+
if (ns !== sig || lodDirty) { sig = ns; lodDirty = false; applyIndex(); }
362+
// x-ray: whole-body translucency (depthWrite off; cheap, unsorted-blend is fine here)
363+
const wantX = xray.current;
364+
if (mat.transparent !== wantX) { mat.transparent = wantX; mat.depthWrite = !wantX; mat.needsUpdate = true; }
365+
uniforms.uAlpha.value = wantX ? 0.4 : 1.0;
366+
// browser pick → glide the orbit target + dolly onto the chosen concept
367+
if (focus.current) {
368+
const f = focus.current;
369+
target.lerp(new THREE.Vector3(f.x, f.y, f.z), 0.12);
370+
dist += (f.d - dist) * 0.12;
371+
if (Math.abs(dist - f.d) < 0.02) focus.current = null;
372+
dirty.current = true;
373+
}
304374
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));
305375
camera.lookAt(target);
306376
renderer.render(scene, camera);
@@ -347,8 +417,15 @@ export default function BodyHelix() {
347417
const [d, setD] = useState<Decoded | null>(null);
348418
const [error, setError] = useState('');
349419
const [on, setOn] = useState<Record<number, boolean>>({ 1: false, 2: false, 3: true, 4: true, 5: true, 6: true, 7: true, 8: true });
420+
const [xray, setXray] = useState(false);
421+
const [lod, setLod] = useState(false); // server HHTL LOD — opt-in (off = full render)
422+
const [query, setQuery] = useState('');
423+
const [open, setOpen] = useState<Record<number, boolean>>({ 4: true }); // expanded layer groups
350424
const enabledRef = useRef(new Float32Array([0, 0, 1, 1, 1, 1, 1, 1, 1]));
351425
const dirtyRef = useRef(true); // request a redraw (the render loop is on-demand)
426+
const focusRef = useRef<Focus | null>(null);
427+
const xrayRef = useRef(false);
428+
const lodRef = useRef(false);
352429

353430
useEffect(() => {
354431
let cancelled = false;
@@ -361,33 +438,77 @@ export default function BodyHelix() {
361438
for (let i = 1; i <= 8; i++) enabledRef.current[i] = on[i] ? 1 : 0;
362439
dirtyRef.current = true;
363440
}, [on]);
364-
useEffect(() => { const c = ref.current; if (!c || !d) return; return mount(c, d, enabledRef.current, dirtyRef); }, [d]);
441+
useEffect(() => { xrayRef.current = xray; dirtyRef.current = true; }, [xray]);
442+
useEffect(() => { lodRef.current = lod; dirtyRef.current = true; }, [lod]);
443+
useEffect(() => { const c = ref.current; if (!c || !d) return; return mount(c, d, enabledRef.current, dirtyRef, focusRef, xrayRef, lodRef); }, [d]);
444+
445+
const focusOn = (c: ConceptMeta) => {
446+
focusRef.current = { x: c.cx, y: c.cy, z: c.cz, d: 0.6 };
447+
if (!enabledRef.current[c.layer]) setOn((p) => ({ ...p, [c.layer]: true })); // reveal its layer
448+
dirtyRef.current = true;
449+
};
365450

366451
const btn = (active: boolean): React.CSSProperties => ({
367452
padding: '5px 10px', borderRadius: 6, cursor: 'pointer', border: '1px solid #2a3242',
368453
background: active ? '#1c2738' : '#0e1219', color: active ? '#cdd9e5' : '#6b7686', font: '12px ui-monospace, monospace',
369454
});
455+
const q = query.trim().toLowerCase();
456+
const groups = LAYERS.map((l) => ({
457+
l, items: d ? d.conceptList.filter((c) => c.layer === l.id && (!q || c.name.toLowerCase().includes(q))) : [],
458+
})).filter((g) => g.items.length > 0 || !q);
370459

371460
return (
372461
<div style={{ position: 'fixed', inset: 0, background: `#${PAGE_BG.toString(16).padStart(6, '0')}` }}>
373462
<div ref={ref} style={{ position: 'absolute', inset: 0 }} />
374-
<div style={{ position: 'absolute', top: 12, left: 16, color: '#cdd9e5', font: '13px ui-monospace, monospace' }}>
375-
<div style={{ color: '#fff', fontSize: 15 }}>/helix — surfel-normal viewer (experimental)</div>
376-
<div style={{ opacity: 0.65, marginTop: 2, maxWidth: 440 }}>
463+
<div style={{ position: 'absolute', top: 12, left: 16, color: '#cdd9e5', font: '13px ui-monospace, monospace', pointerEvents: 'none' }}>
464+
<div style={{ color: '#fff', fontSize: 15 }}>/helix — living anatomy browser</div>
465+
<div style={{ opacity: 0.6, marginTop: 2, maxWidth: 300 }}>
377466
{error ? <span style={{ color: '#e06c6c' }}>{error}</span>
378-
: 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.`
379-
: 'loading canonical helix bake (Signed360 normals)…'}
467+
: d ? `${d.nVerts.toLocaleString()} verts · ${d.concepts.toLocaleString()} structures · helix::Signed360 normals (Fisher-Z rim)`
468+
: 'loading canonical helix bake…'}
380469
</div>
381470
</div>
382471
{d && (
383-
<div style={{ position: 'absolute', top: 12, right: 16, display: 'flex', gap: 6, flexWrap: 'wrap', maxWidth: 360, justifyContent: 'flex-end' }}>
472+
<div style={{ position: 'absolute', top: 12, right: 16, display: 'flex', gap: 6, flexWrap: 'wrap', maxWidth: 380, justifyContent: 'flex-end' }}>
473+
<button style={btn(xray)} onClick={() => setXray((x) => !x)} title="x-ray: make the whole body translucent so deeper structures show through">x-ray</button>
474+
<button style={btn(lod)} onClick={() => setLod((v) => !v)} title="LOD: the HHTL depth-cascade culls off-frustum structures as you zoom in — the living database deciding what's worth drawing">LOD {lod ? 'on' : 'off'}</button>
384475
{LAYERS.map((l) => (
385476
<button key={l.id} style={btn(on[l.id])} onClick={() => setOn((p) => ({ ...p, [l.id]: !p[l.id] }))}>
386477
<span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 4, background: l.color, marginRight: 5, verticalAlign: 'middle' }} />{l.name}
387478
</button>
388479
))}
389480
</div>
390481
)}
482+
{d && (
483+
<div style={{ position: 'absolute', left: 16, top: 66, bottom: 16, width: 290, display: 'flex', flexDirection: 'column', background: 'rgba(11,15,22,0.92)', border: '1px solid #1c2530', borderRadius: 10, overflow: 'hidden' }}>
484+
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={`search ${d.concepts.toLocaleString()} structures…`}
485+
style={{ margin: 10, padding: '8px 10px', borderRadius: 7, border: '1px solid #243244', background: '#0e1219', color: '#cdd9e5', font: '13px ui-monospace, monospace', outline: 'none' }} />
486+
<div style={{ overflowY: 'auto', padding: '0 6px 8px' }}>
487+
{groups.map(({ l, items }) => {
488+
const expanded = !!open[l.id] || !!q;
489+
return (
490+
<div key={l.id}>
491+
<div onClick={() => setOpen((p) => ({ ...p, [l.id]: !expanded }))}
492+
style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '7px 8px', cursor: 'pointer', color: '#cdd9e5', font: '12px ui-monospace, monospace', userSelect: 'none' }}>
493+
<span style={{ width: 8, opacity: 0.7 }}>{expanded ? '▾' : '▸'}</span>
494+
<span style={{ display: 'inline-block', width: 9, height: 9, borderRadius: 5, background: l.color }} />
495+
<span style={{ flex: 1 }}>{l.name}</span>
496+
<span style={{ opacity: 0.45 }}>{items.length}</span>
497+
</div>
498+
{expanded && items.slice(0, 500).map((c) => (
499+
<div key={c.row} onClick={() => focusOn(c)} title={c.name}
500+
style={{ padding: '4px 8px 4px 30px', cursor: 'pointer', color: '#9fb0c2', font: '12px ui-monospace, monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', borderRadius: 5 }}
501+
onMouseEnter={(e) => { e.currentTarget.style.background = '#152030'; e.currentTarget.style.color = '#dce6f0'; }}
502+
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#9fb0c2'; }}>
503+
{c.name}
504+
</div>
505+
))}
506+
</div>
507+
);
508+
})}
509+
</div>
510+
</div>
511+
)}
391512
</div>
392513
);
393514
}

0 commit comments

Comments
 (0)