|
| 1 | +// /fma-body — MY full-body FMA viewer (additive to the other session's /torso*). |
| 2 | +// |
| 3 | +// Renders cockpit/public/fma_body.mesh (baked by `fma`'s cockpit_bake) — the same SPM1 |
| 4 | +// indexed triangle surface the cockpit already decodes, but the per-vertex `opacity` |
| 5 | +// byte carries a clean LAYER id (1 skin · 2 muscle · 3 organ · 4 skeleton · 5 vessel · |
| 6 | +// 6 nerve · 7 connective · 8 other). So the viewer can TOGGLE each layer with a button, |
| 7 | +// and switch the whole body between SOLID and TRANSPARENT. Color is the converged |
| 8 | +// `tissue` byte (is_a); geometry is real BodyParts3D, vertex-cluster decimated. |
| 9 | +// |
| 10 | +// This does not touch /torso, /torso-live, /torso-splat, /torso-map (#57/#58). |
| 11 | +// |
| 12 | +// Geometry/data: BodyParts3D, (c) The Database Center for Life Science, CC-BY 4.0. |
| 13 | +import { useEffect, useRef, useState } from 'react'; |
| 14 | +import * as THREE from 'three'; |
| 15 | +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| 16 | + |
| 17 | +const PAGE_BG = 0x0a0e17; |
| 18 | + |
| 19 | +// layer id (opacity byte) → label + swatch. id 0 unused; index = id. |
| 20 | +const LAYERS: { id: number; name: string; color: string }[] = [ |
| 21 | + { id: 1, name: 'skin', color: '#dba88a' }, |
| 22 | + { id: 2, name: 'muscle', color: '#bd5c57' }, |
| 23 | + { id: 3, name: 'organ', color: '#cc9484' }, |
| 24 | + { id: 4, name: 'skeleton', color: '#ebe0c7' }, |
| 25 | + { id: 5, name: 'vessel', color: '#cc3838' }, |
| 26 | + { id: 6, name: 'nerve', color: '#ebd152' }, |
| 27 | + { id: 7, name: 'connective', color: '#e0dbcc' }, |
| 28 | + { id: 8, name: 'other', color: '#9696a0' }, |
| 29 | +]; |
| 30 | + |
| 31 | +interface Mesh { |
| 32 | + vertCount: number; |
| 33 | + triCount: number; |
| 34 | + positions: Float32Array; |
| 35 | + normals: Float32Array; |
| 36 | + colors: Uint8Array; |
| 37 | + layer: Float32Array; // per-vertex layer id (from the opacity byte) |
| 38 | + index: Uint32Array; |
| 39 | +} |
| 40 | + |
| 41 | +// SPM1 (little-endian): header 40 B | vert 21 B [pos 3f|normal 3i8|rgb 3u8|opacity u8| |
| 42 | +// node_row u16] | index 12 B. Orientation (x,y,z)->(-x,z,y): proper rotation (det +1), |
| 43 | +// head-up in three.js Y-up — identical to TorsoMesh so both viewers agree. |
| 44 | +function decodeSpm1(buf: ArrayBuffer): Mesh { |
| 45 | + const dv = new DataView(buf); |
| 46 | + const magic = String.fromCharCode(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3)); |
| 47 | + if (magic !== 'SPM1') throw new Error(`bad magic "${magic}" (expected SPM1)`); |
| 48 | + const vertCount = dv.getUint32(4, true); |
| 49 | + const triCount = dv.getUint32(8, true); |
| 50 | + const voff = 40; |
| 51 | + const positions = new Float32Array(vertCount * 3); |
| 52 | + const normals = new Float32Array(vertCount * 3); |
| 53 | + const colors = new Uint8Array(vertCount * 3); |
| 54 | + const layer = new Float32Array(vertCount); |
| 55 | + for (let i = 0; i < vertCount; i++) { |
| 56 | + const b = voff + i * 21; |
| 57 | + const x = dv.getFloat32(b, true), y = dv.getFloat32(b + 4, true), z = dv.getFloat32(b + 8, true); |
| 58 | + positions[i * 3] = -x; positions[i * 3 + 1] = z; positions[i * 3 + 2] = y; |
| 59 | + normals[i * 3] = -dv.getInt8(b + 12) / 127; |
| 60 | + normals[i * 3 + 1] = dv.getInt8(b + 14) / 127; |
| 61 | + normals[i * 3 + 2] = dv.getInt8(b + 13) / 127; |
| 62 | + colors[i * 3] = dv.getUint8(b + 15); |
| 63 | + colors[i * 3 + 1] = dv.getUint8(b + 16); |
| 64 | + colors[i * 3 + 2] = dv.getUint8(b + 17); |
| 65 | + layer[i] = dv.getUint8(b + 18); // opacity byte = LAYER id |
| 66 | + } |
| 67 | + const ioff = voff + vertCount * 21; |
| 68 | + const index = new Uint32Array(triCount * 3); |
| 69 | + for (let t = 0; t < triCount; t++) { |
| 70 | + const b = ioff + t * 12; |
| 71 | + index[t * 3] = dv.getUint32(b, true); |
| 72 | + index[t * 3 + 1] = dv.getUint32(b + 4, true); |
| 73 | + index[t * 3 + 2] = dv.getUint32(b + 8, true); |
| 74 | + } |
| 75 | + return { vertCount, triCount, positions, normals, colors, layer, index }; |
| 76 | +} |
| 77 | + |
| 78 | +// Per-layer visibility via uEnabled[9] (indexed by the vertex layer id) + a global alpha |
| 79 | +// for the solid↔transparent switch. Phong smooth shade, two-sided. |
| 80 | +const VERT = ` |
| 81 | +attribute vec3 aNormal; |
| 82 | +attribute vec3 aColor; |
| 83 | +attribute float aLayer; |
| 84 | +varying vec3 vNormal; |
| 85 | +varying vec3 vColor; |
| 86 | +varying float vLayer; |
| 87 | +void main() { |
| 88 | + vNormal = aNormal; |
| 89 | + vColor = aColor; |
| 90 | + vLayer = aLayer; |
| 91 | + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); |
| 92 | +}`; |
| 93 | +const FRAG = ` |
| 94 | +precision mediump float; |
| 95 | +uniform float uEnabled[9]; |
| 96 | +uniform float uAlpha; |
| 97 | +varying vec3 vNormal; |
| 98 | +varying vec3 vColor; |
| 99 | +varying float vLayer; |
| 100 | +void main() { |
| 101 | + int li = int(vLayer + 0.5); |
| 102 | + if (li < 0 || li > 8 || uEnabled[li] < 0.5) discard; // layer toggled off |
| 103 | + vec3 n = normalize(vNormal); |
| 104 | + if (!gl_FrontFacing) n = -n; // two-sided |
| 105 | + const vec3 L = vec3(-0.401, 0.783, 0.476); |
| 106 | + float ndl = max(dot(n, L), 0.0); |
| 107 | + float hemi = 0.34 + 0.20 * (n.y * 0.5 + 0.5); |
| 108 | + float fill = 0.12 * (-n.x * 0.5 + 0.5); |
| 109 | + float shade = min(hemi + fill + 0.92 * ndl, 1.3); |
| 110 | + gl_FragColor = vec4(vColor * shade, uAlpha); |
| 111 | +}`; |
| 112 | + |
| 113 | +interface RenderState { |
| 114 | + enabled: Float32Array; // length 9, indexed by layer id |
| 115 | + alpha: number; |
| 116 | + transparent: boolean; |
| 117 | +} |
| 118 | + |
| 119 | +function mount(container: HTMLDivElement, mesh: Mesh, st: RenderState, onStats: (s: { fps: number }) => void): () => void { |
| 120 | + let w = container.clientWidth || window.innerWidth; |
| 121 | + let h = container.clientHeight || window.innerHeight; |
| 122 | + |
| 123 | + const scene = new THREE.Scene(); |
| 124 | + scene.background = new THREE.Color(PAGE_BG); |
| 125 | + const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100); |
| 126 | + camera.position.set(0, 0.05, 3.0); |
| 127 | + const renderer = new THREE.WebGLRenderer({ antialias: true }); |
| 128 | + renderer.setSize(w, h); |
| 129 | + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| 130 | + container.appendChild(renderer.domElement); |
| 131 | + |
| 132 | + const geom = new THREE.BufferGeometry(); |
| 133 | + geom.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3)); |
| 134 | + geom.setAttribute('aNormal', new THREE.BufferAttribute(mesh.normals, 3)); |
| 135 | + geom.setAttribute('aColor', new THREE.BufferAttribute(mesh.colors, 3, true)); |
| 136 | + geom.setAttribute('aLayer', new THREE.BufferAttribute(mesh.layer, 1)); |
| 137 | + geom.setIndex(new THREE.BufferAttribute(mesh.index, 1)); |
| 138 | + const mat = new THREE.ShaderMaterial({ |
| 139 | + vertexShader: VERT, |
| 140 | + fragmentShader: FRAG, |
| 141 | + uniforms: { uEnabled: { value: st.enabled }, uAlpha: { value: st.alpha } }, |
| 142 | + side: THREE.DoubleSide, |
| 143 | + transparent: st.transparent, |
| 144 | + depthWrite: !st.transparent, |
| 145 | + }); |
| 146 | + const obj = new THREE.Mesh(geom, mat); |
| 147 | + scene.add(obj); |
| 148 | + |
| 149 | + const controls = new OrbitControls(camera, renderer.domElement); |
| 150 | + controls.enableDamping = true; |
| 151 | + controls.dampingFactor = 0.08; |
| 152 | + controls.autoRotate = true; |
| 153 | + controls.autoRotateSpeed = 0.6; |
| 154 | + controls.target.set(0, 0, 0); |
| 155 | + controls.minDistance = 0.6; |
| 156 | + controls.maxDistance = 12; |
| 157 | + |
| 158 | + let raf = 0; |
| 159 | + let ema = 16.6; |
| 160 | + let last = performance.now(); |
| 161 | + let sinceStat = 0; |
| 162 | + let wasTransparent = st.transparent; |
| 163 | + const tick = () => { |
| 164 | + raf = requestAnimationFrame(tick); |
| 165 | + const now = performance.now(); |
| 166 | + ema = ema * 0.9 + (now - last) * 0.1; |
| 167 | + last = now; |
| 168 | + const pr = ema > 30 ? 1 : Math.min(window.devicePixelRatio, 2); |
| 169 | + if (renderer.getPixelRatio() !== pr) renderer.setPixelRatio(pr); |
| 170 | + mat.uniforms.uEnabled.value = st.enabled; |
| 171 | + mat.uniforms.uAlpha.value = st.alpha; |
| 172 | + if (st.transparent !== wasTransparent) { |
| 173 | + mat.transparent = st.transparent; |
| 174 | + mat.depthWrite = !st.transparent; |
| 175 | + mat.needsUpdate = true; |
| 176 | + wasTransparent = st.transparent; |
| 177 | + } |
| 178 | + controls.update(); |
| 179 | + renderer.render(scene, camera); |
| 180 | + if (++sinceStat >= 20) { |
| 181 | + sinceStat = 0; |
| 182 | + onStats({ fps: Math.round(1000 / Math.max(ema, 1)) }); |
| 183 | + } |
| 184 | + }; |
| 185 | + tick(); |
| 186 | + |
| 187 | + const onResize = () => { |
| 188 | + w = container.clientWidth || window.innerWidth; |
| 189 | + h = container.clientHeight || window.innerHeight; |
| 190 | + camera.aspect = w / h; |
| 191 | + camera.updateProjectionMatrix(); |
| 192 | + renderer.setSize(w, h); |
| 193 | + }; |
| 194 | + const ro = new ResizeObserver(onResize); |
| 195 | + ro.observe(container); |
| 196 | + |
| 197 | + return () => { |
| 198 | + cancelAnimationFrame(raf); |
| 199 | + ro.disconnect(); |
| 200 | + controls.dispose(); |
| 201 | + geom.dispose(); |
| 202 | + mat.dispose(); |
| 203 | + renderer.dispose(); |
| 204 | + if (renderer.domElement.parentNode === container) container.removeChild(renderer.domElement); |
| 205 | + }; |
| 206 | +} |
| 207 | + |
| 208 | +export function FmaBody() { |
| 209 | + const ref = useRef<HTMLDivElement>(null); |
| 210 | + const [mesh, setMesh] = useState<Mesh | null>(null); |
| 211 | + const [error, setError] = useState<string | null>(null); |
| 212 | + const [stats, setStats] = useState<{ fps: number } | null>(null); |
| 213 | + // skin off by default (so the anatomy shows); everything else on. |
| 214 | + const [on, setOn] = useState<Record<number, boolean>>({ 1: false, 2: true, 3: true, 4: true, 5: true, 6: true, 7: true, 8: true }); |
| 215 | + const [transparent, setTransparent] = useState(false); |
| 216 | + // shared, mutation-friendly render state (read every frame by the GL loop). |
| 217 | + const stRef = useRef<RenderState>({ enabled: new Float32Array([0, 0, 1, 1, 1, 1, 1, 1, 1]), alpha: 1, transparent: false }); |
| 218 | + |
| 219 | + // push React state → the GL render state each change. |
| 220 | + useEffect(() => { |
| 221 | + const e = new Float32Array(9); |
| 222 | + for (let i = 1; i <= 8; i++) e[i] = on[i] ? 1 : 0; |
| 223 | + stRef.current.enabled = e; |
| 224 | + stRef.current.transparent = transparent; |
| 225 | + stRef.current.alpha = transparent ? 0.42 : 1.0; |
| 226 | + }, [on, transparent]); |
| 227 | + |
| 228 | + useEffect(() => { |
| 229 | + let cancelled = false; |
| 230 | + fetch('/fma_body.mesh') |
| 231 | + .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status} fetching fma_body.mesh`); return r.arrayBuffer(); }) |
| 232 | + .then((buf) => { if (!cancelled) setMesh(decodeSpm1(buf)); }) |
| 233 | + .catch((e) => { if (!cancelled) setError(String(e)); }); |
| 234 | + return () => { cancelled = true; }; |
| 235 | + }, []); |
| 236 | + |
| 237 | + useEffect(() => { |
| 238 | + const container = ref.current; |
| 239 | + if (!container || !mesh) return; |
| 240 | + return mount(container, mesh, stRef.current, setStats); |
| 241 | + }, [mesh]); |
| 242 | + |
| 243 | + const btn = (active: boolean): React.CSSProperties => ({ |
| 244 | + padding: '5px 11px', |
| 245 | + borderRadius: 6, |
| 246 | + border: `1px solid ${active ? '#5a7fa8' : '#2a3242'}`, |
| 247 | + background: active ? '#16202e' : '#0e1219', |
| 248 | + color: active ? '#cdd9e5' : '#6a7686', |
| 249 | + font: '12px ui-monospace, monospace', |
| 250 | + cursor: 'pointer', |
| 251 | + }); |
| 252 | + |
| 253 | + return ( |
| 254 | + <div style={{ position: 'fixed', inset: 0, background: '#0a0e17', overflow: 'hidden' }}> |
| 255 | + <div ref={ref} style={{ position: 'absolute', inset: 0 }} /> |
| 256 | + |
| 257 | + <div style={{ position: 'absolute', top: 12, left: 16, color: '#cdd9e5', font: '13px ui-monospace, monospace', pointerEvents: 'none' }}> |
| 258 | + <div style={{ fontSize: 15, color: '#fff' }}>FMA body · (place:tissue) layers</div> |
| 259 | + <div style={{ opacity: 0.7 }}> |
| 260 | + {mesh ? `${mesh.triCount.toLocaleString()} triangles · drag to orbit` : error ? '' : 'loading fma_body.mesh…'} |
| 261 | + </div> |
| 262 | + {stats && <div style={{ opacity: 0.5, marginTop: 2 }}>{stats.fps} fps · solid surface, layer-gated by the converged key</div>} |
| 263 | + </div> |
| 264 | + |
| 265 | + {/* layer toggles + solid/transparent */} |
| 266 | + <div style={{ position: 'absolute', top: 12, right: 16, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}> |
| 267 | + <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 360 }}> |
| 268 | + {LAYERS.map((l) => ( |
| 269 | + <button key={l.id} style={btn(on[l.id])} onClick={() => setOn((p) => ({ ...p, [l.id]: !p[l.id] }))}> |
| 270 | + <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 4, background: l.color, marginRight: 6, verticalAlign: 'middle' }} /> |
| 271 | + {l.name} |
| 272 | + </button> |
| 273 | + ))} |
| 274 | + </div> |
| 275 | + <button style={btn(transparent)} onClick={() => setTransparent((v) => !v)}> |
| 276 | + {transparent ? 'transparent' : 'solid'} ⇄ |
| 277 | + </button> |
| 278 | + <div style={{ display: 'flex', gap: 14, font: '12px ui-monospace, monospace', marginTop: 2 }}> |
| 279 | + <a href="/torso-live" style={{ color: '#7fa6c4', textDecoration: 'none' }}>/torso-live →</a> |
| 280 | + <a href="/fma" style={{ color: '#7fa6c4', textDecoration: 'none' }}>/fma graph →</a> |
| 281 | + </div> |
| 282 | + </div> |
| 283 | + |
| 284 | + {error && ( |
| 285 | + <div style={{ position: 'absolute', top: '46%', width: '100%', textAlign: 'center', color: '#ff8095', font: '13px ui-monospace, monospace' }}> |
| 286 | + {error} |
| 287 | + <div style={{ opacity: 0.7, marginTop: 6 }}> |
| 288 | + bake: <code>cargo run -p fma --bin cockpit_bake -- <parts> <element_parts> <converged.tsv> cockpit/public/fma_body.mesh</code> |
| 289 | + </div> |
| 290 | + </div> |
| 291 | + )} |
| 292 | + |
| 293 | + <div style={{ position: 'absolute', bottom: 10, left: 16, color: '#5a6b7e', font: '10px ui-monospace, monospace', maxWidth: '70%', pointerEvents: 'none' }}> |
| 294 | + BodyParts3D, (c) The Database Center for Life Science, licensed under CC Attribution 4.0 International |
| 295 | + </div> |
| 296 | + </div> |
| 297 | + ); |
| 298 | +} |
0 commit comments