diff --git a/cockpit/public/fma_body.mesh b/cockpit/public/fma_body.mesh new file mode 100644 index 000000000..61bc8e122 Binary files /dev/null and b/cockpit/public/fma_body.mesh differ diff --git a/cockpit/public/fma_body.mesh.manifest.json b/cockpit/public/fma_body.mesh.manifest.json new file mode 100644 index 000000000..f761f65ac --- /dev/null +++ b/cockpit/public/fma_body.mesh.manifest.json @@ -0,0 +1 @@ +{"source":"BodyParts3D 4.0 (DBCLS) is_a OBJ, vertex-cluster decimated","attribution":"BodyParts3D, (c) The Database Center for Life Science, CC-BY 4.0 / CC-BY-SA 2.1 JP","format":"SPM1; opacity byte = LAYER id (1 skin·2 muscle·3 organ·4 skeleton·5 vessel·6 nerve·7 connective·8 other)","verts":329478,"tris":744742,"cell_mm":3.6,"layers":{"vessel":496,"organ":270,"skeleton":231,"other":202,"muscle":57,"skin":2}} \ No newline at end of file diff --git a/cockpit/src/FmaBody.tsx b/cockpit/src/FmaBody.tsx new file mode 100644 index 000000000..878867638 --- /dev/null +++ b/cockpit/src/FmaBody.tsx @@ -0,0 +1,298 @@ +// /fma-body — MY full-body FMA viewer (additive to the other session's /torso*). +// +// Renders cockpit/public/fma_body.mesh (baked by `fma`'s cockpit_bake) — the same SPM1 +// indexed triangle surface the cockpit already decodes, but the per-vertex `opacity` +// byte carries a clean LAYER id (1 skin · 2 muscle · 3 organ · 4 skeleton · 5 vessel · +// 6 nerve · 7 connective · 8 other). So the viewer can TOGGLE each layer with a button, +// and switch the whole body between SOLID and TRANSPARENT. Color is the converged +// `tissue` byte (is_a); geometry is real BodyParts3D, vertex-cluster decimated. +// +// This does not touch /torso, /torso-live, /torso-splat, /torso-map (#57/#58). +// +// Geometry/data: BodyParts3D, (c) The Database Center for Life Science, CC-BY 4.0. +import { useEffect, useRef, useState } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +const PAGE_BG = 0x0a0e17; + +// layer id (opacity byte) → label + swatch. id 0 unused; index = id. +const LAYERS: { id: number; name: string; color: string }[] = [ + { 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: 'nerve', color: '#ebd152' }, + { id: 7, name: 'connective', color: '#e0dbcc' }, + { id: 8, name: 'other', color: '#9696a0' }, +]; + +interface Mesh { + vertCount: number; + triCount: number; + positions: Float32Array; + normals: Float32Array; + colors: Uint8Array; + layer: Float32Array; // per-vertex layer id (from the opacity byte) + index: Uint32Array; +} + +// SPM1 (little-endian): header 40 B | vert 21 B [pos 3f|normal 3i8|rgb 3u8|opacity u8| +// node_row u16] | index 12 B. Orientation (x,y,z)->(-x,z,y): proper rotation (det +1), +// head-up in three.js Y-up — identical to TorsoMesh so both viewers agree. +function decodeSpm1(buf: ArrayBuffer): Mesh { + const dv = new DataView(buf); + const magic = String.fromCharCode(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3)); + if (magic !== 'SPM1') throw new Error(`bad magic "${magic}" (expected SPM1)`); + const vertCount = dv.getUint32(4, true); + const triCount = dv.getUint32(8, true); + const voff = 40; + const positions = new Float32Array(vertCount * 3); + const normals = new Float32Array(vertCount * 3); + const colors = new Uint8Array(vertCount * 3); + const layer = new Float32Array(vertCount); + for (let i = 0; i < vertCount; i++) { + const b = voff + i * 21; + const x = dv.getFloat32(b, true), y = dv.getFloat32(b + 4, true), z = dv.getFloat32(b + 8, true); + positions[i * 3] = -x; positions[i * 3 + 1] = z; positions[i * 3 + 2] = y; + normals[i * 3] = -dv.getInt8(b + 12) / 127; + normals[i * 3 + 1] = dv.getInt8(b + 14) / 127; + normals[i * 3 + 2] = dv.getInt8(b + 13) / 127; + colors[i * 3] = dv.getUint8(b + 15); + colors[i * 3 + 1] = dv.getUint8(b + 16); + colors[i * 3 + 2] = dv.getUint8(b + 17); + layer[i] = dv.getUint8(b + 18); // opacity byte = LAYER id + } + const ioff = voff + vertCount * 21; + const index = new Uint32Array(triCount * 3); + for (let t = 0; t < triCount; t++) { + const b = ioff + t * 12; + index[t * 3] = dv.getUint32(b, true); + index[t * 3 + 1] = dv.getUint32(b + 4, true); + index[t * 3 + 2] = dv.getUint32(b + 8, true); + } + return { vertCount, triCount, positions, normals, colors, layer, index }; +} + +// Per-layer visibility via uEnabled[9] (indexed by the vertex layer id) + a global alpha +// for the solid↔transparent switch. Phong smooth shade, two-sided. +const VERT = ` +attribute vec3 aNormal; +attribute vec3 aColor; +attribute float aLayer; +varying vec3 vNormal; +varying vec3 vColor; +varying float vLayer; +void main() { + vNormal = aNormal; + vColor = aColor; + vLayer = aLayer; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +}`; +const FRAG = ` +precision mediump float; +uniform float uEnabled[9]; +uniform float uAlpha; +varying vec3 vNormal; +varying vec3 vColor; +varying float vLayer; +void main() { + int li = int(vLayer + 0.5); + if (li < 0 || li > 8 || uEnabled[li] < 0.5) discard; // layer toggled off + vec3 n = normalize(vNormal); + if (!gl_FrontFacing) n = -n; // two-sided + const vec3 L = vec3(-0.401, 0.783, 0.476); + float ndl = max(dot(n, L), 0.0); + float hemi = 0.34 + 0.20 * (n.y * 0.5 + 0.5); + float fill = 0.12 * (-n.x * 0.5 + 0.5); + float shade = min(hemi + fill + 0.92 * ndl, 1.3); + gl_FragColor = vec4(vColor * shade, uAlpha); +}`; + +interface RenderState { + enabled: Float32Array; // length 9, indexed by layer id + alpha: number; + transparent: boolean; +} + +function mount(container: HTMLDivElement, mesh: Mesh, st: RenderState, onStats: (s: { fps: number }) => void): () => void { + let w = container.clientWidth || window.innerWidth; + let h = container.clientHeight || window.innerHeight; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(PAGE_BG); + const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100); + camera.position.set(0, 0.05, 3.0); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(w, h); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + container.appendChild(renderer.domElement); + + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3)); + geom.setAttribute('aNormal', new THREE.BufferAttribute(mesh.normals, 3)); + geom.setAttribute('aColor', new THREE.BufferAttribute(mesh.colors, 3, true)); + geom.setAttribute('aLayer', new THREE.BufferAttribute(mesh.layer, 1)); + geom.setIndex(new THREE.BufferAttribute(mesh.index, 1)); + const mat = new THREE.ShaderMaterial({ + vertexShader: VERT, + fragmentShader: FRAG, + uniforms: { uEnabled: { value: st.enabled }, uAlpha: { value: st.alpha } }, + side: THREE.DoubleSide, + transparent: st.transparent, + depthWrite: !st.transparent, + }); + const obj = new THREE.Mesh(geom, mat); + scene.add(obj); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.autoRotate = true; + controls.autoRotateSpeed = 0.6; + controls.target.set(0, 0, 0); + controls.minDistance = 0.6; + controls.maxDistance = 12; + + let raf = 0; + let ema = 16.6; + let last = performance.now(); + let sinceStat = 0; + let wasTransparent = st.transparent; + const tick = () => { + raf = requestAnimationFrame(tick); + const now = performance.now(); + ema = ema * 0.9 + (now - last) * 0.1; + last = now; + const pr = ema > 30 ? 1 : Math.min(window.devicePixelRatio, 2); + if (renderer.getPixelRatio() !== pr) renderer.setPixelRatio(pr); + mat.uniforms.uEnabled.value = st.enabled; + mat.uniforms.uAlpha.value = st.alpha; + if (st.transparent !== wasTransparent) { + mat.transparent = st.transparent; + mat.depthWrite = !st.transparent; + mat.needsUpdate = true; + wasTransparent = st.transparent; + } + controls.update(); + renderer.render(scene, camera); + if (++sinceStat >= 20) { + sinceStat = 0; + onStats({ fps: Math.round(1000 / Math.max(ema, 1)) }); + } + }; + tick(); + + const onResize = () => { + w = container.clientWidth || window.innerWidth; + h = container.clientHeight || window.innerHeight; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + }; + const ro = new ResizeObserver(onResize); + ro.observe(container); + + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + controls.dispose(); + geom.dispose(); + mat.dispose(); + renderer.dispose(); + if (renderer.domElement.parentNode === container) container.removeChild(renderer.domElement); + }; +} + +export function FmaBody() { + const ref = useRef(null); + const [mesh, setMesh] = useState(null); + const [error, setError] = useState(null); + const [stats, setStats] = useState<{ fps: number } | null>(null); + // skin off by default (so the anatomy shows); everything else on. + const [on, setOn] = useState>({ 1: false, 2: true, 3: true, 4: true, 5: true, 6: true, 7: true, 8: true }); + const [transparent, setTransparent] = useState(false); + // shared, mutation-friendly render state (read every frame by the GL loop). + const stRef = useRef({ enabled: new Float32Array([0, 0, 1, 1, 1, 1, 1, 1, 1]), alpha: 1, transparent: false }); + + // push React state → the GL render state each change. + useEffect(() => { + const e = new Float32Array(9); + for (let i = 1; i <= 8; i++) e[i] = on[i] ? 1 : 0; + stRef.current.enabled = e; + stRef.current.transparent = transparent; + stRef.current.alpha = transparent ? 0.42 : 1.0; + }, [on, transparent]); + + useEffect(() => { + let cancelled = false; + fetch('/fma_body.mesh') + .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status} fetching fma_body.mesh`); return r.arrayBuffer(); }) + .then((buf) => { if (!cancelled) setMesh(decodeSpm1(buf)); }) + .catch((e) => { if (!cancelled) setError(String(e)); }); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + const container = ref.current; + if (!container || !mesh) return; + return mount(container, mesh, stRef.current, setStats); + }, [mesh]); + + const btn = (active: boolean): React.CSSProperties => ({ + padding: '5px 11px', + borderRadius: 6, + border: `1px solid ${active ? '#5a7fa8' : '#2a3242'}`, + background: active ? '#16202e' : '#0e1219', + color: active ? '#cdd9e5' : '#6a7686', + font: '12px ui-monospace, monospace', + cursor: 'pointer', + }); + + return ( +
+
+ +
+
FMA body · (place:tissue) layers
+
+ {mesh ? `${mesh.triCount.toLocaleString()} triangles · drag to orbit` : error ? '' : 'loading fma_body.mesh…'} +
+ {stats &&
{stats.fps} fps · solid surface, layer-gated by the converged key
} +
+ + {/* layer toggles + solid/transparent */} +
+
+ {LAYERS.map((l) => ( + + ))} +
+ + +
+ + {error && ( +
+ {error} +
+ bake: cargo run -p fma --bin cockpit_bake -- <parts> <element_parts> <converged.tsv> cockpit/public/fma_body.mesh +
+
+ )} + +
+ BodyParts3D, (c) The Database Center for Life Science, licensed under CC Attribution 4.0 International +
+
+ ); +} diff --git a/cockpit/src/main.tsx b/cockpit/src/main.tsx index 8ab6c39e4..6646256ba 100644 --- a/cockpit/src/main.tsx +++ b/cockpit/src/main.tsx @@ -12,6 +12,7 @@ import { TorsoMesh } from './TorsoMesh'; import { TorsoSplat } from './TorsoSplat'; import { TorsoRender } from './TorsoRender'; import { TorsoMap } from './TorsoMap'; +import { FmaBody } from './FmaBody'; import { ReasoningPage } from './ReasoningPage'; import { ErrorBoundary } from './components/ErrorBoundary'; import './styles/cockpit.css'; @@ -92,6 +93,10 @@ createRoot(document.getElementById('root')!).render( {/* FMA torso map — splat AS the GUID/value-tenant SoA: click a gaussian → its FMA node (O(1) switch into the node SoA) → label + partonomy ↔ graph */} } /> + {/* MY full-body FMA viewer — solid triangle surface gated per (place:tissue) + LAYER (skin/muscle/organ/skeleton/vessel/nerve buttons) + solid↔transparent. + Additive; reads cockpit/public/fma_body.mesh; never touches /torso* (#57/#58). */} + } /> {/* The Palantir JSON-graph cockpit (221 aiwar nodes) stays reachable at /palantir and as the catch-all for its own sub-routes. */} } /> diff --git a/fma/README.md b/fma/README.md index 74a98f615..376b24758 100644 --- a/fma/README.md +++ b/fma/README.md @@ -39,7 +39,10 @@ BodyParts3D meshes ──tissue (is_a tree)──► triangle rasterizer (z-buff | `turntable` | parallel 360° prerender, N frames → `fma_frames/frame_NNNN.png` | | `serve` | dep-free std HTTP server, all routes under `/FMA` (binds `0.0.0.0:$PORT`) | | `guid` | mint the part_of GUID per FMA node → `guid/guid_manifest.tsv`, `guid/fj_guid.tsv` | -| `converge` | **v3**: cascading-HHTL `(part_of:is_a)` **canonical NodeGuid** → `guid/guid_converged.tsv` | +| `converge` | **v3**: cascading-HHTL `(place:tissue)` **canonical NodeGuid** + `connected_to` edges → `guid/{guid_converged,nodes,edges}.tsv` | +| `graph` | **v3 render**: SOLID triangle surface colored by `tissue`, with a GUID **prefix** that selects the subtree (`graph … 00000a02` = skeleton) → `graph/graph_.png` | +| `cockpit_bake` | bake the full body → `cockpit/public/fma_body.mesh` (SPM1, opacity = layer id) for the **`/fma-body`** cockpit page (layer toggles + transparency) | +| `soa_scan` | 1M-row SoA scalability PoC: key-only **prefix-route** scan vs full **value-decode** scan (~90× at 1M — the canon's *"prerender with zero value decode"*) | | `anchor` | compression study: cascade vs raw-cartesian vs Cartesian-Skeleton hybrid | ## Routes (`serve`) @@ -65,26 +68,52 @@ that converges them **without replacing either** — disjoint files, disjoint ro |---|---|---|---| | **v1** (other session) | FMA **heart** graph, canonical `NodeGuid`, served at **`/fma`** | `is_a` (taxonomy) | `crates/osint-bake/.../fma.rs`, `cockpit/.../FmaGraph.tsx` | | **v2** (this crate, `guid`) | **full-body** part_of FNV cascade + 3D mesh at **`/FMA`** | `part_of` (mereology) | `fma/src/bin/guid.rs` | -| **v3** (this crate, `converge`) | cascading-HHTL **canonical `NodeGuid`** | **`(part_of:is_a)`** | `fma/src/bin/converge.rs` | - -**v3 is the convergence.** Each 8:8 HHTL tier packs both axes — `high = part_of` -(mixin / family / basin: *where*), `low = is_a` (identity / type: *what*) — -cascading HEEL→HIP→TWIG so the high-byte chain prefix-routes the body partonomy -and the low-byte chain prefix-routes the type taxonomy: **both hierarchies in one -key, routable on either axis at every level.** The 16-byte layout is byte-identical -to `lance_graph_contract::canonical_node::NodeGuid` (OGAR canon, locked 2026-06-13: -`classid·HEEL·HIP·TWIG·family·identity`), and `classid` uses the same `0x0A0x` -`ConceptDomain::Anatomy` space as v1's bake (`0x0A01` soft tissue, `0x0A02` -skeleton) — so a heart node from v1 and a heart node from v3 share `classid`. v3 -is dep-free (emits the canonical bytes directly) so this crate stays standalone. +| **v3** (this crate, `converge`+`graph`) | cascading-HHTL **canonical `NodeGuid`** + `connected_to` + key-driven render | **`(place:tissue)`** | `fma/src/bin/{converge,graph}.rs` | + +**v3 is the convergence** — and it now converges the *render*, not just the address. +Each 8:8 HHTL tier packs both axes — `high = place` (*where*), `low = tissue` +(*what* = is_a) — cascading HEEL→HIP→TWIG so the high-byte chain prefix-routes the +body and the low-byte chain prefix-routes the type taxonomy: **both hierarchies in +one key.** The 16-byte layout is byte-identical to +`lance_graph_contract::canonical_node::NodeGuid` (OGAR canon, 2026-06-13: +`classid·HEEL·HIP·TWIG·family·identity`); `classid` uses the same `0x0A` +`ConceptDomain::Anatomy` space as v1's bake (`0x0A01` soft, `0x0A02` skeleton). v3 +is dep-free, so this crate stays standalone. + +Three things make `place` and the render converge (`classid`-dispatched, OGAR `HhtlMode`): + +1. **Located skeleton** — for `0x0A02` bones, the `place` bytes are the **Morton + spatial cell** of the bone centroid (the exact anchor *is* the key — my `anchor.rs` + hybrid). For `0x0A01` soft tissue, `place` is the `part_of` rank (Cascade), inheriting + position from its `part_of` basin's skeleton anchor. +2. **`connected_to`** — the canonical EdgeBlock (12 in-family + 4 out): `part_of` + siblings are the in-family adjacency (the aortic segments / heart chambers that + physically connect), the `is_a` parent is the out-of-family type link. +3. **The renderer reads the key** — `graph` places each node by `place`, colors it by + `tissue` (is_a), and wires it by `connected_to`. No mesh needed: the address *is* the + render. ```text -aorta subtree (v3): classid HEEL HIP TWIG family·identity - FMA3736 ascending 00000a01-0901-0702-0e02-000105·880ff7 part_of:ascending aorta is_a:ascending aorta - FMA3789 abdominal 00000a01-0901-0702-0e02-000305·aea610 part_of:abdominal aorta is_a:abdominal aorta - ^ shared classid + HEEL/HIP/TWIG = same region AND same vessel type; tail disambiguates +Located skeleton (thoracic vertebrae T9/T10/T11, classid 0x0A02, mode Located): + FMA10014 00000a02-ce01-fe02-7b02-… ↔ T10,T11,T12,… shared Morton HEEL ce = same spatial octant + FMA10059 00000a02-ce01-d602-eb02-… ↔ T9,T10,T12,… HIP/TWIG descend as the centroid descends (z 1164→1107) +Cascade soft tissue (aortic segments, 0x0A01, mode Cascade): + FMA3736 ascending 00000a01-0901-0702-0e02-… ↔ arch, descending part_of siblings = the connected segments ``` +![FMA skeleton, selected by GUID prefix](docs/graph_skeleton.png) + +*`graph … 00000a02` — the canonical key SELECTS the geometry: the `0x0A02` (skeleton) +classid prefix renders just the bones as solid triangles (922K), colored by the `tissue` +byte (is_a). The address is the render; the prefix is the query. `graph all` / `tissues` +/ `vessel` render the whole body / inner tissues / vascular tree.* + +The same converged mesh drives an interactive cockpit page (**`/fma-body`**, additive to +`/torso*`): `cockpit_bake` writes `cockpit/public/fma_body.mesh` (SPM1, the opacity byte = +a layer id), and `cockpit/src/FmaBody.tsx` renders the solid `THREE.Mesh` with **per-layer +toggle buttons** (skin / muscle / organ / skeleton / vessel / nerve) + a **solid↔transparent** +switch — each layer gated by the converged `(place:tissue)` key. + ## Run ```sh diff --git a/fma/docs/graph_skeleton.png b/fma/docs/graph_skeleton.png new file mode 100644 index 000000000..e19785e62 Binary files /dev/null and b/fma/docs/graph_skeleton.png differ diff --git a/fma/src/bin/cockpit_bake.rs b/fma/src/bin/cockpit_bake.rs new file mode 100644 index 000000000..705314139 --- /dev/null +++ b/fma/src/bin/cockpit_bake.rs @@ -0,0 +1,292 @@ +// cockpit_bake.rs — bake the full-body FMA mesh for MY cockpit page (/fma-body), +// additive to the other session's /torso (their torso.mesh is untouched). +// +// Emits an SPM1 indexed triangle mesh (the SAME wire the cockpit already decodes), but +// the per-vertex `opacity` byte carries a clean LAYER id (skin/muscle/organ/skeleton/ +// vessel/nerve/…) instead of a continuous alpha — so the viewer can toggle each layer +// exactly with a button. Color is the converged `tissue` byte (is_a); geometry is the +// BodyParts3D is_a OBJ set, vertex-cluster decimated (the curve-ruler smoothing) the +// same way bake_torso_mesh.py does it. +// +// SPM1 (little-endian), byte-identical to bake_torso_mesh.py: +// header 40 B: "SPM1" | vert_count u32 | tri_count u32 | node_count u32 | bbox_min 3f | bbox_max 3f +// vertex 21 B: pos 3f | normal 3i8 | rgb 3u8 | opacity(=LAYER id) u8 | node_row u16 +// index 12 B: 3x u32 +// Positions normalized to [-1,1]; orientation (x,-z,y) + i8-normal dequant happen in +// the renderer (FmaBody.tsx), same as torso.mesh. +// +// usage: cockpit_bake [cell_mm] + +use std::collections::HashMap; +use std::fs::File; +use std::io::Write; + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} +fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn normalize(a: [f32; 3]) -> [f32; 3] { + let n = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt(); + if n < 1e-9 { [0.0, 0.0, 1.0] } else { [a[0] / n, a[1] / n, a[2] / n] } +} +fn parse_obj(text: &str) -> (Vec<[f32; 3]>, Vec<[usize; 3]>) { + let (mut verts, mut tris) = (Vec::new(), Vec::new()); + for line in text.lines() { + let mut it = line.split_whitespace(); + match it.next() { + Some("v") => { + let c: Vec = it.take(3).filter_map(|t| t.parse().ok()).collect(); + if c.len() == 3 { + verts.push([c[0], c[1], c[2]]); + } + } + Some("f") => { + let idx: Vec = it + .map(|t| t.split(['/', ' ']).next().unwrap_or("")) + .filter_map(|s| s.parse::().ok()) + .map(|i| if i < 0 { (verts.len() as i32 + i) as usize } else { (i - 1) as usize }) + .collect(); + for k in 1..idx.len().saturating_sub(1) { + tris.push([idx[0], idx[k], idx[k + 1]]); + } + } + _ => {} + } + } + (verts, tris) +} + +type DecimatedMesh = (Vec<[f32; 3]>, Vec<[f32; 3]>, Vec<[usize; 3]>); + +// Vertex clustering on a global grid (cell = cell_mm): collapse verts in a cell to one +// representative (mean position + mean normal), remap faces, drop degenerates. Averaging +// the normals per cell keeps the surface smooth (port of bake_torso_mesh.py). +fn cluster_decimate(verts: &[[f32; 3]], normals: &[[f32; 3]], faces: &[[usize; 3]], inv_h: f32, o: [f32; 3]) -> DecimatedMesh { + let mut cell_of: HashMap<(i32, i32, i32), usize> = HashMap::new(); + let mut acc: Vec<([f64; 6], u32)> = Vec::new(); + let mut remap = vec![0usize; verts.len()]; + for (i, v) in verts.iter().enumerate() { + let key = (((v[0] - o[0]) * inv_h) as i32, ((v[1] - o[1]) * inv_h) as i32, ((v[2] - o[2]) * inv_h) as i32); + let n = normals[i]; + let j = *cell_of.entry(key).or_insert_with(|| { + acc.push(([0.0; 6], 0)); + acc.len() - 1 + }); + let a = &mut acc[j]; + a.0[0] += v[0] as f64; + a.0[1] += v[1] as f64; + a.0[2] += v[2] as f64; + a.0[3] += n[0] as f64; + a.0[4] += n[1] as f64; + a.0[5] += n[2] as f64; + a.1 += 1; + remap[i] = j; + } + let (mut nv, mut nn) = (Vec::with_capacity(acc.len()), Vec::with_capacity(acc.len())); + for (s, c) in &acc { + let c = *c as f64; + nv.push([(s[0] / c) as f32, (s[1] / c) as f32, (s[2] / c) as f32]); + let nl = (s[3] * s[3] + s[4] * s[4] + s[5] * s[5]).sqrt().max(1.0); + nn.push([(s[3] / nl) as f32, (s[4] / nl) as f32, (s[5] / nl) as f32]); + } + let mut nf = Vec::new(); + for f in faces { + let (ra, rb, rc) = (remap[f[0]], remap[f[1]], remap[f[2]]); + if ra != rb && rb != rc && ra != rc { + nf.push([ra, rb, rc]); + } + } + (nv, nn, nf) +} + +// converged tissue (is_a low byte) → (layer id [opacity byte], layer name, rgb). +// The layer id is the exact gating key the viewer's buttons toggle. +fn layer_of(tissue: &str) -> (u8, &'static str, [u8; 3]) { + match tissue { + "skin" => (1, "skin", [219, 168, 138]), + "muscle" => (2, "muscle", [189, 92, 87]), + "organ" => (3, "organ", [204, 148, 132]), + "bone" => (4, "skeleton", [235, 224, 199]), + "cartilage" => (4, "skeleton", [159, 184, 217]), + "vessel" => (5, "vessel", [204, 56, 56]), + "nerve" => (6, "nerve", [235, 209, 82]), + "ligament" | "tendon" => (7, "connective", [224, 219, 204]), + _ => (8, "other", [150, 150, 160]), + } +} + +fn main() { + let a: Vec = std::env::args().collect(); + let parts_dir = a.get(1).cloned().unwrap_or_else(|| "data/isa_parts/isa_BP3D_4.0_obj_99".into()); + let elem_path = a.get(2).cloned().unwrap_or_else(|| "data/combined_element_parts.txt".into()); + let conv_path = a.get(3).cloned().unwrap_or_else(|| "guid/guid_converged.tsv".into()); + let out_path = a.get(4).cloned().unwrap_or_else(|| "cockpit/public/fma_body.mesh".into()); + let cell_mm: f32 = a.get(5).and_then(|s| s.parse().ok()).unwrap_or(3.6); + + // converged key: FMA → (tissue, part_of depth, row). + let mut fma_tissue: HashMap = HashMap::new(); + let mut fma_depth: HashMap = HashMap::new(); + let mut fma_row: HashMap = HashMap::new(); + for (i, line) in std::fs::read_to_string(&conv_path).unwrap_or_default().lines().enumerate() { + if i == 0 { + continue; + } + let f: Vec<&str> = line.split('\t').collect(); + if f.len() >= 7 { + fma_row.insert(f[0].into(), (fma_tissue.len() & 0xFFFF) as u16); + fma_tissue.insert(f[0].into(), f[4].into()); + fma_depth.insert(f[0].into(), f[6].matches(" / ").count()); + } + } + let mut fj_fma: HashMap> = HashMap::new(); + for (i, line) in std::fs::read_to_string(&elem_path).unwrap_or_default().lines().enumerate() { + if i == 0 { + continue; + } + let f: Vec<&str> = line.split('\t').collect(); + if f.len() >= 3 { + fj_fma.entry(f[2].into()).or_default().push(f[0].into()); + } + } + + // global accumulators + let mut pos: Vec<[f32; 3]> = Vec::new(); + let mut nrm: Vec<[f32; 3]> = Vec::new(); + let mut col: Vec<[u8; 3]> = Vec::new(); + let mut lay: Vec = Vec::new(); + let mut row: Vec = Vec::new(); + let mut tris: Vec<[u32; 3]> = Vec::new(); + let mut layer_hist: HashMap<&str, usize> = HashMap::new(); + let inv_h = 1.0 / cell_mm; + + let mut entries: Vec<_> = std::fs::read_dir(&parts_dir).expect("parts").filter_map(|e| e.ok()).map(|e| e.path()).collect(); + entries.sort(); + for path in &entries { + if path.extension().and_then(|s| s.to_str()) != Some("obj") { + continue; + } + let fj = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); + let Some(fma) = fj_fma.get(&fj).and_then(|v| v.iter().filter(|f| fma_tissue.contains_key(*f)).max_by_key(|f| fma_depth.get(*f).copied().unwrap_or(0))) else { continue }; + let tissue = fma_tissue[fma].as_str(); + let (layer_id, layer_name, rgb) = layer_of(tissue); + let r = fma_row.get(fma).copied().unwrap_or(0); + let Ok(text) = std::fs::read_to_string(path) else { continue }; + let (verts, faces) = parse_obj(&text); + if verts.is_empty() || faces.is_empty() { + continue; + } + // per-vertex normals from faces (smooth after clustering averages them) + let mut vn = vec![[0.0f32; 3]; verts.len()]; + for f in &faces { + if f[0] >= verts.len() || f[1] >= verts.len() || f[2] >= verts.len() { + continue; + } + let fnv = cross(sub(verts[f[1]], verts[f[0]]), sub(verts[f[2]], verts[f[0]])); + for &vi in f { + for k in 0..3 { + vn[vi][k] += fnv[k]; + } + } + } + for n in &mut vn { + *n = normalize(*n); + } + let o = [ + verts.iter().map(|v| v[0]).fold(f32::MAX, f32::min), + verts.iter().map(|v| v[1]).fold(f32::MAX, f32::min), + verts.iter().map(|v| v[2]).fold(f32::MAX, f32::min), + ]; + let (nv, nn, nf) = cluster_decimate(&verts, &vn, &faces, inv_h, o); + let base = pos.len() as u32; + for (v, n) in nv.iter().zip(nn.iter()) { + pos.push(*v); + nrm.push(*n); + col.push(rgb); + lay.push(layer_id); + row.push(r); + } + for f in &nf { + tris.push([base + f[0] as u32, base + f[1] as u32, base + f[2] as u32]); + } + *layer_hist.entry(layer_name).or_insert(0) += 1; + } + if pos.is_empty() { + eprintln!("[cockpit_bake] no geometry — check parts_dir/element_parts/converged.tsv"); + return; + } + + // normalize positions to [-1,1] (center + uniform scale), like bake_torso_mesh.py + let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); + for p in &pos { + for k in 0..3 { + lo[k] = lo[k].min(p[k]); + hi[k] = hi[k].max(p[k]); + } + } + let c = [(lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5, (lo[2] + hi[2]) * 0.5]; + let half = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]) * 0.5; + let inv = 1.0 / half.max(1e-6); + for p in &mut pos { + for (pk, ck) in p.iter_mut().zip(c.iter()) { + *pk = (*pk - ck) * inv; + } + } + let (mut bmin, mut bmax) = ([f32::MAX; 3], [f32::MIN; 3]); + for p in &pos { + for k in 0..3 { + bmin[k] = bmin[k].min(p[k]); + bmax[k] = bmax[k].max(p[k]); + } + } + + // emit SPM1 + let qi8 = |v: f32| -> i8 { (v * 127.0).round().clamp(-127.0, 127.0) as i8 }; + let mut buf: Vec = Vec::with_capacity(40 + pos.len() * 21 + tris.len() * 12); + buf.extend_from_slice(b"SPM1"); + buf.extend_from_slice(&(pos.len() as u32).to_le_bytes()); + buf.extend_from_slice(&(tris.len() as u32).to_le_bytes()); + buf.extend_from_slice(&(fma_tissue.len() as u32).to_le_bytes()); + for v in &bmin { + buf.extend_from_slice(&v.to_le_bytes()); + } + for v in &bmax { + buf.extend_from_slice(&v.to_le_bytes()); + } + for i in 0..pos.len() { + for k in 0..3 { + buf.extend_from_slice(&pos[i][k].to_le_bytes()); + } + buf.push(qi8(nrm[i][0]) as u8); + buf.push(qi8(nrm[i][1]) as u8); + buf.push(qi8(nrm[i][2]) as u8); + buf.extend_from_slice(&col[i]); + buf.push(lay[i]); // opacity byte = LAYER id + buf.extend_from_slice(&row[i].to_le_bytes()); + } + for t in &tris { + for &x in t { + buf.extend_from_slice(&x.to_le_bytes()); + } + } + if let Some(parent) = std::path::Path::new(&out_path).parent() { + std::fs::create_dir_all(parent).ok(); + } + File::create(&out_path).unwrap().write_all(&buf).unwrap(); + + // manifest (layer histogram drives the viewer's buttons) + let manifest_path = format!("{out_path}.manifest.json"); + let mut layers: Vec<(&str, usize)> = layer_hist.into_iter().collect(); + layers.sort_by_key(|&(_, v)| std::cmp::Reverse(v)); + let layers_json: String = layers.iter().map(|(k, v)| format!("\"{k}\":{v}")).collect::>().join(","); + let manifest = format!( + "{{\"source\":\"BodyParts3D 4.0 (DBCLS) is_a OBJ, vertex-cluster decimated\",\"attribution\":\"BodyParts3D, (c) The Database Center for Life Science, CC-BY 4.0 / CC-BY-SA 2.1 JP\",\"format\":\"SPM1; opacity byte = LAYER id (1 skin·2 muscle·3 organ·4 skeleton·5 vessel·6 nerve·7 connective·8 other)\",\"verts\":{},\"tris\":{},\"cell_mm\":{cell_mm},\"layers\":{{{layers_json}}}}}", + pos.len(), + tris.len() + ); + File::create(&manifest_path).unwrap().write_all(manifest.as_bytes()).unwrap(); + + eprintln!("[cockpit_bake] {} verts, {} tris -> {out_path} ({} MB) + manifest", pos.len(), tris.len(), buf.len() / 1_000_000); + eprintln!("[cockpit_bake] opacity byte = LAYER id; layers: {layers:?}"); +} diff --git a/fma/src/bin/converge.rs b/fma/src/bin/converge.rs index 7b1b00098..9f2826a84 100644 --- a/fma/src/bin/converge.rs +++ b/fma/src/bin/converge.rs @@ -1,36 +1,34 @@ -// converge.rs — VERSION 3: cascading-HHTL `(part_of : is_a)` canonical NodeGuid. +// converge.rs — VERSION 3: cascading-HHTL `(place : tissue)` canonical NodeGuid, +// now converging BOTH the baked address AND the rendering substrate. // -// The convergence that loses NEITHER existing version. Three FMA addressings now -// coexist in disjoint files: +// Three FMA addressings coexist in disjoint files (lose none): +// * v1 (other session): is_a heart graph, /fma, canonical NodeGuid (osint-bake). +// * v2 (this crate, guid.rs): full-body part_of FNV cascade, /FMA. +// * v3 (HERE): each 8:8 HHTL tier = `(place : tissue)` cascading HEEL→HIP→TWIG. // -// * v1 (other session, in-tree): is_a heart graph — `crates/osint-bake/.../fma.rs`, -// `cockpit/.../FmaGraph.tsx`, served at `/fma`. Untouched. -// * v2 (this crate, `guid.rs`): part_of FNV cascade, served at `/FMA`. Untouched. -// * v3 (HERE): each 8:8 HHTL tier = `(part_of : is_a)` — the two axes are the -// two BYTES of every tier, cascading down HEEL→HIP→TWIG: -// high byte = part_of (mixin / family / basin — WHERE it sits; partonomy) -// low byte = is_a (identity / type — WHAT it is; taxonomy) -// The high-byte chain prefix-routes the body partonomy; the low-byte chain -// prefix-routes the type taxonomy — both hierarchies in ONE key, routable on -// either axis at every level. "Cascading HHTL — that's the best of it." +// The two bytes of every tier: +// high = PLACE — WHERE it sits. classid-dispatched (OGAR HhtlMode): +// · skeleton (classid 0x0A02, Located): Morton spatial cell of the bone +// centroid — the exact anchor IS the key (my anchor.rs hybrid). +// · soft tissue (0x0A01, Cascade): part_of sibling-rank — ontological place, +// inheriting position from its part_of basin's skeleton anchor. +// low = TISSUE — WHAT it is. is_a (taxonomy) sibling-rank, both modes. +// The high-byte chain prefix-routes the body (spatial for bone, partonomy for soft); +// the low-byte chain prefix-routes the type taxonomy. Both hierarchies, one key. +// +// connected_to (the EdgeBlock, 12 in-family + 4 out-of-family): part_of siblings are +// the in-family adjacency (the segments/sub-parts that physically connect — e.g. the +// aortic segments, the heart chambers); the is_a parent + nearest cross-basin neighbor +// are the out-of-family links. This is the "nodes connecting via relationships" graph. // // Canonical 16-byte layout, byte-identical to // `lance_graph_contract::canonical_node::NodeGuid::new(classid, heel, hip, twig, -// family, identity)` (OGAR canon, locked 2026-06-13): -// -// 0..4 classid (u32 LE) ← OGAR ConceptDomain::Anatomy (high byte 0x0A) -// 4..6 HEEL (u16 LE) ┐ (part_of:is_a) cascade level 0 -// 6..8 HIP (u16 LE) ├ level 1 -// 8..10 TWIG (u16 LE) ┘ level 2 -// 10..13 family (u24 LE) ← (part_of:is_a) level 3 = basin (0 ⇒ default basin) -// 13..16 identity (u24 LE) ← golden-stride unique mint (24-bit, collision-probed) +// family, identity)` (OGAR canon, 2026-06-13). classid in ConceptDomain::Anatomy +// (0x0A): 0x0A01 soft tissue, 0x0A02 skeleton — same space as the other session's bake. // -// classid aligns with the other session's `osint-bake/fma.rs`: -// 0x0000_0A01 = anatomical_structure (soft tissue); 0x0000_0A02 = skeleton -// (0x0A03/0x0A04 reserved bone/joint). A heart node from their bake and a heart -// node from this full body therefore share classid — the addressings converge. -// -// usage: converge [inclusion.txt] [isa_inclusion.txt] [out_dir] +// usage: converge [inclusion.txt] [isa_inclusion.txt] [out_dir] [parts_dir] [element_parts] +// (parts_dir optional — present ⇒ Located skeleton + spatial edges + render-ready +// nodes.tsv/edges.tsv; absent ⇒ Cascade everywhere + structural edges only.) use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs::File; @@ -52,14 +50,19 @@ fn load_tree(path: &str) -> Tree { let mut nodes = HashSet::new(); for (i, line) in txt.lines().enumerate() { if i == 0 { - continue; // header: parent id / parent name / child id / child name + continue; } let f: Vec<&str> = line.split('\t').collect(); if f.len() >= 4 { parent_of.insert(f[2].to_string(), f[0].to_string()); - children.entry(f[0].to_string()).or_default().push(f[2].to_string()); + children + .entry(f[0].to_string()) + .or_default() + .push(f[2].to_string()); name_of.insert(f[2].to_string(), f[3].to_string()); - name_of.entry(f[0].to_string()).or_insert_with(|| f[1].to_string()); + name_of + .entry(f[0].to_string()) + .or_insert_with(|| f[1].to_string()); nodes.insert(f[0].to_string()); nodes.insert(f[2].to_string()); } @@ -67,13 +70,15 @@ fn load_tree(path: &str) -> Tree { for v in children.values_mut() { v.sort(); } - Tree { parent_of, children, name_of, nodes } + Tree { + parent_of, + children, + name_of, + nodes, + } } impl Tree { - /// 1-based sibling rank under the node's parent (capped to a byte); 0 at the - /// root. Two nodes sharing a parent get distinct ranks ⇒ distinct tier bytes; - /// nodes sharing an ancestor at depth d share every tier byte above d. fn rank_of(&self, node: &str) -> u8 { match self.parent_of.get(node) { Some(p) => self.children[p] @@ -83,7 +88,6 @@ impl Tree { None => 0, } } - /// root..node id chain (distinguished-name path). fn chain(&self, node: &str) -> Vec { let mut ids = vec![node.to_string()]; let mut cur = node.to_string(); @@ -91,7 +95,7 @@ impl Tree { seen.insert(cur.clone()); while let Some(p) = self.parent_of.get(&cur) { if !seen.insert(p.clone()) { - break; // cycle guard + break; } ids.push(p.clone()); cur = p.clone(); @@ -99,7 +103,6 @@ impl Tree { ids.reverse(); ids } - /// Per-level sibling-rank along the chain (root-first); the byte axis. fn rank_chain(&self, node: &str) -> Vec { self.chain(node).iter().map(|n| self.rank_of(n)).collect() } @@ -110,26 +113,179 @@ impl Tree { .collect::>() .join(" / ") } + /// in-family adjacency = part_of siblings (the co-parts that physically connect). + fn siblings(&self, node: &str) -> Vec { + match self.parent_of.get(node) { + Some(p) => self.children[p] + .iter() + .filter(|c| c.as_str() != node) + .cloned() + .collect(), + None => Vec::new(), + } + } +} + +// ── tissue classification (is_a taxonomy → label/color), shared with the renderer ── +const TISSUE: &[(&str, [u8; 3], &[&str])] = &[ + ( + "bone", + [235, 224, 199], + &["bone", "skeletal", "osseous", "vertebra", "sesamoid"], + ), + ("cartilage", [159, 184, 217], &["cartilage", "chondral"]), + ("ligament", [230, 230, 219], &["ligament"]), + ("tendon", [224, 219, 204], &["tendon", "aponeurosis"]), + ( + "muscle", + [189, 92, 87], + &["muscle", "musculature", "musculus"], + ), + ( + "vessel", + [204, 56, 56], + &[ + "artery", + "arterial", + "vein", + "venous", + "vascular", + "capillary", + ], + ), + ( + "nerve", + [235, 209, 82], + &["nerve", "neural", "ganglion", "plexus", "nervous"], + ), + ("organ", [204, 148, 132], &["organ", "viscus", "gland"]), + ("skin", [219, 168, 138], &["skin", "integument"]), +]; +fn tissue_of(ia: &Tree, fma: &str) -> (&'static str, [u8; 3]) { + for id in ia.chain(fma).iter().rev() { + if let Some(nm) = ia.name_of.get(id) { + let l = nm.to_lowercase(); + for (lab, col, kws) in TISSUE { + if kws.iter().any(|k| l.contains(k)) { + return (lab, *col); + } + } + } + } + ("other", [150, 150, 160]) +} + +// ── geometry: per-FMA centroid from the meshes (slices 1 + 3) ── +fn obj_centroid(text: &str) -> Option<([f64; 3], usize)> { + let (mut s, mut n) = ([0.0f64; 3], 0usize); + for line in text.lines() { + let mut it = line.split_whitespace(); + if it.next() == Some("v") { + let c: Vec = it.take(3).filter_map(|t| t.parse().ok()).collect(); + if c.len() == 3 { + for k in 0..3 { + s[k] += c[k]; + } + n += 1; + } + } + } + (n > 0).then(|| ([s[0] / n as f64, s[1] / n as f64, s[2] / n as f64], n)) +} + +/// FMA → centroid (vertex-count-weighted mean over its FJ meshes), if a parts_dir is given. +fn load_centroids(parts_dir: &str, elem_path: &str) -> HashMap { + let mut fj_fma: HashMap> = HashMap::new(); + for (i, line) in std::fs::read_to_string(elem_path) + .unwrap_or_default() + .lines() + .enumerate() + { + if i == 0 { + continue; + } + let f: Vec<&str> = line.split('\t').collect(); + if f.len() >= 3 { + fj_fma.entry(f[2].into()).or_default().push(f[0].into()); + } + } + let mut acc: HashMap = HashMap::new(); + if let Ok(rd) = std::fs::read_dir(parts_dir) { + for path in rd.filter_map(|e| e.ok()).map(|e| e.path()) { + if path.extension().and_then(|s| s.to_str()) != Some("obj") { + continue; + } + let fj = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let Some(fmas) = fj_fma.get(&fj) else { + continue; + }; + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + let Some((c, w)) = obj_centroid(&text) else { + continue; + }; + for fma in fmas { + let e = acc.entry(fma.clone()).or_insert(([0.0; 3], 0)); + for (slot, &ci) in e.0.iter_mut().zip(c.iter()) { + *slot += ci * w as f64; + } + e.1 += w; + } + } + } + acc.into_iter() + .filter(|(_, (_, w))| *w > 0) + .map(|(fma, (s, w))| { + ( + fma, + [ + (s[0] / w as f64) as f32, + (s[1] / w as f64) as f32, + (s[2] / w as f64) as f32, + ], + ) + }) + .collect() +} + +// ── Morton (slice 1): interleave 3×8 bits → 24-bit Z-order spatial code ── +fn part1by2(mut n: u32) -> u32 { + n &= 0xff; + n = (n | (n << 16)) & 0x0300_00FF; + n = (n | (n << 8)) & 0x0300_F00F; + n = (n | (n << 4)) & 0x030C_30C3; + n = (n | (n << 2)) & 0x0924_9249; + n +} +fn morton3(x: u8, y: u8, z: u8) -> u32 { + part1by2(x as u32) | (part1by2(y as u32) << 1) | (part1by2(z as u32) << 2) } // ── canonical NodeGuid emit — byte-identical to NodeGuid::new (see header) ── -fn node_guid_bytes(classid: u32, heel: u16, hip: u16, twig: u16, family: u32, identity: u32) -> [u8; 16] { +fn node_guid_bytes( + classid: u32, + heel: u16, + hip: u16, + twig: u16, + family: u32, + identity: u32, +) -> [u8; 16] { let c = classid.to_le_bytes(); let h = heel.to_le_bytes(); let p = hip.to_le_bytes(); let t = twig.to_le_bytes(); - let f = family.to_le_bytes(); // low 3 bytes = u24 - let i = identity.to_le_bytes(); // low 3 bytes = u24 + let f = family.to_le_bytes(); + let i = identity.to_le_bytes(); [ - c[0], c[1], c[2], c[3], // 0..4 classid - h[0], h[1], // 4..6 HEEL - p[0], p[1], // 6..8 HIP - t[0], t[1], // 8..10 TWIG - f[0], f[1], f[2], // 10..13 family (u24) - i[0], i[1], i[2], // 13..16 identity (u24) + c[0], c[1], c[2], c[3], h[0], h[1], p[0], p[1], t[0], t[1], f[0], f[1], f[2], i[0], i[1], + i[2], ] } -/// Canonical self-describing print (NodeGuid Display): 8-4-4-4-12 hex dash-groups. fn guid_display(b: &[u8; 16]) -> String { let classid = u32::from_le_bytes([b[0], b[1], b[2], b[3]]); let heel = u16::from_le_bytes([b[4], b[5]]); @@ -139,118 +295,210 @@ fn guid_display(b: &[u8; 16]) -> String { let identity = u32::from_le_bytes([b[13], b[14], b[15], 0]); format!("{classid:08x}-{heel:04x}-{hip:04x}-{twig:04x}-{family:06x}{identity:06x}") } - -/// 8:8 tier = `(part_of_rank : is_a_rank)`. -fn tier(po: u8, ia: u8) -> u16 { - ((po as u16) << 8) | ia as u16 +fn tier(hi: u8, lo: u8) -> u16 { + ((hi as u16) << 8) | lo as u16 } - -// Golden stride = GOLDEN_RATIO × EULER_GAMMA (the bgz17 / bgz-hhtl-d / helix -// low-discrepancy generator); helix CurveRuler walk at stride 4, offset 20. const GOLDEN_STRIDE: f64 = std::f64::consts::GOLDEN_RATIO * std::f64::consts::EULER_GAMMA; fn golden_id24(k: usize) -> u32 { let x = (20.0 + (4 * k) as f64) * GOLDEN_STRIDE; (((x - x.floor()) * 16_777_216.0) as u32) & 0x00FF_FFFF } - fn is_skeletal(s: &str) -> bool { let s = s.to_lowercase(); - ["bone", "skelet", "cartilage", "osseous", "vertebra", "rib", "femur", "skull"] - .iter() - .any(|k| s.contains(k)) + [ + "bone", + "skelet", + "cartilage", + "osseous", + "vertebra", + "rib", + "femur", + "skull", + ] + .iter() + .any(|k| s.contains(k)) } fn main() { let args: Vec = std::env::args().collect(); - let po_path = args.get(1).cloned().unwrap_or_else(|| "data/inclusion.txt".into()); - let ia_path = args.get(2).cloned().unwrap_or_else(|| "data/isa_inclusion.txt".into()); + let po_path = args + .get(1) + .cloned() + .unwrap_or_else(|| "data/inclusion.txt".into()); + let ia_path = args + .get(2) + .cloned() + .unwrap_or_else(|| "data/isa_inclusion.txt".into()); let out_dir = args.get(3).cloned().unwrap_or_else(|| "guid".into()); + let parts_dir = args.get(4).cloned(); + let elem_path = args + .get(5) + .cloned() + .unwrap_or_else(|| "data/combined_element_parts.txt".into()); let po = load_tree(&po_path); let ia = load_tree(&ia_path); - // Spine = the part_of nodes (where a part physically sits); is_a is joined in - // per node as the type axis (absent ⇒ 0 byte = zero-fallback "not consulted"). + // Optional centroids → Located skeleton (slice 1) + spatial edges + render input. + let centroids = parts_dir + .as_ref() + .map(|d| load_centroids(d, &elem_path)) + .unwrap_or_default(); + // Global centroid bbox for Morton normalization. + let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); + for c in centroids.values() { + for k in 0..3 { + lo[k] = lo[k].min(c[k]); + hi[k] = hi[k].max(c[k]); + } + } + let morton_of = |c: &[f32; 3]| -> u32 { + let q = |v: f32, k: usize| -> u8 { + let span = (hi[k] - lo[k]).max(1e-6); + (((v - lo[k]) / span).clamp(0.0, 1.0) * 255.0) as u8 + }; + morton3(q(c[0], 0), q(c[1], 1), q(c[2], 2)) + }; + let mut nodes: Vec = po.nodes.iter().cloned().collect(); nodes.sort(); std::fs::create_dir_all(&out_dir).ok(); let mut mf = File::create(format!("{out_dir}/guid_converged.tsv")).unwrap(); - writeln!(mf, "fma\tcanonical_guid\tclassid\tpart_of_dn\tis_a_dn").unwrap(); + writeln!( + mf, + "fma\tcanonical_guid\tclassid\tplace_mode\ttissue\tconnected_to\tpart_of_dn\tis_a_dn" + ) + .unwrap(); + let mut nf = File::create(format!("{out_dir}/nodes.tsv")).unwrap(); + writeln!(nf, "fma\tx\ty\tz\tclassid\ttissue\tr\tg\tb\tguid").unwrap(); + let mut ef = File::create(format!("{out_dir}/edges.tsv")).unwrap(); + writeln!(ef, "src_fma\tdst_fma\trel").unwrap(); let mut used: HashSet<[u8; 16]> = HashSet::new(); - let mut records: Vec<(String, [u8; 16], u32, String, String)> = Vec::new(); - let (mut collisions, mut with_isa, mut skeletal) = (0usize, 0usize, 0usize); + let (mut located, mut with_isa, mut skeletal, mut edge_n, mut placed) = + (0usize, 0usize, 0usize, 0usize, 0usize); + let mut demo: Vec<(String, [u8; 16], String, String, &'static str)> = Vec::new(); for (k, fma) in nodes.iter().enumerate() { - let po_ranks = po.rank_chain(fma); // root..node, WHERE - let ia_ranks = ia.rank_chain(fma); // root..node, WHAT (may be empty) - let in_isa = ia.nodes.contains(fma); - if in_isa { + let po_ranks = po.rank_chain(fma); + let ia_ranks = ia.rank_chain(fma); + if ia.nodes.contains(fma) { with_isa += 1; } - - // byte at cascade level L: part_of ancestor rank at depth L+1 (skip the - // shared root), is_a likewise. 0 when that axis isn't that deep. let lvl = |ranks: &[u8], l: usize| -> u8 { ranks.get(l + 1).copied().unwrap_or(0) }; - let heel = tier(lvl(&po_ranks, 0), lvl(&ia_ranks, 0)); - let hip = tier(lvl(&po_ranks, 1), lvl(&ia_ranks, 1)); - let twig = tier(lvl(&po_ranks, 2), lvl(&ia_ranks, 2)); - // family (u24) = (part_of:is_a) level 3 in the low 16 bits — the basin. - let family = ((lvl(&po_ranks, 3) as u32) << 8) | lvl(&ia_ranks, 3) as u32; - - // classid in the 0x0A anatomy domain, split skeleton vs soft tissue. - let names: String = po.dn(fma) + " " + &ia.dn(fma); - let classid = if is_skeletal(&names) { 0x0000_0A02 } else { 0x0000_0A01 }; - if classid == 0x0000_0A02 { + let names = po.dn(fma) + " " + &ia.dn(fma); + let skeletal_node = is_skeletal(&names); + let classid = if skeletal_node { + 0x0000_0A02 + } else { + 0x0000_0A01 + }; + if skeletal_node { skeletal += 1; } + let (tissue, rgb) = tissue_of(&ia, fma); + + // PLACE (high bytes): Located Morton for a skeletal node WITH a centroid, + // else Cascade part_of rank. TISSUE (low bytes): is_a rank, both modes. + let (heel, hip, twig, mode) = match (skeletal_node, centroids.get(fma)) { + (true, Some(c)) => { + let m = morton_of(c); + located += 1; + ( + tier(((m >> 16) & 0xFF) as u8, lvl(&ia_ranks, 0)), + tier(((m >> 8) & 0xFF) as u8, lvl(&ia_ranks, 1)), + tier((m & 0xFF) as u8, lvl(&ia_ranks, 2)), + "Located", + ) + } + _ => ( + tier(lvl(&po_ranks, 0), lvl(&ia_ranks, 0)), + tier(lvl(&po_ranks, 1), lvl(&ia_ranks, 1)), + tier(lvl(&po_ranks, 2), lvl(&ia_ranks, 2)), + "Cascade", + ), + }; + // family (u24) = ontological basin: (part_of:is_a) level 3, both modes. + let family = ((lvl(&po_ranks, 3) as u32) << 8) | lvl(&ia_ranks, 3) as u32; - // golden-stride identity, probed unique within the (classid,heel,hip,twig,family) basin. let mut identity = golden_id24(k); let mut bytes = node_guid_bytes(classid, heel, hip, twig, family, identity); while used.contains(&bytes) { identity = (identity + 1) & 0x00FF_FFFF; bytes = node_guid_bytes(classid, heel, hip, twig, family, identity); - collisions += 1; } used.insert(bytes); + // connected_to: in-family = part_of siblings (≤12); out-of-family = is_a parent (≤4). + let sibs = po.siblings(fma); + let in_family: Vec = sibs.iter().take(12).cloned().collect(); + for s in &in_family { + writeln!(ef, "{fma}\t{s}\tin_family:part_of_sibling").unwrap(); + edge_n += 1; + } + if let Some(p) = ia.parent_of.get(fma) { + writeln!(ef, "{fma}\t{p}\tout_family:is_a_parent").unwrap(); + edge_n += 1; + } + let conn = if in_family.is_empty() { + "—".to_string() + } else { + in_family.join(",") + }; + writeln!( mf, - "{fma}\t{}\t{:#010x}\t{}\t{}", + "{fma}\t{}\t{:#010x}\t{mode}\t{tissue}\t{conn}\t{}\t{}", guid_display(&bytes), classid, po.dn(fma), - if in_isa { ia.dn(fma) } else { "—".into() } + if ia.nodes.contains(fma) { + ia.dn(fma) + } else { + "—".into() + } ) .unwrap(); - records.push((fma.clone(), bytes, classid, po.dn(fma), ia.dn(fma))); - } - // Self-check: the layout must round-trip (proves byte-compat with NodeGuid). - if let Some((_, b, classid, _, _)) = records.first() { - let dec_classid = u32::from_le_bytes([b[0], b[1], b[2], b[3]]); - assert_eq!(dec_classid, *classid, "classid must round-trip at bytes 0..4 (canonical LE)"); + if let Some(c) = centroids.get(fma) { + writeln!( + nf, + "{fma}\t{:.4}\t{:.4}\t{:.4}\t{:#010x}\t{tissue}\t{}\t{}\t{}\t{}", + c[0], + c[1], + c[2], + classid, + rgb[0], + rgb[1], + rgb[2], + guid_display(&bytes) + ) + .unwrap(); + placed += 1; + } + if po.dn(fma).to_lowercase().contains("aorta") { + demo.push((fma.clone(), bytes, po.dn(fma), conn.clone(), tissue)); + } } eprintln!( - "[converge] {} nodes -> {out_dir}/guid_converged.tsv ({with_isa} with is_a axis, \ - {skeletal} skeletal 0x0A02, {collisions} identity probes)", - records.len() + "[converge] {} nodes -> {out_dir}/ ({with_isa} with is_a, {skeletal} skeletal, {located} Located via centroid, {placed} placed, {edge_n} edges)", + nodes.len() ); - eprintln!("[converge] canonical NodeGuid layout (OGAR 2026-06-13): classid·HEEL·HIP·TWIG·family·identity, each tier 8:8 = (part_of:is_a)"); + eprintln!("[converge] tier = (place:tissue): place = Morton(bone)/part_of(soft) [classid-dispatched], tissue = is_a; canonical NodeGuid (OGAR 2026-06-13)"); + if centroids.is_empty() { + eprintln!("[converge] no parts_dir → Cascade everywhere + structural edges only (pass a parts_dir for Located skeleton + render-ready nodes.tsv/edges.tsv)"); + } - // Demo: the aorta subtree resolves on BOTH axes at once — shared part_of - // high-bytes (same body region) AND shared is_a low-bytes (same vessel type). - eprintln!("\n[demo] aorta subtree — (part_of:is_a) cascade, shared leading tiles = shared ancestry on each axis:"); - let mut demo: Vec<&(String, [u8; 16], u32, String, String)> = - records.iter().filter(|(_, _, _, po_dn, _)| po_dn.to_lowercase().contains("aorta")).collect(); - demo.sort_by(|a, b| guid_display(&a.1).cmp(&guid_display(&b.1))); - for (fma, b, _, po_dn, ia_dn) in demo.iter().take(8) { + eprintln!("\n[demo] aorta subtree — (place:tissue) + connected_to (part_of siblings = the segments that connect):"); + demo.sort_by_key(|d| guid_display(&d.1)); + for (fma, b, po_dn, conn, tissue) in demo.iter().take(8) { let short = po_dn.rsplit(" / ").next().unwrap_or(po_dn); - let typ = ia_dn.rsplit(" / ").next().unwrap_or(ia_dn); - eprintln!(" {} {fma} part_of:{short} is_a:{typ}", guid_display(b)); + eprintln!( + " {} {fma} {short} [{tissue}] ↔ {conn}", + guid_display(b) + ); } } diff --git a/fma/src/bin/graph.rs b/fma/src/bin/graph.rs new file mode 100644 index 000000000..5186c5da6 --- /dev/null +++ b/fma/src/bin/graph.rs @@ -0,0 +1,399 @@ +// graph.rs — SLICE 3: the renderer reads the key, as SOLID TRIANGLES. +// +// Renders the filled triangle surface (the same z-buffered Gouraud rasterizer as +// `mesh`), but the render is driven by the converged canonical key: +// * COLOR — each mesh is colored by its FMA's `tissue` byte (the is_a low byte of +// the converged GUID), read straight from `converge`'s manifest. +// * SELECT — an optional GUID **prefix** picks which triangles to draw: pass +// `00000a01-0901-0702` and only the parts whose canonical key starts with +// it render (one part_of/is_a subtree). Prefix-routing the key, made +// visible — the address selects the geometry. `all` draws the whole body. +// +// This is "70k nodes connecting via relationships → 16M triangles": the relationships +// (part_of/is_a) ARE the address, and the address drives which triangles light up. +// +// usage: graph [all||] + +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::BufWriter; + +fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn normalize(a: [f32; 3]) -> [f32; 3] { + let n = dot(a, a).sqrt(); + if n < 1e-9 { + [0.0, 0.0, 1.0] + } else { + [a[0] / n, a[1] / n, a[2] / n] + } +} +fn look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [[f32; 4]; 4] { + let f = normalize(sub(target, eye)); + let r = normalize(cross(up, f)); + let u = cross(f, r); + [ + [r[0], r[1], r[2], -dot(r, eye)], + [u[0], u[1], u[2], -dot(u, eye)], + [f[0], f[1], f[2], -dot(f, eye)], + [0.0, 0.0, 0.0, 1.0], + ] +} +fn xform(m: &[[f32; 4]; 4], p: [f32; 3]) -> [f32; 3] { + [ + m[0][0] * p[0] + m[0][1] * p[1] + m[0][2] * p[2] + m[0][3], + m[1][0] * p[0] + m[1][1] * p[1] + m[1][2] * p[2] + m[1][3], + m[2][0] * p[0] + m[2][1] * p[1] + m[2][2] * p[2] + m[2][3], + ] +} +fn parse_obj(text: &str) -> (Vec<[f32; 3]>, Vec<[usize; 3]>) { + let (mut verts, mut tris) = (Vec::new(), Vec::new()); + for line in text.lines() { + let mut it = line.split_whitespace(); + match it.next() { + Some("v") => { + let c: Vec = it.take(3).filter_map(|t| t.parse().ok()).collect(); + if c.len() == 3 { + verts.push([c[0], c[1], c[2]]); + } + } + Some("f") => { + let idx: Vec = it + .map(|t| t.split(['/', ' ']).next().unwrap_or("")) + .filter_map(|s| s.parse::().ok()) + .map(|i| { + if i < 0 { + (verts.len() as i32 + i) as usize + } else { + (i - 1) as usize + } + }) + .collect(); + for k in 1..idx.len().saturating_sub(1) { + tris.push([idx[0], idx[k], idx[k + 1]]); + } + } + _ => {} + } + } + (verts, tris) +} + +// tissue label → solid color (same palette as mesh/converge — the is_a low byte). +fn tissue_color(t: &str) -> [f32; 3] { + match t { + "bone" => [0.92, 0.88, 0.78], + "cartilage" => [0.62, 0.72, 0.85], + "ligament" => [0.90, 0.90, 0.86], + "tendon" => [0.88, 0.86, 0.80], + "muscle" => [0.74, 0.36, 0.34], + "vessel" => [0.80, 0.22, 0.22], + "nerve" => [0.92, 0.82, 0.32], + "organ" => [0.80, 0.58, 0.52], + "skin" => [0.86, 0.66, 0.54], + _ => [0.60, 0.60, 0.64], + } +} + +struct Tri { + p: [[f32; 3]; 3], + n: [[f32; 3]; 3], + c: [f32; 3], +} + +fn main() { + let a: Vec = std::env::args().collect(); + let parts_dir = a + .get(1) + .cloned() + .unwrap_or_else(|| "data/isa_parts/isa_BP3D_4.0_obj_99".into()); + let elem_path = a + .get(2) + .cloned() + .unwrap_or_else(|| "data/combined_element_parts.txt".into()); + let conv_path = a + .get(3) + .cloned() + .unwrap_or_else(|| "guid/guid_converged.tsv".into()); + let out_dir = a.get(4).cloned().unwrap_or_else(|| "graph".into()); + let sel = a.get(5).cloned().unwrap_or_else(|| "all".into()); + + // ── read the converged key: FMA → (guid, tissue, part_of depth) ── + let mut fma_guid: HashMap = HashMap::new(); + let mut fma_tissue: HashMap = HashMap::new(); + let mut fma_depth: HashMap = HashMap::new(); + for (i, line) in std::fs::read_to_string(&conv_path) + .unwrap_or_default() + .lines() + .enumerate() + { + if i == 0 { + continue; + } + let f: Vec<&str> = line.split('\t').collect(); + if f.len() >= 7 { + fma_guid.insert(f[0].into(), f[1].into()); + fma_tissue.insert(f[0].into(), f[4].into()); + fma_depth.insert(f[0].into(), f[6].matches(" / ").count()); // part_of_dn depth + } + } + if fma_guid.is_empty() { + eprintln!("[graph] no converged manifest at {conv_path} — run `converge` first"); + return; + } + // FJ → its FMAs (deepest-first preference handled by manifest membership). + let mut fj_fma: HashMap> = HashMap::new(); + for (i, line) in std::fs::read_to_string(&elem_path) + .unwrap_or_default() + .lines() + .enumerate() + { + if i == 0 { + continue; + } + let f: Vec<&str> = line.split('\t').collect(); + if f.len() >= 3 { + fj_fma.entry(f[2].into()).or_default().push(f[0].into()); + } + } + + // `sel` ∈ { all | tissues | | }. Hex/dash ⇒ prefix. + let is_prefix = !matches!(sel.as_str(), "all" | "tissues") + && sel.chars().all(|c| c.is_ascii_hexdigit() || c == '-'); + let keep = |guid: &str, tissue: &str| -> bool { + match sel.as_str() { + "all" => true, + "tissues" => tissue != "skin" && tissue != "other", + _ if is_prefix => guid.starts_with(&sel), + _ => tissue == sel, + } + }; + + // ── collect solid triangles, colored by the key's tissue, selected by the key ── + let mut tris: Vec = Vec::new(); + let mut entries: Vec<_> = std::fs::read_dir(&parts_dir) + .expect("parts") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .collect(); + entries.sort(); + let mut kept: HashSet<&str> = HashSet::new(); + for path in &entries { + if path.extension().and_then(|s| s.to_str()) != Some("obj") { + continue; + } + let fj = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + // pick the FJ's DEEPEST addressed FMA (most specific, like guid.rs) so a bone + // mesh keys to its skeletal leaf, not a soft-tissue parent. Skip unaddressed. + let Some(fma) = fj_fma.get(&fj).and_then(|v| { + v.iter() + .filter(|f| fma_guid.contains_key(*f)) + .max_by_key(|f| fma_depth.get(*f).copied().unwrap_or(0)) + }) else { + continue; + }; + let (guid, tissue) = (&fma_guid[fma], fma_tissue[fma].as_str()); + if !keep(guid, tissue) { + continue; + } + let col = tissue_color(tissue); + let Ok(text) = std::fs::read_to_string(path) else { + continue; + }; + let (verts, faces) = parse_obj(&text); + if verts.is_empty() { + continue; + } + let mut vn = vec![[0.0f32; 3]; verts.len()]; + for f in &faces { + if f[0] >= verts.len() || f[1] >= verts.len() || f[2] >= verts.len() { + continue; + } + let fnv = cross(sub(verts[f[1]], verts[f[0]]), sub(verts[f[2]], verts[f[0]])); + for &vi in f { + for k in 0..3 { + vn[vi][k] += fnv[k]; + } + } + } + for n in &mut vn { + *n = normalize(*n); + } + for f in &faces { + if f[0] >= verts.len() || f[1] >= verts.len() || f[2] >= verts.len() { + continue; + } + tris.push(Tri { + p: [verts[f[0]], verts[f[1]], verts[f[2]]], + n: [vn[f[0]], vn[f[1]], vn[f[2]]], + c: col, + }); + } + kept.insert(tissue_color_name(tissue)); + } + eprintln!( + "[graph] select='{sel}' ({}): {} triangles from addressed meshes", + if is_prefix { + "guid-prefix" + } else if sel == "all" { + "whole body" + } else { + "tissue" + }, + tris.len() + ); + if tris.is_empty() { + eprintln!("[graph] nothing matched '{sel}' — try `all`, a tissue (bone/vessel/...), or a guid prefix like 00000a01-0901"); + return; + } + + // ── normalize + camera + rasterize (the mesh.rs solid path) ── + let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); + for t in &tris { + for v in &t.p { + for k in 0..3 { + lo[k] = lo[k].min(v[k]); + hi[k] = hi[k].max(v[k]); + } + } + } + let sc = 1.7 / (hi[2] - lo[2]).max(1e-3); + let (mx, my) = ((lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5); + for t in &mut tris { + for v in &mut t.p { + *v = [(v[0] - mx) * sc, (v[1] - my) * sc, (v[2] - lo[2]) * sc]; + } + } + let half_h = (hi[2] - lo[2]) * 0.5 * sc; + let center = [0.0f32, 0.0, half_h]; + let (w, h) = (980u32, 1280u32); + let dist = half_h * 4.2; + let eye = [ + center[0] + dist * 0.42, + center[1] - dist * 0.85, + center[2] + half_h * 0.12, + ]; + let view = look_at(eye, center, [0.0, 0.0, 1.0]); + let focal = w.max(h) as f32 * 1.1; + let (cx, cy) = (w as f32 * 0.5, h as f32 * 0.5); + let light = normalize([-0.35, -0.9, 0.55]); + + let mut fb = vec![0.0f32; (3 * w * h) as usize]; + for i in (0..fb.len()).step_by(3) { + fb[i] = 0.05; + fb[i + 1] = 0.06; + fb[i + 2] = 0.08; + } + let mut zbuf = vec![f32::MAX; (w * h) as usize]; + let edge = |a: [f32; 2], b: [f32; 2], c: [f32; 2]| { + (c[0] - a[0]) * (b[1] - a[1]) - (c[1] - a[1]) * (b[0] - a[0]) + }; + for t in &tris { + let cs: [[f32; 3]; 3] = [ + xform(&view, t.p[0]), + xform(&view, t.p[1]), + xform(&view, t.p[2]), + ]; + if cs[0][2] <= 0.02 || cs[1][2] <= 0.02 || cs[2][2] <= 0.02 { + continue; + } + let proj = |c: [f32; 3]| [focal * c[0] / c[2] + cx, focal * c[1] / c[2] + cy]; + let s = [proj(cs[0]), proj(cs[1]), proj(cs[2])]; + let area = edge(s[0], s[1], s[2]); + if area.abs() < 1e-6 { + continue; + } + let inv = 1.0 / area; + let (minx, maxx) = ( + s[0][0].min(s[1][0]).min(s[2][0]).floor().max(0.0) as i32, + s[0][0].max(s[1][0]).max(s[2][0]).ceil().min(w as f32 - 1.0) as i32, + ); + let (miny, maxy) = ( + s[0][1].min(s[1][1]).min(s[2][1]).floor().max(0.0) as i32, + s[0][1].max(s[1][1]).max(s[2][1]).ceil().min(h as f32 - 1.0) as i32, + ); + for py in miny..=maxy { + for px in minx..=maxx { + let pc = [px as f32 + 0.5, py as f32 + 0.5]; + let mut w0 = edge(s[1], s[2], pc) * inv; + let mut w1 = edge(s[2], s[0], pc) * inv; + let mut w2 = edge(s[0], s[1], pc) * inv; + if !((w0 >= 0.0 && w1 >= 0.0 && w2 >= 0.0) || (w0 <= 0.0 && w1 <= 0.0 && w2 <= 0.0)) + { + continue; + } + if area < 0.0 { + w0 = -w0; + w1 = -w1; + w2 = -w2; + } + let depth = w0 * cs[0][2] + w1 * cs[1][2] + w2 * cs[2][2]; + let idx = (py as u32 * w + px as u32) as usize; + if depth >= zbuf[idx] { + continue; + } + zbuf[idx] = depth; + let mut nrm = [ + w0 * t.n[0][0] + w1 * t.n[1][0] + w2 * t.n[2][0], + w0 * t.n[0][1] + w1 * t.n[1][1] + w2 * t.n[2][1], + w0 * t.n[0][2] + w1 * t.n[1][2] + w2 * t.n[2][2], + ]; + nrm = normalize(nrm); + let shade = 0.30 + 0.70 * dot(nrm, light).abs(); + for k in 0..3 { + fb[idx * 3 + k] = (t.c[k] * shade).clamp(0.0, 1.0); + } + } + } + } + + std::fs::create_dir_all(&out_dir).ok(); + let mut rgb = vec![0u8; (3 * w * h) as usize]; + for y in 0..h as usize { + let (srow, drow) = ((h as usize - 1 - y) * w as usize, y * w as usize); + for x in 0..w as usize { + for k in 0..3 { + rgb[(drow + x) * 3 + k] = + (fb[(srow + x) * 3 + k].clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + } + let tag = sel.replace('-', ""); + let file = format!("{out_dir}/graph_{tag}.png"); + let mut enc = png::Encoder::new(BufWriter::new(File::create(&file).unwrap()), w, h); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + enc.write_header().unwrap().write_image_data(&rgb).unwrap(); + eprintln!("[graph] solid surface, colored by `tissue` (is_a) + selected by the canonical key -> {file} ({w}x{h})"); +} + +// identity helper so the kept-set stays &'static (label set is fixed). +fn tissue_color_name(t: &str) -> &'static str { + match t { + "bone" => "bone", + "cartilage" => "cartilage", + "ligament" => "ligament", + "tendon" => "tendon", + "muscle" => "muscle", + "vessel" => "vessel", + "nerve" => "nerve", + "organ" => "organ", + "skin" => "skin", + _ => "other", + } +} diff --git a/fma/src/bin/soa_scan.rs b/fma/src/bin/soa_scan.rs new file mode 100644 index 000000000..0731797fd --- /dev/null +++ b/fma/src/bin/soa_scan.rs @@ -0,0 +1,118 @@ +// soa_scan.rs — 1M-row SoA scalability PoC: key-only scan vs full-value scan. +// +// Proves the OGAR canon's "the key prerenders nodes with ZERO value decode": when the +// canonical NodeRow (512 B = key 16 + edges 16 + value 480) is laid out COLUMNAR +// (struct-of-arrays) — a contiguous key column + a contiguous value column — a key-only +// scan (prefix-route / render-select, e.g. "draw the skeleton subtree" = classid 0x0A02) +// touches ~30x less memory than materializing the value slab, and stays flat as N grows. +// This is the same prefix routing the /fma-body skeleton button and `graph 00000a02` do, +// measured at scale. +// +// 1M is SYNTHETIC — real FMA is ~1368 placed meshes / ~75K terms; the scan throughput is +// the point, not data realism. Synthetic rows are seeded by the FMA addressing +// distribution (~25% skeleton classid, the rest soft tissue). +// +// usage: soa_scan [max_n] [reps] (default 1_000_000 rows, 5 reps; lower max_n for less RAM) + +use std::hint::black_box; +use std::time::Instant; + +const CLASSID_SOFT: u32 = 0x0000_0A01; +const CLASSID_SKELETON: u32 = 0x0000_0A02; + +/// Columnar SoA: the 16-byte GUID key column and the 480-byte value-slab column, kept +/// in separate contiguous allocations (the "SoA > tenant view" split). +fn build(n: usize) -> (Vec<[u8; 16]>, Vec<[u8; 480]>) { + let mut keys = Vec::with_capacity(n); + let mut values = Vec::with_capacity(n); + for i in 0..n { + // synthetic canonical GUID seeded by i: ~25% skeleton, rest soft tissue. + let classid = if i % 4 == 0 { CLASSID_SKELETON } else { CLASSID_SOFT }; + let h = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15); // splitmix-ish spread + let mut k = [0u8; 16]; + k[0..4].copy_from_slice(&classid.to_le_bytes()); // 0..4 classid + k[4..12].copy_from_slice(&h.to_le_bytes()); // 4..12 HEEL/HIP/TWIG + family low + let id = (i as u32) & 0x00FF_FFFF; + k[12..15].copy_from_slice(&id.to_le_bytes()[..3]); // identity (u24) + k[15] = (h >> 56) as u8; + keys.push(k); + // value slab: filled from i so the read can't be elided to a no-op. + let mut v = [0u8; 480]; + v[0] = (i & 0xFF) as u8; + v[239] = (h & 0xFF) as u8; + v[479] = (i >> 8) as u8; + values.push(v); + } + (keys, values) +} + +fn gbps(bytes: usize, secs: f64) -> f64 { + (bytes as f64) / secs / 1e9 +} + +fn main() { + let a: Vec = std::env::args().collect(); + let max_n: usize = a.get(1).and_then(|s| s.parse().ok()).unwrap_or(1_000_000); + let reps: usize = a.get(2).and_then(|s| s.parse().ok()).unwrap_or(5); + + println!("# SoA scalability: key-only (16 B/row, prefix-route) vs value (480 B/row, decode the slab)"); + println!("# NodeRow = 512 B = key(16) + edges(16) + value(480); columnar SoA. {reps} reps, best-of."); + println!("{:>10} | {:>20} | {:>20} | {:>8}", "rows", "key-only (route)", "value (decode slab)", "speedup"); + println!("{:->10}-+-{:->20}-+-{:->20}-+-{:->8}", "", "", "", ""); + + let scales: Vec = [64_000usize, 256_000, max_n].into_iter().filter(|&n| n > 0).collect(); + for &n in &scales { + let (keys, values) = build(n); + + // key-only scan: prefix-route — count the skeleton subtree, reading only the key + // column (classid at bytes 0..4; the 16-byte key column is what's streamed). + let mut best_key = f64::MAX; + let mut routed = 0usize; + for _ in 0..reps { + let t = Instant::now(); + let mut c = 0usize; + for k in &keys { + let classid = u32::from_le_bytes([k[0], k[1], k[2], k[3]]); + if classid == CLASSID_SKELETON { + c += 1; + } + } + black_box(c); + best_key = best_key.min(t.elapsed().as_secs_f64()); + routed = c; + } + + // value scan: materialize the slab — sum all 480 bytes per row (the work a value + // decode / tenant read does), reading the whole value column. + let mut best_val = f64::MAX; + for _ in 0..reps { + let t = Instant::now(); + let mut s = 0u64; + for v in &values { + let mut acc = 0u64; + for &b in v.iter() { + acc = acc.wrapping_add(b as u64); + } + s = s.wrapping_add(acc); + } + black_box(s); + best_val = best_val.min(t.elapsed().as_secs_f64()); + } + + let key_bytes = n * 16; // key column streamed + let val_bytes = n * 480; // value column streamed + let key_rps = n as f64 / best_key; + let val_rps = n as f64 / best_val; + println!( + "{:>10} | {:>8.0} M/s {:>5.1} GB/s | {:>8.0} M/s {:>5.1} GB/s | {:>6.1}x", + n, + key_rps / 1e6, + gbps(key_bytes, best_key), + val_rps / 1e6, + gbps(val_bytes, best_val), + best_val / best_key, + ); + let _ = routed; + } + println!("# key-only touches 16 B/row vs 480 B/row (30x less); routing/render-select needs NO value decode."); +}