diff --git a/.gitignore b/.gitignore index c5ae8c52f..1de1a8d87 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,12 @@ CLAUDE.local.md # Visual brainstorming companion scratch files .superpowers/ + +# FMA body.soa bake scratch (regenerated from uploads; do not commit) +scratch-fma/ +crates/osint-bake/tools/__pycache__/ + +# big FMA body wire — lives in GitHub Releases, never in git +cockpit/public/body.soa +cockpit/public/body.soa.gz +scratch-fma/ diff --git a/Cargo.lock b/Cargo.lock index 7e06e2a1d..37238b893 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6786,7 +6786,7 @@ dependencies = [ [[package]] name = "ogar-adapter-surrealql" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#302c28437c7621957acfd414b80925d9ebccee84" +source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#a1fb1701ec60246c4393d126e3ff6bb8b3a53dcf" dependencies = [ "ogar-vocab", ] @@ -6794,7 +6794,7 @@ dependencies = [ [[package]] name = "ogar-class-view" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#302c28437c7621957acfd414b80925d9ebccee84" +source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#a1fb1701ec60246c4393d126e3ff6bb8b3a53dcf" dependencies = [ "lance-graph-contract", "ogar-vocab", @@ -6803,12 +6803,12 @@ dependencies = [ [[package]] name = "ogar-ontology" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#302c28437c7621957acfd414b80925d9ebccee84" +source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#a1fb1701ec60246c4393d126e3ff6bb8b3a53dcf" [[package]] name = "ogar-vocab" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#302c28437c7621957acfd414b80925d9ebccee84" +source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#a1fb1701ec60246c4393d126e3ff6bb8b3a53dcf" [[package]] name = "once_cell" diff --git a/Cargo.toml b/Cargo.toml index 08053aff1..822288f4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -222,6 +222,13 @@ path = "../lance-graph/crates/lance-graph-contract" # u16). family(u16) is the deterministic basin (round→anchor), NOT a category and # NOT a Louvain cluster — location is permanent via the HHTL, the basin is an # interface the node adapts to. +# NOTE: guid-v3-tail is NOT enabled workspace-wide. Only `osint-bake` mints on the +# V3 cascade tail (the FMA bake), so it requests `guid-v3-tail` on its OWN dep +# (crates/osint-bake/Cargo.toml). Enabling it here forced EVERY member — including +# cockpit-server — to require a lance-graph-contract carrying the feature, which +# broke the Railway cockpit build against the deliberately-pinned LANCE_GRAPH_REF +# (Dockerfile COUNT_FUSE lockstep). cockpit-server uses no V3 minting; it needs +# only guid-v2-tail (the OSINT/Gotham v2 leaf·family·identity tail). features = ["guid-v2-tail"] # OGAR Active-Record activation crate — the real `impl lance_graph_contract:: @@ -237,6 +244,17 @@ path = "../lance-graph/crates/causal-edge" [workspace.dependencies.graph-flow] path = "./crates/stubs/graph-flow" +# The REAL AdaWorldAPI/ndarray fork, consumed DIRECTLY — the SAME local checkout +# lance-graph compiles (`../ndarray`), so the whole binary unifies on one fork. +# No wrapper crate: when everything compiles into a single binary from local +# source, wrapping ndarray behind another crate buys nothing — it only hides +# `ndarray::simd` / `ndarray::hpc` (the AVX-512→AVX2→scalar polyfill, compile-time +# `target-cpu=x86-64-v4` + runtime `simd_caps()`) behind a pointless indirection. +[workspace.dependencies.ndarray] +path = "../ndarray" +default-features = false +features = ["std", "hpc-extras"] + [workspace.dependencies.q2-ndarray] path = "./crates/stubs/q2-ndarray" diff --git a/Dockerfile b/Dockerfile index 6c1e1fef7..de6b7d792 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,28 +50,45 @@ COPY . /build/q2 # so include_dir! can embed it at compile time COPY --from=frontend /build/dist/ /build/q2/cockpit/dist/ +# Pull the big FMA body wire (BSO2) from the q2 release into dist/ so include_dir! +# embeds it and the server serves it SAME-ORIGIN at /body.soa.gz. The browser cannot +# fetch the release URL directly (github.com/.../releases/download sends no CORS +# header on its redirect → "TypeError: Failed to fetch"), so /body fetches the +# same-origin copy. The asset stays in the release (downloaded at build), never git. +RUN curl -fSL https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1/body.soa.gz \ + -o /build/q2/cockpit/dist/body.soa.gz \ + && ls -lh /build/q2/cockpit/dist/body.soa.gz + # Sibling deps — clone from GitHub # graph-flow stub is local (crates/stubs/graph-flow), no rs-graph-llm needed # -# lance-graph is PINNED to an explicit commit (NOT `--depth 1 main`) for two -# reasons: -# 1. Cache-bust. A `--depth 1 main` clone lives in its own Docker layer that -# an empty/unrelated q2 commit does NOT invalidate, so Railway reuses a -# STALE lance-graph from an earlier build. Bumping this SHA changes the -# RUN and forces a fresh clone. -# 2. COUNT_FUSE lockstep. lance-graph-ogar compile-asserts (E0080 on mismatch) -# that lance_graph_contract::ogar_codebook::CODEBOOK.len() == -# ogar_vocab::class_ids::ALL.len(). q2's Cargo.lock pins ogar-vocab to a -# fixed OGAR SHA (302c284 = 43 concepts); the lance-graph clone MUST carry -# the matching 43-concept mirror. 36059ce0 is the #595 merge (ogar_codebook -# synced to 43) — the matched pair of the ogar-vocab pin. -# WHEN OGAR MINTS CONCEPTS: bump ogar-vocab in q2's Cargo.lock AND this SHA -# together (after the lance-graph mirror lands), or the fuse trips again. -ARG LANCE_GRAPH_REF=36059ce0 -RUN git clone https://github.com/AdaWorldAPI/lance-graph.git \ - && git -C lance-graph checkout "${LANCE_GRAPH_REF}" \ - && git clone --depth 1 https://github.com/AdaWorldAPI/ndarray.git \ - && git clone --depth 1 https://github.com/AdaWorldAPI/neo4j-rs.git +# lance-graph + ndarray are cloned at their BRANCH HEAD (latest) — NOT a pinned, +# stale SHA. The repos at their tips are mutually consistent, so "use the latest of +# everything" is the rule: a pinned-old lance-graph (36059ce0) is exactly what +# lacked `guid-v3-tail` and broke the build. The `COPY . /build/q2` above changes on +# every q2 commit, invalidating this RUN layer too, so each build re-clones fresh +# (no stale-cache problem the old pin was guarding against). +# +# Sibling checkouts the path deps resolve against: +# /build/lance-graph → lance-graph @ main HEAD — carries guid-v2-tail + +# guid-v3-tail and the 65-concept ogar_codebook mirror. +# /build/ndarray → the REAL AdaWorldAPI/ndarray fork, consumed by BOTH +# lance-graph (../../../ndarray) AND q2-ndarray +# (../../../../ndarray). `--depth 1` WITHOUT +# --recurse-submodules: ndarray's workspace `exclude`s +# crates/burn, so the burn submodule (AdaWorldAPI/burn.git) +# is never needed — leaving it unfetched is correct. +# +# COUNT_FUSE: lance-graph-ogar asserts (E0080 on mismatch) +# CODEBOOK.len() == ogar_vocab::class_ids::ALL.len(). Both move together at HEAD — +# lance-graph main's 65-concept codebook mirror matches OGAR main's 65-concept vocab; +# q2's Cargo.lock pins ogar-vocab to OGAR main HEAD (a1fb170). Always bump the two +# repos' HEADs together, never one alone. +# +# neo4j-rs is intentionally NOT cloned — a discarded Neo4j-GUI experiment referenced +# by no manifest; the only neo4j path is the opt-in `neo4j-fallback` (crates.io neo4rs). +RUN git clone --depth 1 https://github.com/AdaWorldAPI/lance-graph.git \ + && git clone --depth 1 https://github.com/AdaWorldAPI/ndarray.git # CPU baseline: x86-64-v4 (the 4th microarch level — AVX-512F/BW/CD/DQ/VL on top # of v3's AVX2+FMA). This is the compile FLOOR; it flips on `target_feature = diff --git a/claude-notes/plans/2026-06-27-body-v3-server-lod-fill.md b/claude-notes/plans/2026-06-27-body-v3-server-lod-fill.md new file mode 100644 index 000000000..d96e8c679 --- /dev/null +++ b/claude-notes/plans/2026-06-27-body-v3-server-lod-fill.md @@ -0,0 +1,157 @@ +# /body — server-side HHTL LOD + helix + slicer-fill (option 2) + +## Overview + +`/body` must render the full FMA body as **filled polygons** (slicer-style infill, +per material — tubes/vessels/solids), addressed on the **V3 substrate** +(`classid 0x1000_0A01`, the `(part_of:is_a)` 8:8 cascade), with **LOD** driven by +the HHTL depth-cascade. The earlier `/body` was wrong on every axis: raw-OBJ hollow +shells, no fill, no LOD, no helix, V3 key mis-encoded as `(depth:is_a)`, renderer +ignoring `classid`. + +**Decision (operator, 2026-06-27): option 2 — compute server-side.** The HHTL LOD +(`depth_cascade`), helix-3-byte, and slicer-fill run in `cockpit-server` (x86, full +`F32x16` SIMD), streaming LOD-selected geometry to a thin three.js viewer. Rationale: +ndarray's **wasm** SIMD backend (`simd_wasm.rs`) is an un-wired stub — `F32x16` +falls back to scalar on wasm, ~16× too slow for per-frame client-side LOD. Native is +fully polyfilled (AVX-512/AVX2/NEON), so the cascade belongs server-side. (Option 1 — +complete the wasm `F32x16` v128 backend — is the alternative, deferred.) + +`splat3d` is a gaussian raster (the rejected "confetti" path); we use ONLY its +renderer-agnostic `depth_cascade` (LOD block-preselection) + `helix_orient`, and draw +**polygons**, never gaussians. + +## Foundation — DONE (verified) + +`scratch-fma/lodprobe` (standalone, builds against ndarray `features=["std","splat3d"]`): +body.spm1 → per-concept `BlockBounds{center,radius}` → `cascade_blocks(camera, …)`. +Verified monotonic LOD: near ⇒ 1513/1658 `ProjectExact`; far ⇒ 1446/1658 `KeepCoarse`. +API pinned: `depth_cascade::{BlockBounds, DepthCascadeBudget, HhtlAction(Reject/ +KeepCoarse/Refine/ProjectExact/RenderExact), cascade_blocks}`, `project::Camera`. + +## Work items + +### Phase A — V3 substrate correctness (independent of LOD) +- [ ] Bake the cascade as **6×(8:8) `(part_of:is_a)`** tiles: walk BOTH + `partof_inclusion_relation_list.txt` AND `isa_inclusion_relation_list.txt`; each + tier byte-pair = `(part_of_rank << 8) | is_a_rank`; identity tier too. (Current bake + packs `(depth:is_a)` and never walked part_of — wrong.) +- [ ] `body.rs`: emit the 6 tiers directly from the (part_of,is_a) pair arrays; drop + the `mixin_for_depth` hack. +- [ ] Renderer: dispatch on `classid` — assert `0x1000_xxxx` (V3), decode the + `(part_of:is_a)` tile per node, use it (group/colour/pick by the two axes). + +### Phase B — multi-LOD geometry (the pyramid the cascade selects from) +- [ ] Per concept, bake a decimation pyramid: L0 full-res (ProjectExact), L1/L2 + vertex-cluster-decimated (KeepCoarse). Store offsets per (concept, level). +- [ ] BlockBounds table (centroid + radius) per concept, baked alongside. + +### Phase C — slicer-fill + helix (the "3D printing slicer" infill) +- [ ] Per solid material (tube/vessel/organ), generate infill geometry inside the + shell (slicer-style), placed via HHTL tile coords + `helix_orient` 3-byte → exact + location. Tubes get tubular infill; solids get volumetric. +- [ ] Material-prototype texture per layer (tube/vessel/bone/…). + +### Phase D — server endpoint + streaming viewer +- [ ] `cockpit-server`: dep ndarray `features=["std","splat3d"]`; `/api/body/lod` + (POST camera {view,fx,fy,w,h}) → `cascade_blocks` → assemble selected (concept,LOD) + blocks → SPM1 stream. +- [ ] `BodyV3.tsx`: thin — throttled orbit posts the camera; swap the streamed mesh. + Drop the full 168 MB client fetch. + +## LOCKED design (operator review, 2026-06-27) — supersedes the BSO1 AoS bake + +The wire is **SoA columns** (`MultiLaneColumn`, 64-byte aligned, `Arc<[u8]>`), +joined by ONE SoA row identity. **Store identities/indices, never raw values; +ClassView dereferences.** Three separate 16-byte GUID columns (cheap: 16 B × +100k = 1.6 MB; × 396k surfels = 6.4 MB — separation beats bit-packing): + +| column | 16-B GUID / value | content | resolves via | +|---|---|---|---| +| **address** | `classid 0x1000` + `(part_of:is_a)` **8:8** cascade + identity tier | the node key (unique; routable prefix) | ClassView / registry | +| **location** | `XYZ` standard 3D | position — GPU/slicer native; Z = slice, X·Y = in-slice 256² grid (256=4⁴ hierarchical) | direct | +| **helix** | **2 helices** + reserved | helix-pos (dir from origin 0,0,0, 3 B) · helix-normal (self orientation, 3 B) · depth derivable by trig from XYZ | direct decode | +| material | **codebook index** | Doppler flow class (low-res artery / high-res artery / portal / hepatic-vein / caval) | ClassView → material prototype | +| label | **codebook index** | never raw text | ClassView → text + synonyms | +| edges | **SoA-row refs** | part_of parent / branches / supplies / synonym alias | ClassView | + +Rules that fell out of review: +- **Collusion = a location collision = same geometry ⇒ a ClassView resolution, + not a bug.** (`celiac trunk ≡ celiac artery` = same lumen → ClassView aliases; + the 3 celiac branches have distinct XYZ → distinct, linked as children.) + Bilateral pairs are unique by x-sign for free. +- **Relationships reference the SoA row (linked identity), never embed a + neighbour's location/helix.** Edge says *who*; row says *where/what/oriented*. +- **64k⁶ = (256³)⁴** — same space; XYZ-bytes factoring is the slicer-native one. +- **Tubes:** normal is radial from the centerline ⇒ helix-normal derivable by trig + from XYZ + the part_of branch-tree centerline; slicer fills cylindrically + (depth-along-axis · helix-angle · radius). Explicit helix bytes only for + non-radial surfaces (sheets/capsules). +- **Material fill** densifies the *surface* (slicer-style), per Doppler class. +- **Render:** Gouraud shading; **bgz17/Base17 (#17) palette drives the alpha / + transparency** channel (17 levels). Keep **6+ M polygons** (NO decimation to + 1.6 M yet — LOD pyramid is later). +- compute server-side (ndarray native SIMD); deno_core/V8 is the *document* JS + engine, never in the 3D path. + +## Shipped increments (2026-06-28) + +### Vessel "inflatable tube" fix — `fill_body_soa.py` +The slicer-fill cores ballooned where vessels curve: the radius was the +perpendicular distance from the **global** PCA axis, so a point on a bend sits +far off the straight axis → radius inflates. Fixed: bin the points along the +axis, then derive each ring from its **own bin** — centroid = bin's local centre +(follows the curve), radius = **median** perpendicular distance from *that* bin +centroid (robust to outliers), clamped to an **absolute** `[RMIN, RMAX]` +diameter boundary (`RMAX=0.020` ≈ 34 mm dia, covers the aorta, kills balloons). +662 vessels → +71,872 core verts / +133,152 tris. + +### Half-precision positions — the "A" brick +> **SUPERSEDED to F16 (BSO2 ver 5).** BF16 (ver 4) was tried first and **rejected**: +> its 7-bit mantissa gave a ~3 mm step near the head (y≈0.85) → a visible staircase +> (Treppeneffekt) on the eye/brain. Shipped format is **F16 / IEEE half (ver 5)** — +> same 6 B/vertex, 10-bit mantissa, ~0.2 mm (measured 0.21 mm max over the wire), no +> staircase. Bake uses ndarray's `F16::from_f32`; renderer widens via a 64K half→f32 +> LUT. `BodyV3.tsx` reads ver 3 (f32) / 4 (BF16) / 5 (F16). gz ≈ 63 MB (vs f32's 80). +> The BF16 description below is retained for history. + +Per-vertex `pos` column was **BF16** (3× u16 LE = 6 B/vertex), half of ver 3's +12 B f32. Conversion via ndarray's sanctioned RNE batch path +(`f32_to_bf16_batch_rne`) on the native bake host (AVX-512/AMX). The renderer +widens back to f32 client-side (`bits << 16` — BF16 is the top 16 bits of f32, so +the widening is exact). Round-trip ≈ 1.4 mm — which turned out to be visible on +small smooth structures, hence the F16 upgrade above. Asset in release +`fma-body-soa-v3-v1` (Dockerfile pulls it same-origin). + +### "B": server-side HHTL-O(1) LOD endpoint — WIRED (de-risked) +cockpit-server can't build in-sandbox (quarto-core→runtimelib is a proxy-blocked +git dep), so B is a blind deploy — de-risked three ways: +1. **Verified core, standalone:** `scratch-fma/bodylod` builds + runs here against + ndarray-only. `build_blocks(wire)` → per-concept `BlockBounds`, `concept_actions` + → `cascade_blocks` (HHTL HEEL→HIP→TWIG→LEAF, O(concepts≈1658), the O(1) + reference). Monotonic LOD on the real BF16 wire: near 1521 ProjectExact / 137 + KeepCoarse → far 211 / 1447. The cockpit-server handler reuses this exact logic. +2. **Tiny embedded asset, not the geometry:** `soabake` bakes `body.blocks` + (1658×16 B = 26 KB: centroid + radius per concept, in the renderer's DISPLAY + space so the client posts its three.js camera directly). cockpit-server + `include_bytes!`s it — no 57 MB startup gunzip, no feature gate. +3. **Opt-in client, default OFF:** `BodyV3.tsx` keeps the full render; a "server + LOD" toggle (default off) posts the throttled camera to `/api/body/lod`, writes + the per-concept `HhtlAction` bytes into a 1658-px R8 `DataTexture`, and the + frag shader discards Reject concepts (gated by `uLodOn`). If the endpoint 404s + (old deploy) or errors, it silently falls back to the full render. So a wrong + cull (camera-space math is unverifiable here) only ever shows when the user + flips the toggle — never by default. + +Files: `crates/cockpit-server/src/body_lod.rs` (+ route in `main.rs`, `splat3d` +feature in `Cargo.toml`, `assets/body.blocks`); `cockpit/src/BodyV3.tsx`. +**Deferred (Phase B pyramid):** with single-LOD geometry the cascade only +distinguishes show/cull, so the win is frustum-culling whole concepts when zoomed +in; switching KeepCoarse → a decimated mesh needs the L1/L2 decimation pyramid. + +## Constraints +- Big baked assets (LOD pyramid, fill) → GitHub Releases (q2 `fma-body-soa-v3-*`), + never git. `cockpit/public/body.soa*` gitignored. +- q2 workspace cargo can't build in-sandbox (proxy-blocked `runtimed` git dep); + ndarray-only crates verify standalone; the server build runs on deploy. +- No model identifier in any committed artifact. diff --git a/claude-notes/research/2026-06-27-helix-orientation-holy-grail.md b/claude-notes/research/2026-06-27-helix-orientation-holy-grail.md new file mode 100644 index 000000000..e7a06070f --- /dev/null +++ b/claude-notes/research/2026-06-27-helix-orientation-holy-grail.md @@ -0,0 +1,47 @@ +# Helix orientation — deterministic, comparable-without-materialization (measured) + +**Date:** 2026-06-27 · **Branch:** claude/q2-fma-v3-bake · **Data:** real torso.mesh / torso.splat + +## Claim, measured (not asserted) +A surfel/gaussian's orientation encodes as a **1–3 byte deterministic helix code** — residual-VQ +on the sphere (the same RVQ machinery as palette256, on S²; decode is **Fisher-2z normalized**). +It is **comparable in O(1) LUT without materializing the vector**, and replaces a trained 3DGS +quaternion (16 B, per-scene) with no training. + +| metric | 1 byte | 2 bytes | 3 bytes | +|---|---|---|---| +| encode error (real surfel normals) | 4.87° | 0.97° | **0.073°** | +| render PSNR vs original (turntable, Lambert, conservative) | 48.3 dB | — | **84.5 dB** | +| effective directions | 256 | ~65 K | ~16.7 M | + +- **8192-dir ("8K") target = 2.244°** → beaten at **2 bytes**. +- **Compare-without-materialization** (80 K pairs, LUT on codes vs true angle): **Pearson 0.9917 / Spearman 0.9924**, encode 4.86°, cost = 2 byte loads + 1 table lookup (no decode/dot/acos), storage 1 B vs 12 B. +- Tool: `crates/osint-bake/tools/helix_orient.py` (self-tests to 0.073° at 3 bytes). + +## The unified representation (the grail) +A surfel/gaussian is an **address**, not a trained blob — every op runs in normalized LUT space: + +| component | codec | manifold | cost | replaces | +|---|---|---|---|---| +| position | HHTL `Located` (`ogar-fma-skeleton`) | ℝ³ Morton | 6 B | xyz / Cesium tile coords | +| **orientation** | **helix** (Fisher-2z) | **S²** | **3 B @ 0.07°** | trained quaternion (16 B) | +| scale / magnitude | palette256 (Fisher-Z) | ℝ | 1 B | trained anisotropic scale | +| pairwise / edges | turbovec (`lance-graph-turbovec`) | graph | 1–16 B | adjacency / KNN | +| LOD | `ndarray hpc/splat3d/depth_cascade.rs` (Cesium SSE) | — | — | screen-space refinement | +| volume infill | TPMS gyroid (closed-form) | — | 1 B density | stored interior voxels | + +~236 B trained gaussian → ~12 B deterministic codes (~20×), no training, comparable in O(1). + +## Epiphanies (each grounded) +1. **helix and palette256 are ONE codec on two manifolds** — Fisher-Z RVQ on the line vs Fisher-2z RVQ on the sphere; the "2z" is the sphere's extra DOF. Proven by building helix as exact RVQ (4.87°→0.073° in three residual bytes). +2. **Comparison-without-materialization is the universal op** — normalized decode ⟹ all distance/sort/cull/LOD in O(1) LUTs on bytes; never reconstruct until render. The bottleneck 3DGS (matrix builds) and Cesium (normal decode) both hit. +3. **Cartography = Cesium = gaussian-splat** — all are position+orientation+scale-on-a-manifold-with-LOD; the only missing piece was a deterministic, comparable orientation code. Helix completes all three. +4. **No-training** — where geometry exists, every code is deterministic (encode = O(1) inverse placement). Precondition for planet-scale and for *generating* anatomy. + +## Honest edges +- Pixel parity above is **Lambert** (orientation→brightness, a conservative bound; the actual EWA footprint effect is smaller). The remaining end-to-end test is the **real `splat3d` EWA render** (PSNR/SSIM), wired in Rust — verify on Railway (ndarray builds there). +- Helix **sidesteps** the image→geometry inverse problem (where you have geometry it's deterministic); it does not abolish it (pure photographic 3DGS still needs the geometry step first). + +## Wiring status +- `helix_orient.py` (codec + parity harness) committed. +- Rust wiring = a one-line helix decode in the SPL3→`Gaussian3D` build path (`ndarray hpc/splat3d`), so each splat's orientation is the 3-byte code, not a stored normal. Build/verify on Railway. diff --git a/cockpit/public/fma.soa b/cockpit/public/fma.soa index 6269c5b27..c720e336a 100644 Binary files a/cockpit/public/fma.soa and b/cockpit/public/fma.soa differ diff --git a/cockpit/src/BodyV3.tsx b/cockpit/src/BodyV3.tsx new file mode 100644 index 000000000..32f729b40 --- /dev/null +++ b/cockpit/src/BodyV3.tsx @@ -0,0 +1,495 @@ +// FMA body · full-resolution SoA (BSO2 v3) · Gouraud · #17-palette alpha · compartments. +// +// Reads the option-2 rebake wire `body.soa` (BSO2): SoA columns, the two-GUID design +// (address = classid 0x1000 + (part_of:is_a) 8:8 + identity; location = XYZ; helix +// server-side). material + label + LAYER are codebook indices (never raw text). +// Renders the full 6.68 M-triangle body: Gouraud, #17-palette alpha (Doppler material), +// and an 8-LAYER compartment menu (skin/muscle/organ/skeleton/vessel/nerve/connective/ +// other) so the skin shell can be toggled away to reveal the anatomy. +// +// BSO2 (LE): magic "BSO2" | ver u16 | nC u32 | nV u32 | nT u32 +// | GUID[16·nC] | material u8[nC] | LAYER u8[nC] | label u32[nC] | centroid 3f[nC] +// | (vstart,vcount) 2u32[nC] | pos[nV] | helix 6B[nV] | row u32[nV] | idx 3u32[nT] +// | labels_json (u32 len + utf8) | materials_json (u32 len + utf8) +// pos column: ver 3 = 3×f32 (12 B/vertex); ver 4 = 3×BF16; ver 5 = 3×F16 / IEEE +// half (6 B/vertex, widened to f32 client-side via a 64K LUT). F16's 10-bit mantissa +// avoids the BF16 staircase (~3 mm steps near the head). This decoder reads all three. +// +// Data: BodyParts3D, (c) The Database Center for Life Science, CC-BY 4.0. +import { useEffect, useMemo, useRef, useState } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +const PAGE_BG = 0x0a0e17; +const FMA_V3_CLASSID = 0x10000a01; + +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: 'nervous', color: '#ebd152' }, + { id: 7, name: 'connective', color: '#e0dbcc' }, + { id: 8, name: 'other', color: '#9696a0' }, +]; + +interface Material { id: number; name: string; doppler: string; rgb: [number, number, number] } + +// bgz17 / Base17 "#17" palette → alpha: transparency quantized to 17 levels (0..16)/16. +const P17 = (lvl: number) => Math.max(0, Math.min(16, lvl)) / 16; +const MATERIAL_ALPHA_LEVEL: Record = { + low_resistance_artery: 13, high_resistance_artery: 13, portal_venous: 11, + systemic_venous: 11, solid_tissue: 16, neural: 15, +}; + +const hexRgb = (h: string): [number, number, number] => + [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; +const LAYER_RGB: Record = Object.fromEntries(LAYERS.map((l) => [l.id, hexRgb(l.color)])); +const frac = (x: number) => x - Math.floor(x); +// deterministic per-concept colour: vessels keep the Doppler material rgb (artery red / +// vein blue); every other layer is tinted from its LAYER_RGB base (skeleton = bone, organ +// = warm, …) with a per-concept brightness + slight per-channel tilt so adjacent organs / +// bones read as distinct shades instead of one flat colour. +function conceptColor(layerId: number, matRgb: [number, number, number], row: number): [number, number, number] { + if (layerId === 5) return matRgb; // vessels → Doppler material colour + const base = LAYER_RGB[layerId] ?? [150, 150, 160]; + const h = frac(Math.sin(row * 12.9898) * 43758.5453); // stable hash in [0,1) + const bright = 0.82 + 0.34 * h; + const tilt = (s: number) => 1 + 0.13 * (frac(Math.sin(row * s) * 9711.13) - 0.5) * 2; + const out: [number, number, number] = [base[0] * bright * tilt(1.7), base[1] * bright * tilt(2.9), base[2] * bright * tilt(4.1)]; + return [Math.min(255, out[0]), Math.min(255, out[1]), Math.min(255, out[2])]; +} + +interface ConceptInfo { row: number; name: string; centroid: [number, number, number]; layer: number; material: string } + +// 64K IEEE-half → f32 lookup (built once): ver-5 wire stores positions as F16 +// (10-bit mantissa, ~0.2 mm here — no BF16 staircase). LUT keeps decode O(1)/vertex. +const HALF_LUT: Float32Array = (() => { + const t = new Float32Array(65536); + for (let h = 0; h < 65536; h++) { + const s = (h & 0x8000) ? -1 : 1, e = (h & 0x7c00) >> 10, f = h & 0x03ff; + t[h] = e === 0 ? s * Math.pow(2, -14) * (f / 1024) + : e === 0x1f ? (f ? NaN : s * Infinity) + : s * Math.pow(2, e - 15) * (1 + f / 1024); + } + return t; +})(); + +interface Decoded { + nConcepts: number; nVerts: number; nTris: number; classid: number; + positions: Float32Array; index: Uint32Array; opaqueTris: number; + colors: Uint8Array; alpha: Float32Array; layer: Float32Array; row: Float32Array; + materials: Material[]; labels: string[]; concepts: ConceptInfo[]; +} +interface RenderState { enabled: Float32Array; alpha: number; transparent: boolean; lodOn: boolean; focus: { t: [number, number, number]; d: number } | null } + +function decodeBso2(buf: ArrayBuffer): Decoded { + const dv = new DataView(buf); + const magic = String.fromCharCode(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3)); + if (magic !== 'BSO2') throw new Error(`bad magic "${magic}" (expected BSO2)`); + const ver = dv.getUint16(4, true); + const posBytes = ver >= 4 ? 6 : 12; // ver 4 (BF16) / ver 5 (F16) = 2 B/comp; ver 3 = f32 + const nC = dv.getUint32(6, true), nV = dv.getUint32(10, true), nT = dv.getUint32(14, true); + let o = 18; + const guidOff = o; o += 16 * nC; + const matOff = o; o += nC; // material codebook index u8 + const layerOff = o; o += nC; // LAYER index u8 (1..8) + const labOff = o; o += 4 * nC; // label codebook index u32 + const cenOff = o; o += 12 * nC; // per-concept centroid 3×f32 (search zoom + server LOD) + o += 8 * nC; // (vstart,vcount) + const posOff = o; o += posBytes * nV; + o += 6 * nV; // helix (server-side) + const rowOff = o; o += 4 * nV; + const idxOff = o; o += 12 * nT; + const labLen = dv.getUint32(o, true); o += 4; + const labels: string[] = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, o, labLen))); o += labLen; + const matLen = dv.getUint32(o, true); o += 4; + const materials: Material[] = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, o, matLen))); + + const classid = dv.getUint32(guidOff, true); + const cMat = new Uint8Array(buf.slice(matOff, matOff + nC)); + const cLayer = new Uint8Array(buf.slice(layerOff, layerOff + nC)); + const cNameIdx = new Uint32Array(buf.slice(labOff, labOff + 4 * nC)); + const cCen = new Float32Array(buf.slice(cenOff, cenOff + 12 * nC)); // bake-space; remap below + + // per-concept colour (precompute once) + searchable concept table (name + centroid). + const conceptRgb: [number, number, number][] = new Array(nC); + const concepts: ConceptInfo[] = new Array(nC); + for (let cI = 0; cI < nC; cI++) { + const mat = materials[cMat[cI]] ?? materials[materials.length - 1]; + const li = cLayer[cI] || 8; + conceptRgb[cI] = conceptColor(li, mat.rgb, cI); + concepts[cI] = { + row: cI, name: labels[cNameIdx[cI]] ?? `concept ${cI}`, layer: li, material: mat.name, + centroid: [-cCen[cI * 3], cCen[cI * 3 + 2], cCen[cI * 3 + 1]], // (x,y,z)->(-x,z,y) display + }; + } + + // pos → f32 working array. ver 5 = F16 (IEEE half) widened via the 64K LUT; ver 4 = + // BF16 widened via bits<<16 (BF16 is the top 16 bits of an f32 — exact widening); + // ver 3 = raw f32. buf.slice → a fresh 4-aligned buffer (posOff isn't guaranteed + // 4-aligned, and a Float32Array *view* requires 4-alignment; slice copies + aligns). + let srcPos: Float32Array; + if (ver >= 5) { + const hf = new Uint16Array(buf.slice(posOff, posOff + nV * 6)); + srcPos = new Float32Array(nV * 3); + for (let k = 0; k < hf.length; k++) srcPos[k] = HALF_LUT[hf[k]]; + } else if (ver === 4) { + const bf = new Uint16Array(buf.slice(posOff, posOff + nV * 6)); + const widened = new Uint32Array(nV * 3); + for (let k = 0; k < bf.length; k++) widened[k] = bf[k] << 16; + srcPos = new Float32Array(widened.buffer); + } else { + srcPos = new Float32Array(buf.slice(posOff, posOff + nV * 12)); + } + const rowArr = new Uint32Array(buf.slice(rowOff, rowOff + 4 * nV)); + const positions = new Float32Array(nV * 3); + const colors = new Uint8Array(nV * 3); + const alpha = new Float32Array(nV); + const layer = new Float32Array(nV); + const row = new Float32Array(nV); // concept index per vertex → server-LOD gate + for (let i = 0; i < nV; i++) { + positions[i * 3] = -srcPos[i * 3]; // (x,y,z)->(-x,z,y) head-up + positions[i * 3 + 1] = srcPos[i * 3 + 2]; + positions[i * 3 + 2] = srcPos[i * 3 + 1]; + const r = rowArr[i]; + const m = materials[cMat[r]] ?? materials[materials.length - 1]; + const rgb = conceptRgb[r]; + colors[i * 3] = rgb[0]; colors[i * 3 + 1] = rgb[1]; colors[i * 3 + 2] = rgb[2]; + alpha[i] = P17(MATERIAL_ALPHA_LEVEL[m.name] ?? 16); + layer[i] = cLayer[r] || 8; + row[i] = r; + } + // partition triangles: opaque (all 3 verts α≈1 — solids) first, then transparent + // (vessels/#17 α<1). Lets the renderer draw opaque fast (early-Z, no blend) and + // only blend the ~translucent minority — ~2× fps without dropping any triangle. + const raw = new Uint32Array(buf.slice(idxOff, idxOff + 12 * nT)); + const index = new Uint32Array(nT * 3); + let op = 0, tr = nT * 3; + for (let t = 0; t < nT; t++) { + const a = t * 3, x = raw[a], y = raw[a + 1], z = raw[a + 2]; + const opaque = alpha[x] >= 0.999 && alpha[y] >= 0.999 && alpha[z] >= 0.999; + if (opaque) { index[op] = x; index[op + 1] = y; index[op + 2] = z; op += 3; } + else { tr -= 3; index[tr] = x; index[tr + 1] = y; index[tr + 2] = z; } + } + const opaqueTris = op / 3; + return { nConcepts: nC, nVerts: nV, nTris: nT, classid, positions, index, opaqueTris, colors, alpha, layer, row, materials, labels, concepts }; +} + +const VERT = ` +attribute vec3 aColor; attribute float aAlpha; attribute float aLayer; attribute highp float aRow; +varying vec3 vNormal; varying vec3 vColor; varying float vAlpha; varying float vLayer; varying highp float vRow; +void main(){ vNormal = normalMatrix * normal; vColor = aColor; vAlpha = aAlpha; vLayer = aLayer; vRow = aRow; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`; +const FRAG = ` +precision mediump float; +uniform float uEnabled[9]; uniform float uGlobalAlpha; +uniform sampler2D uLod; uniform highp float uLodN; uniform float uLodOn; // server HHTL LOD gate +varying vec3 vNormal; varying vec3 vColor; varying float vAlpha; varying float vLayer; +varying highp float vRow; // highp: concept IDs up to ~1658 + the texel-center divide must + // resolve exactly; mediump's min precision aliases adjacent rows. +void main(){ + int li = int(vLayer + 0.5); + if(li < 1 || li > 8 || uEnabled[li] < 0.5) discard; // compartment gate + if(uLodOn > 0.5){ // server LOD: HhtlAction 0=Reject ⇒ cull + highp vec2 uv = vec2((vRow + 0.5) / uLodN, 0.5); + float act = texture2D(uLod, uv).r; + if(act < 0.002) discard; // 0/255 = Reject + } + vec3 n = normalize(vNormal); if(!gl_FrontFacing) n = -n; + const vec3 L = vec3(-0.401,0.783,0.476); + float ndl = max(dot(n,L),0.0); + float shade = min(0.34 + 0.20*(n.y*0.5+0.5) + 0.12*(-n.x*0.5+0.5) + 0.92*ndl, 1.3); + gl_FragColor = vec4(vColor*shade, vAlpha * uGlobalAlpha); // #17 alpha × solid/transparent +}`; + +function mount(container: HTMLDivElement, d: Decoded, st: RenderState, onStats: (s: { fps: number }) => void): () => void { + let w = container.clientWidth || window.innerWidth, h = container.clientHeight || window.innerHeight; + const scene = new THREE.Scene(); scene.background = new THREE.Color(PAGE_BG); + const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100); camera.position.set(0, 0.05, 3.0); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(w, h); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + container.appendChild(renderer.domElement); + + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.BufferAttribute(d.positions, 3)); + geom.setAttribute('aColor', new THREE.BufferAttribute(d.colors, 3, true)); + geom.setAttribute('aAlpha', new THREE.BufferAttribute(d.alpha, 1)); + geom.setAttribute('aLayer', new THREE.BufferAttribute(d.layer, 1)); + geom.setAttribute('aRow', new THREE.BufferAttribute(d.row, 1)); + geom.setIndex(new THREE.BufferAttribute(d.index, 1)); + geom.computeVertexNormals(); + + // server-LOD action texture: 1 px per concept, init 255 (= show all). RGBA8 (not R8 / + // RedFormat) so it's a complete, universally-supported sampler — an incomplete R8 + // texture samples as 0, which the shader reads as Reject ⇒ EVERYTHING discarded ⇒ + // black screen. Action byte lives in .r; nearest filter (no interpolation across + // concept cells). + const lodData = new Uint8Array(d.nConcepts * 4).fill(255); + const lodTex = new THREE.DataTexture(lodData, d.nConcepts, 1, THREE.RGBAFormat, THREE.UnsignedByteType); + lodTex.magFilter = THREE.NearestFilter; lodTex.minFilter = THREE.NearestFilter; + lodTex.needsUpdate = true; + // two draw groups over the partitioned index: [0]=opaque solids (fast), + // [1]=translucent vessels (blended, drawn after). + geom.clearGroups(); + geom.addGroup(0, d.opaqueTris * 3, 0); + geom.addGroup(d.opaqueTris * 3, (d.nTris - d.opaqueTris) * 3, 1); + const uniforms = { + uEnabled: { value: st.enabled }, uGlobalAlpha: { value: st.alpha }, + uLod: { value: lodTex }, uLodN: { value: d.nConcepts }, uLodOn: { value: 0 }, + }; + // solid mode: opaque solids draw fast (transparent:false), #17 vessels blend over + // them. transparent mode (port of /fma-body's uniform-uAlpha x-ray): BOTH groups go + // translucent — uGlobalAlpha (0.42) drops the whole body to see-through, not just + // the #17-alpha organs. depthWrite off when translucent so interior shows through. + const opaqueMat = new THREE.ShaderMaterial({ + vertexShader: VERT, fragmentShader: FRAG, uniforms, + side: THREE.DoubleSide, transparent: st.transparent, depthWrite: !st.transparent, + }); + const transMat = new THREE.ShaderMaterial({ + vertexShader: VERT, fragmentShader: FRAG, uniforms, + side: THREE.DoubleSide, transparent: true, depthWrite: false, + }); + scene.add(new THREE.Mesh(geom, [opaqueMat, transMat])); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; controls.dampingFactor = 0.08; + controls.autoRotate = true; controls.autoRotateSpeed = 0.6; + controls.minDistance = 0.6; controls.maxDistance = 12; + + // throttled server-LOD poll: post the live camera (in display space — block bounds + // are baked in the same space), write the per-concept action bytes into lodTex. + let lodNext = 0, lodInflight = false, lodFail = false, lodReady = false, lodWarned = false; + const postLod = (now: number) => { + if (!st.lodOn || lodFail || lodInflight || now < lodNext) return; + lodInflight = true; lodNext = now + 220; + camera.updateMatrixWorld(); + const e = camera.matrixWorldInverse.elements; // column-major → row-major view + const view = [ + [e[0], e[4], e[8], e[12]], [e[1], e[5], e[9], e[13]], + [e[2], e[6], e[10], e[14]], [e[3], e[7], e[11], e[15]], + ]; + const fy = (h / 2) / Math.tan((camera.fov * Math.PI) / 360); + const body = { + view, fx: fy, fy, cx: w / 2, cy: h / 2, near: camera.near, far: camera.far, + width: w, height: h, position: [camera.position.x, camera.position.y, camera.position.z], + }; + fetch('/api/body/lod', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) + .then((j: { actions: number[]; n_concepts?: number; tally?: number[] }) => { + const a = j.actions; + const visible = (j.n_concepts ?? a.length) - (j.tally?.[0] ?? 0); + // all (or nearly all) Reject ⇒ the cascade culled the whole body, which only + // happens if the camera mapping is off. Don't black out — show everything. + const degenerate = visible <= Math.max(1, a.length * 0.02); + if (degenerate && !lodWarned) { console.warn('[body LOD] cascade rejected ~all concepts — showing all (camera mapping suspect)'); lodWarned = true; } + for (let i = 0; i < d.nConcepts && i < a.length; i++) lodData[i * 4] = degenerate ? 255 : a[i]; + lodReady = true; + lodTex.needsUpdate = true; + }) + .catch(() => { lodFail = true; }) // endpoint absent (old deploy) → silently keep full render + .finally(() => { lodInflight = false; }); + }; + + let raf = 0, ema = 16.6, last = performance.now(), since = 0, wasT = st.transparent; + const tmp = new THREE.Vector3(); + 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); + uniforms.uEnabled.value = st.enabled; // shared by both materials + uniforms.uGlobalAlpha.value = st.alpha; + if (!st.lodOn) lodFail = false; // toggling LOD off clears a transient failure → re-enabling retries + uniforms.uLodOn.value = st.lodOn && !lodFail && lodReady ? 1 : 0; // only cull after a real response + if (st.focus) { // search-pick zoom: glide to the organ + tmp.set(st.focus.t[0], st.focus.t[1], st.focus.t[2]); + controls.target.lerp(tmp, 0.12); + const dir = camera.position.clone().sub(controls.target).normalize(); + camera.position.lerp(tmp.clone().add(dir.multiplyScalar(st.focus.d)), 0.12); + } + if (st.transparent !== wasT) { + // flip ONLY the solid group into the x-ray (whole-body translucent) and back. + // transMat is the always-blended #17 vessel pass — it must keep depthWrite OFF + // in both modes, or its unsorted triangles self-occlude later transparent ones. + opaqueMat.transparent = st.transparent; opaqueMat.depthWrite = !st.transparent; opaqueMat.needsUpdate = true; + wasT = st.transparent; + } + postLod(now); + controls.update(); renderer.render(scene, camera); + if (++since >= 20) { since = 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(); lodTex.dispose(); opaqueMat.dispose(); transMat.dispose(); renderer.dispose(); + if (renderer.domElement.parentNode === container) container.removeChild(renderer.domElement); + }; +} + +const REL = 'https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1'; + +export function BodyV3() { + const ref = useRef(null); + const [d, setD] = useState(null); + const [error, setError] = useState(null); + const [stats, setStats] = useState<{ fps: number } | null>(null); + // skin (1) off by default so the anatomy shows. + 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); // default solid (like /fma-body) + const [lod, setLod] = useState(false); // server HHTL LOD — opt-in (off = today's full render) + const [query, setQuery] = useState(''); + const [selected, setSelected] = useState(null); + const stRef = useRef({ enabled: new Float32Array([0, 0, 1, 1, 1, 1, 1, 1, 1]), alpha: 1, transparent: false, lodOn: false, focus: null }); + + 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; + // /fma-body translucency model: one uniform alpha for the WHOLE body. transparent + // ⇒ 0.42 x-ray (see through skin/muscle to organs); solid ⇒ 1.0 (#17 vessels only). + stRef.current.alpha = transparent ? 0.42 : 1.0; + stRef.current.lodOn = lod; + }, [on, transparent, lod]); + + useEffect(() => { + let cancelled = false; + // Decide by the *payload*, not by DecompressionStream support: if the browser (or a + // CDN) already decoded Content-Encoding: gzip, the bytes are plain; if served raw, + // they start with the gzip magic 1f 8b. Only inflate when actually gzipped. + const inflate = async (r: Response): Promise => { + const buf = await r.arrayBuffer(); + const u8 = new Uint8Array(buf); + const gz = u8.length > 1 && u8[0] === 0x1f && u8[1] === 0x8b; + if (!gz) return buf; + if (typeof DecompressionStream === 'undefined') + throw new Error('body.soa.gz is gzip but this browser lacks DecompressionStream'); + return new Response(new Blob([buf]).stream().pipeThrough(new DecompressionStream('gzip'))).arrayBuffer(); + }; + (async () => { + const local = await fetch('/body.soa.gz').catch(() => null); + if (local && local.ok) return decodeBso2(await inflate(local)); + const rel = await fetch(`${REL}/body.soa.gz`); + if (!rel.ok) throw new Error(`HTTP ${rel.status} fetching body.soa.gz`); + return decodeBso2(await inflate(rel)); + })().then((x) => { if (!cancelled) setD(x); }).catch((e) => { if (!cancelled) setError(String(e)); }); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { const c = ref.current; if (!c || !d) return; return mount(c, d, stRef.current, setStats); }, [d]); + + // search the label codebook (concept names) → ranked matches. + const matches = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q || !d) return [] as ConceptInfo[]; + const hits = d.concepts.filter((c) => c.name.toLowerCase().includes(q)); + hits.sort((a, b) => a.name.length - b.name.length); // shortest (closest) name first + return hits.slice(0, 14); + }, [query, d]); + + function pick(c: ConceptInfo) { + setSelected(c); + stRef.current.focus = { t: c.centroid, d: 0.6 }; // glide the camera to the organ + } + + 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 · SoA · Gouraud · compartments
+
+ {d ? `${d.nTris.toLocaleString()} triangles · ${d.nVerts.toLocaleString()} vertices — ALL points, drag to orbit` + : error ? '' : 'loading body.soa (BSO2)…'} +
+ {d && ( +
+ {d.nConcepts.toLocaleString()} concepts ·{' '} + {d.classid === FMA_V3_CLASSID + ? classid 0x{d.classid.toString(16)} ✓ V3 (part_of:is_a) + : classid 0x{d.classid.toString(16)}} +
+ )} + {stats &&
{stats.fps} fps · {transparent ? 'x-ray (whole body 0.42)' : 'solid · #17 vessels'}
} +
+ + {/* search the label codebook + detail side window (left) */} + {d && ( +
+ setQuery(e.target.value)} + placeholder={`search ${d.nConcepts.toLocaleString()} labels — e.g. "liver"`} + style={{ width: '100%', boxSizing: 'border-box', padding: '7px 9px', borderRadius: 6, border: '1px solid #2a3242', background: '#0e1219', color: '#cdd9e5', font: '13px ui-monospace, monospace' }} + /> + {matches.length > 0 && ( +
+ {matches.map((c) => ( + + ))} +
+ )} + {selected && ( +
+
{selected.name}
+
+ compartment + {LAYERS[(selected.layer - 1) % 8]?.name} + material + {selected.material.replace(/_/g, ' ')} + row + #{selected.row} + centroid + {selected.centroid.map((v) => v.toFixed(2)).join(', ')} +
+
+ + +
+
+ )} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* 8-compartment toggles + solid/transparent (right) */} +
+
+ {LAYERS.map((l) => ( + + ))} +
+ + + 2k layered → +
+ +
+ 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 7871ea8db..d821c820d 100644 --- a/cockpit/src/main.tsx +++ b/cockpit/src/main.tsx @@ -13,6 +13,7 @@ import { TorsoSplat } from './TorsoSplat'; import { TorsoRender } from './TorsoRender'; import { TorsoMap } from './TorsoMap'; import { FmaBody } from './FmaBody'; +import { BodyV3 } from './BodyV3'; import { CpicCockpit } from './CpicCockpit'; import { ReasoningPage } from './ReasoningPage'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -98,6 +99,12 @@ createRoot(document.getElementById('root')!).render( LAYER (skin/muscle/organ/skeleton/vessel/nerve buttons) + solid↔transparent. Additive; reads cockpit/public/fma_body.mesh; never touches /torso* (#57/#58). */} } /> + {/* /body — the FULL-RESOLUTION FMA body on the V3 substrate: ALL points + (4.2 M-vert / 6.7 M-tri BodyParts3D surface, no decimation), every concept + minted on the CLASSID_FMA_V3 (part_of:is_a) cascade. Reads the pre-baked + cockpit/public/body.soa (BSO1 = V3 node table + SPM1 geometry). Polygons, + not surfels — the successor to /torso-live's decimated 2k-concept torso. */} + } /> {/* /cpic — CPIC pharmacogenomics cockpit (gene-first): {gene, diplotype, drug} → phenotype → recommendation, 2-hop NARS deduction over the real CPIC tables via POST /api/cpic/reason (the standalone cpic crate). Additive, gene-first diff --git a/crates/cockpit-server/Cargo.toml b/crates/cockpit-server/Cargo.toml index 23e4eb1d3..c42d03b76 100644 --- a/crates/cockpit-server/Cargo.toml +++ b/crates/cockpit-server/Cargo.toml @@ -60,8 +60,16 @@ quarto-system-runtime.workspace = true deno_core.workspace = true serde_v8.workspace = true -# ── SIMD compute ──────────────────────────────────────────────────── -q2-ndarray.workspace = true +# ── SIMD compute — the REAL AdaWorldAPI/ndarray fork, used DIRECTLY ── +# Same local checkout lance-graph compiles. SIMD (dot/hamming/popcount, BLAS +# L1-L3, CAM-PQ) goes through `ndarray::simd` / `ndarray::backend` / `ndarray::hpc` +# — the fork's own AVX-512→AVX2→scalar polyfill (compile-time target-cpu=v4 flips +# cfg(target_feature="avx512f"); runtime simd_caps() lights up AMX/BF16). No +# q2-ndarray wrapper: one binary, one fork, no indirection. +# `splat3d` adds the HHTL depth-cascade (`ndarray::hpc::splat3d::depth_cascade`) +# used by /api/body/lod for server-side LOD selection. It only pulls `std` +# (no extra dep tree), merged on top of the workspace `std`+`hpc-extras`. +ndarray = { workspace = true, features = ["splat3d"] } # ── Embedded frontend ────────────────────────────────────────── # When embed-cockpit feature is on, the Vite React build (cockpit/dist/) diff --git a/crates/cockpit-server/assets/body.blocks b/crates/cockpit-server/assets/body.blocks new file mode 100644 index 000000000..5f30286d6 Binary files /dev/null and b/crates/cockpit-server/assets/body.blocks differ diff --git a/crates/cockpit-server/src/body_lod.rs b/crates/cockpit-server/src/body_lod.rs new file mode 100644 index 000000000..282058719 --- /dev/null +++ b/crates/cockpit-server/src/body_lod.rs @@ -0,0 +1,91 @@ +//! `/api/body/lod` — server-side HHTL depth-cascade LOD for the `/body` viewer. +//! +//! Option-2 (compute server-side): the wasm `F32x16` backend is an un-wired stub, +//! so the per-frame LOD cascade runs here on native x86 (full SIMD). The viewer has +//! the full geometry already; this endpoint returns, per concept, an `HhtlAction` +//! byte (Reject / KeepCoarse / Refine / ProjectExact / RenderExact) for the posted +//! camera. The client gates each concept's draw by that byte (Reject ⇒ cull) — so +//! when zoomed in, off-frustum concepts stop drawing. O(concepts ≈ 1658) — the HHTL +//! O(1) reference, NOT O(verts). +//! +//! The cascade core is verified standalone in `scratch-fma/bodylod` (cockpit-server +//! can't build in-sandbox: its quarto-core→runtimelib git dep is proxy-blocked). +//! The per-concept `BlockBounds` (centroid + radius) are baked by `soabake` into the +//! 26 KB `assets/body.blocks` asset embedded below — NOT the 57 MB geometry. +use std::sync::LazyLock; + +use axum::Json; +use ndarray::hpc::splat3d::depth_cascade::{cascade_blocks, BlockBounds, DepthCascadeBudget}; +use ndarray::hpc::splat3d::project::Camera; +use serde::{Deserialize, Serialize}; + +/// `nc × (center[3] f32 | radius f32)`, little-endian — baked by `soabake`. +static BODY_BLOCKS: &[u8] = + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/body.blocks")); + +static BLOCKS: LazyLock> = LazyLock::new(|| { + BODY_BLOCKS + .chunks_exact(16) + .map(|c| { + let f = |k: usize| f32::from_le_bytes(c[k * 4..k * 4 + 4].try_into().unwrap()); + BlockBounds { center: [f(0), f(1), f(2)], radius: f(3).max(1e-4) } + }) + .collect() +}); + +/// Camera posted by the viewer (mirrors `ndarray::…::project::Camera`). +#[derive(Debug, Deserialize)] +pub struct LodCamera { + pub view: [[f32; 4]; 4], + pub fx: f32, + pub fy: f32, + pub cx: f32, + pub cy: f32, + pub near: f32, + pub far: f32, + pub width: u32, + pub height: u32, + pub position: [f32; 3], +} + +#[derive(Debug, Serialize)] +pub struct LodResponse { + /// One `HhtlAction` byte per concept, in concept (row) order. + pub actions: Vec, + pub n_concepts: usize, + /// Tally [Reject, KeepCoarse, Refine, ProjectExact, RenderExact]. + pub tally: [usize; 5], +} + +/// POST `/api/body/lod` { camera } → per-concept LOD action bytes. +pub async fn body_lod_handler(Json(cam): Json) -> Json { + let camera = Camera { + view: cam.view, + fx: cam.fx, + fy: cam.fy, + cx: cam.cx, + cy: cam.cy, + near: cam.near, + far: cam.far, + width: cam.width.max(1), + height: cam.height.max(1), + position: cam.position, + }; + let budget = DepthCascadeBudget::default(); + let blocks: &[BlockBounds] = &BLOCKS; + let mut decisions = Vec::with_capacity(blocks.len()); + cascade_blocks(&camera, blocks, &budget, &mut decisions); + + let mut actions = vec![0u8; BLOCKS.len()]; + let mut tally = [0usize; 5]; + for d in &decisions { + let a = d.action as usize; + if d.block_index < actions.len() { + actions[d.block_index] = a as u8; + } + if a < 5 { + tally[a] += 1; + } + } + Json(LodResponse { actions, n_concepts: BLOCKS.len(), tally }) +} diff --git a/crates/cockpit-server/src/main.rs b/crates/cockpit-server/src/main.rs index ebc493d4b..bf6078009 100644 --- a/crates/cockpit-server/src/main.rs +++ b/crates/cockpit-server/src/main.rs @@ -28,6 +28,7 @@ use tokio::sync::broadcast; use tower_http::cors::CorsLayer; mod openai; +mod body_lod; mod graph_engine; mod clinical; mod pgx; @@ -181,6 +182,9 @@ async fn main() { // Pre-baked enriched OSINT SoA bytes — the 3D view (/osint3d) fetches // these and decodes each GUID → xyz client-side (no JSON). .route("/osint.soa", get(osint_soa_handler)) + // /body server-side HHTL LOD — POST camera → per-concept HhtlAction byte + // (cascade over 1658 baked BlockBounds; native SIMD; client gates draw by it). + .route("/api/body/lod", post(body_lod::body_lod_handler)) // Health .route("/health", get(health_handler)); diff --git a/crates/osint-bake/Cargo.toml b/crates/osint-bake/Cargo.toml index 60dd33379..702a96f33 100644 --- a/crates/osint-bake/Cargo.toml +++ b/crates/osint-bake/Cargo.toml @@ -14,7 +14,11 @@ path = "src/main.rs" [dependencies] aiwar-ingest = { path = "../aiwar-ingest" } -lance-graph-contract.workspace = true +# osint-bake is the ONLY crate that mints on the V3 cascade tail (the FMA bake: +# CLASSID_FMA_V3 0x1000_0A01 → ReadMode::FMA_V3, the part_of:is_a reading of the +# SAME leaf·family·identity bytes). guid-v3-tail is requested here, NOT workspace- +# wide, so it doesn't force the feature onto cockpit-server's Railway build. +lance-graph-contract = { workspace = true, features = ["guid-v3-tail"] } serde_json.workspace = true [lints] diff --git a/crates/osint-bake/src/bin/body.rs b/crates/osint-bake/src/bin/body.rs new file mode 100644 index 000000000..11a92504b --- /dev/null +++ b/crates/osint-bake/src/bin/body.rs @@ -0,0 +1,158 @@ +//! Full-body FMA bake — fuses the full-resolution polygon geometry with a +//! `CLASSID_FMA_V3` NodeGuid substrate into `cockpit/public/body.soa`. +//! +//! This is the operator-directed successor to the decimated `torso.mesh` + flat +//! `guid=(container<<16)|identity` torso: ALL points (the 4.2 M-vertex / 6.7 M- +//! triangle BodyParts3D is_a surface, no cell_mm decimation), addressed on the V3 +//! **(part_of:is_a) cascade** — the SAME `mint_for(classid_read_mode(c).tail_variant, +//! …)` path `bin/fma.rs` mints the heart slice on, but over the whole body. +//! +//! Two-stage bake (geometry in Python, V3 minting in Rust — the minting MUST live +//! here because NodeGuid is `lance-graph-contract`): +//! 1. `tools/bake_body_v3.py` → `body.spm1` (full-res SPM1 geometry) + +//! `body.nodes.json` (per-concept is_a cascade sibling-rank chain + tissue/rgb/ +//! geometry range). No decimation: every OBJ vertex survives. +//! 2. THIS bin reads both, mints one V3 NodeGuid per concept (cascade tier +//! `[mixin-by-depth : sibling-rank]`, identity = row), and emits `body.soa`. +//! +//! body.soa wire (BSO1, little-endian) — geometry block is byte-identical SPM1 so the +//! cockpit reuses its SPM1 decoder; the node table is the V3 substrate node_row → key: +//! ```text +//! header 18 B: magic "BSO1" | version u16 | node_count u32 | nodes_len u32 | spm1_len u32 +//! node table nodes_len B: node_count × [ key 16 | tissue u8 | depth u8 | rgb 3u8 +//! | v_start u32 | v_count u32 | label_len u8 | utf8 ] +//! geometry spm1_len B: the SPM1 block verbatim (vert node_row indexes the table) +//! ``` +//! +//! Run from the workspace root (after the Python geometry bake): +//! `cargo run -p osint-bake --bin body` +//! Default paths: scratch-fma/out/{body.nodes.json,body.spm1} → cockpit/public/body.soa. +//! +//! The output is BIG (~168 MB) and is NOT committed to git. It lives as a GitHub +//! **release asset** (q2 release `fma-body-soa-v3-v1`, both `body.soa` and the +//! gzipped `body.soa.gz`); the cockpit `/body` view fetches the .gz from there and +//! inflates it client-side. `cockpit/public/body.soa*` is gitignored so a local +//! bake never lands the binary in the repo. + +use lance_graph_contract::canonical_node::{classid_read_mode, NodeGuid}; +use std::path::{Path, PathBuf}; + +/// FMA V3 cascade key (`0x1000_0A01`) — same constant `bin/fma.rs` uses; the V3 +/// generation marker `0x1000` over the canon `anatomical_structure` concept `0x0A01`. +const CLASSID_FMA: u32 = NodeGuid::CLASSID_FMA_V3; + +/// One 8:8 HHTL tier: `[container-mixin : identity]` (mirrors `fma.rs::tier`). +const fn tier(container: u8, identity: u8) -> u16 { + ((container as u16) << 8) | identity as u16 +} + +/// Kind-mixin for is_a depth `k` (0..4) — the family node each cascade level attaches +/// on. Generic depth mixins (0x01..0x05) play the role `fma.rs`'s Organ/Chamber/Wall/ +/// Tissue/Cell mixins do for the heart; the body's is_a tree is not chamber-shaped, so +/// depth indexes the mixin directly. Deeper-than-5 levels fold into `identity`. +const fn mixin_for_depth(k: usize) -> u8 { + (k as u8) + 1 +} + +fn arg(n: usize, default: &str) -> String { + std::env::args().nth(n).unwrap_or_else(|| default.to_string()) +} + +fn main() { + let nodes_path = arg(1, "scratch-fma/out/body.nodes.json"); + let spm1_path = arg(2, "scratch-fma/out/body.spm1"); + let out_arg = arg(3, ""); + + let nodes_json = std::fs::read_to_string(&nodes_path) + .unwrap_or_else(|e| panic!("read {nodes_path}: {e}")); + let doc: serde_json::Value = + serde_json::from_str(&nodes_json).unwrap_or_else(|e| panic!("parse {nodes_path}: {e}")); + let nodes = doc["nodes"].as_array().expect("body.nodes.json: .nodes array"); + + let spm1 = std::fs::read(&spm1_path).unwrap_or_else(|e| panic!("read {spm1_path}: {e}")); + assert_eq!(&spm1[..4], b"SPM1", "geometry block is not SPM1"); + + let tail = classid_read_mode(CLASSID_FMA).tail_variant; + + // ── node table: one V3 NodeGuid per concept, minted on the is_a cascade ── + let mut table: Vec = Vec::with_capacity(nodes.len() * 40); + let mut deepest = 0u8; + for n in nodes { + let row = n["row"].as_u64().unwrap_or(0) as u32; + let tissue = n["container"].as_u64().unwrap_or(0) as u8; + let depth = n["depth"].as_u64().unwrap_or(0).min(255) as u8; + deepest = deepest.max(depth); + let rgb = n["rgb"].as_array(); + let r = rgb.and_then(|a| a.first()).and_then(|v| v.as_u64()).unwrap_or(180) as u8; + let g = rgb.and_then(|a| a.get(1)).and_then(|v| v.as_u64()).unwrap_or(180) as u8; + let b = rgb.and_then(|a| a.get(2)).and_then(|v| v.as_u64()).unwrap_or(180) as u8; + let v_start = n["v_start"].as_u64().unwrap_or(0) as u32; + let v_count = n["v_count"].as_u64().unwrap_or(0) as u32; + + // cascade = is_a ancestor sibling ranks root->self (≤5 tier identity bytes). + let cascade = n["cascade"].as_array().cloned().unwrap_or_default(); + let id_at = |k: usize| -> u8 { + cascade.get(k).and_then(|v| v.as_u64()).unwrap_or(0) as u8 + }; + let tier_at = |k: usize| -> u16 { + let id = id_at(k); + if id == 0 { 0 } else { tier(mixin_for_depth(k), id) } + }; + + // Mint by the classid's registered tail variant (V3), never hardcoding the + // tail — the contract's `mint_for` litmus (same as fma.rs). + let key = NodeGuid::mint_for( + tail, + CLASSID_FMA, + tier_at(0), // HEEL [depth0-mixin : rank] + tier_at(1), // HIP [depth1-mixin : rank] + tier_at(2), // TWIG [depth2-mixin : rank] + tier_at(3), // LEAF [depth3-mixin : rank] + u32::from(tier_at(4)), // family[depth4-mixin : rank] + row, // identity — stable concept row (node_row link) + ); + + table.extend_from_slice(key.as_bytes()); + table.push(tissue); + table.push(depth); + table.extend_from_slice(&[r, g, b]); + table.extend_from_slice(&v_start.to_le_bytes()); + table.extend_from_slice(&v_count.to_le_bytes()); + let label = n["name"].as_str().unwrap_or(""); + let lb = label.as_bytes(); + let ll = lb.len().min(255); + table.push(ll as u8); + table.extend_from_slice(&lb[..ll]); + } + + // ── BSO1 wire: header | V3 node table | SPM1 block ── + let mut out: Vec = Vec::with_capacity(18 + table.len() + spm1.len()); + out.extend_from_slice(b"BSO1"); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&(nodes.len() as u32).to_le_bytes()); + out.extend_from_slice(&(table.len() as u32).to_le_bytes()); + out.extend_from_slice(&(spm1.len() as u32).to_le_bytes()); + out.extend_from_slice(&table); + out.extend_from_slice(&spm1); + + // geometry counts (from the SPM1 header) for the receipt + let vc = u32::from_le_bytes(spm1[4..8].try_into().unwrap()); + let tc = u32::from_le_bytes(spm1[8..12].try_into().unwrap()); + + let out_path: PathBuf = if !out_arg.is_empty() { + PathBuf::from(out_arg) + } else { + ["cockpit/public/body.soa", "../../cockpit/public/body.soa"] + .iter() + .map(PathBuf::from) + .find(|p| p.parent().is_some_and(Path::exists)) + .unwrap_or_else(|| PathBuf::from("cockpit/public/body.soa")) + }; + std::fs::write(&out_path, &out).unwrap_or_else(|e| panic!("write {}: {e}", out_path.display())); + + println!("── body.soa (BSO1) ──"); + println!(" V3 substrate : {} concepts minted on CLASSID_FMA_V3 cascade (max depth {deepest})", nodes.len()); + println!(" geometry : {vc} verts · {tc} tris (ALL points, full-res SPM1)"); + println!(" node table : {} B geometry block: {} B", table.len(), spm1.len()); + println!(" baked {} ({} B)", out_path.display(), out.len()); +} diff --git a/crates/osint-bake/src/bin/fma.rs b/crates/osint-bake/src/bin/fma.rs index 948e0f328..9485cac89 100644 --- a/crates/osint-bake/src/bin/fma.rs +++ b/crates/osint-bake/src/bin/fma.rs @@ -52,19 +52,23 @@ //! //! Run from the workspace root: `cargo run -p osint-bake --bin fma` -use lance_graph_contract::canonical_node::NodeGuid; +use lance_graph_contract::canonical_node::{NodeGuid, classid_read_mode}; use osint_bake::fma_ttl; use std::path::{Path, PathBuf}; /// The CEILING global-category pole (HEEL=HIP=0xFFFF; sentinel through TWIG = leaf-grain). const CEILING: u16 = 0xFFFF; -/// FMA classid — `anatomical_structure` (`0x0A01`) in OGAR's -/// `ConceptDomain::Anatomy` (high byte `0x0A`; resolves via -/// `ogar_vocab::canonical_concept_domain`). The heart slice is soft-tissue -/// anatomy, so it takes the universal-root concept; OGAR reserves -/// `0x0A02..0x0A04` for skeleton/bone/joint. Aligned to OGAR PR #116 -/// (`docs/FMA-SKELETON-CONVERGENCE-ANCHOR.md`) — was the ad-hoc `0x00F0_0A00`. -const CLASSID_FMA: u32 = 0x0000_0A01; +/// FMA classid — the **V3 cascade-key** `CLASSID_FMA_V3` (`0x1000_0A01`): the V3 +/// generation marker `0x1000` in the HIGH (custom) u16, the canon `anatomical_structure` +/// concept `0x0A01` preserved in the LOW u16 (so `classid_concept_domain` still routes +/// OGAR's `ConceptDomain::Anatomy`, high byte `0x0A`). V3 is a *reading* of the SAME +/// leaf·family·identity tail v2 mints — the `(part_of:is_a)` cascade reinterprets the +/// 8:8 tiers, never re-carves — so the node still mints through `new_v2` (via +/// `mint_for`'s V3 arm), resolving to `ReadMode::FMA_V3`. Migrated from the legacy V2 +/// `0x0000_0A01`; the heart slice is soft-tissue anatomy (universal-root concept; +/// OGAR reserves `0x0A02..0x0A04` for skeleton/bone/joint). See OGAR PR #116 +/// (`docs/FMA-SKELETON-CONVERGENCE-ANCHOR.md`) and the V3 set in `canonical_node.rs`. +const CLASSID_FMA: u32 = NodeGuid::CLASSID_FMA_V3; // class bytes → cockpit colour/label (see FmaGraph.tsx). const C_ORGAN: u8 = 0; @@ -130,7 +134,12 @@ impl Builder { cell: u8, ) -> usize { let i = self.nodes.len(); - let key = NodeGuid::new_v2( + // Mint by the classid's registered tail variant (V3 for CLASSID_FMA_V3), + // never by hardcoding `new` vs `new_v2` — the contract's `mint_for` litmus. + // V3 mints the SAME leaf·family·identity tail through `new_v2`; only the + // read mode (the (part_of:is_a) 8:8 cascade) differs. + let key = NodeGuid::mint_for( + classid_read_mode(CLASSID_FMA).tail_variant, CLASSID_FMA, tier(MX_ORGAN, ID_HEART), // HEEL [Organ:Heart] if chamber > 0 { @@ -144,8 +153,12 @@ impl Builder { } else { 0 }, // LEAF [Tissue:id] - if cell > 0 { tier(MX_CELL, cell) } else { 0 }, // family[Cell:id] - i as u16, // identity — stable node id + if cell > 0 { + tier(MX_CELL, cell) as u32 + } else { + 0 + }, // family[Cell:id] + i as u32, // identity — stable node id ); self.nodes.push(Node { label: label.to_string(), @@ -158,14 +171,15 @@ impl Builder { /// A leaf-limited global TYPE category: HEEL=HIP=TWIG=0xFFFF, LEAF=type idx. fn type_node(&mut self, label: &str, type_idx: u16) -> usize { let i = self.nodes.len(); - let key = NodeGuid::new_v2( + let key = NodeGuid::mint_for( + classid_read_mode(CLASSID_FMA).tail_variant, CLASSID_FMA, CEILING, // HEEL sentinel CEILING, // HIP sentinel CEILING, // TWIG sentinel → leaf-grain ("limited to the leaf") type_idx, // LEAF — the sole discriminator 0, // family — global, no basin - i as u16, + i as u32, ); self.nodes.push(Node { label: label.to_string(), diff --git a/crates/osint-bake/tools/audit_body_semantics.py b/crates/osint-bake/tools/audit_body_semantics.py new file mode 100644 index 000000000..27e854678 --- /dev/null +++ b/crates/osint-bake/tools/audit_body_semantics.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Semantic-layer audit for the baked FMA body (QA gate). + +Runs over a baked `` (body.concepts.json + columns) and checks the +ontology / classification rules the /body viewer relies on: + + QA-1 organ-scale vessel — a concept in a vessel material (0..3) whose mesh is + a low-aspect BLOB (not a thin tube), single connected component, organ-sized. + Catches the heart / liver / iris / choroid class of "whole organ rendered as + a blue vessel" bug. + QA-2 orphan / missing class — a concept with no name, or no is_a parent and no + part_of placement (parent_row == -1 and part_of all zero). + QA-3 smoke tests — assert representative structures land in the right + compartment: liver/eyeball→organ, brain→nervous, femur→skeleton, + biceps→muscle, aorta/vena cava→vessel. + +Exit code is non-zero if any QA-3 smoke test fails, so it can gate CI / a rebake. +Usage: python3 audit_body_semantics.py (default: soa) +""" +import json +import os +import re +import struct +import sys +from collections import defaultdict + +VESSEL_MATERIALS = {0, 1, 2, 3} + +# mirror of soabake's layer_of(tissue) → compartment id, and the UI layer names. +LAYER_OF = { + "skin": 1, "flesh": 1, "muscle": 2, + "heart": 3, "lung": 3, "liver": 3, "kidney": 3, "gi": 3, "gland": 3, "viscus": 3, + "bone": 4, "cartilage": 4, "artery": 5, "vein": 5, "vessel": 5, "nerve": 6, +} +LAYER_NAME = {1: "skin", 2: "muscle", 3: "organ", 4: "skeleton", 5: "vessel", 6: "nervous", 7: "connective", 8: "other"} + + +def layer_of(tissue): + return LAYER_OF.get(tissue, 8) + + +def n_components(pts, cell=0.05): + grid = defaultdict(list) + for i, p in enumerate(pts): + grid[(int(p[0] // cell), int(p[1] // cell), int(p[2] // cell))].append(i) + seen, n = set(), 0 + for s in list(grid): + if s in seen: + continue + n += 1 + st = [s] + seen.add(s) + while st: + c = st.pop() + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for dz in (-1, 0, 1): + nb = (c[0] + dx, c[1] + dy, c[2] + dz) + if nb in grid and nb not in seen: + seen.add(nb) + st.append(nb) + return n + + +def main(d): + doc = json.load(open(os.path.join(d, "body.concepts.json"))) + labels = json.load(open(os.path.join(d, "body.labels.json"))) + concepts = doc["concepts"] + nV = doc["verts"] + pos = struct.unpack(f"<{nV * 3}f", open(os.path.join(d, "body.pos"), "rb").read()[:nV * 12]) + row = struct.unpack(f"<{nV}I", open(os.path.join(d, "body.row"), "rb").read()[:nV * 4]) + by_row = defaultdict(list) + for i in range(nV): + by_row[row[i]].append(i) + + def name(c): + return labels[c["name_idx"]] if 0 <= c["name_idx"] < len(labels) else "" + + # ── QA-1: organ-scale vessel ─────────────────────────────────────────────── + print("── QA-1 organ-scale vessels (blob in a vessel material) ──") + q1 = [] + for c in concepts: + if c["material"] not in VESSEL_MATERIALS: + continue + idx = by_row.get(c["row"], []) + if len(idx) < 400: + continue + xs = [pos[i * 3] for i in idx] + ys = [pos[i * 3 + 1] for i in idx] + zs = [pos[i * 3 + 2] for i in idx] + dims = sorted([max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs)]) + if dims[0] < 1e-6: + continue + aspect = dims[2] / dims[0] + # organ blob: low aspect AND a fat minor axis AND a single solid component + if aspect < 1.6 and dims[0] > 0.05 and n_components([(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]) for i in idx]) == 1: + q1.append((dims[0], aspect, name(c), c["material"], len(idx))) + for mind, asp, nm, mat, n in sorted(q1, reverse=True): + print(f" ⚠ min={mind:.2f} aspect={asp:.2f} mat={mat} v={n:>6} {nm}") + if not q1: + print(" ✓ none") + + # ── QA-2: floating / misplaced geometry ──────────────────────────────────── + # A concept whose mesh splits into regions FAR apart that are NOT a left/right + # mirror pair = geometry attached to the wrong place (the vessel-bridge / "organ + # fragment floating elsewhere" class). Bilateral pairs (hands, ears) are expected. + print("── QA-2 floating / misplaced geometry (non-bilateral split) ──") + q2 = 0 + for c in concepts: + if not name(c): + q2 += 1 + print(f" ⚠ row {c['row']:>4} unnamed concept") + continue + idx = by_row.get(c["row"], []) + if len(idx) < 300: + continue + pts = [(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]) for i in idx] + # cluster into far-apart regions (coarse cell) + grid = defaultdict(list) + for p in pts: + grid[(round(p[0] / 0.12), round(p[1] / 0.12), round(p[2] / 0.12))].append(p) + seen, comps = set(), [] + for s in list(grid): + if s in seen: + continue + stack, cells = [s], [] + seen.add(s) + while stack: + cc = stack.pop() + cells.append(cc) + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for dz in (-1, 0, 1): + nb = (cc[0] + dx, cc[1] + dy, cc[2] + dz) + if nb in grid and nb not in seen: + seen.add(nb) + stack.append(nb) + comps.append([q for cell in cells for q in grid[cell]]) + if len(comps) < 2: + continue + comps.sort(key=len, reverse=True) + cen = [[sum(p[k] for p in comp) / len(comp) for k in range(3)] for comp in comps[:2]] + sep = sum((cen[0][k] - cen[1][k]) ** 2 for k in range(3)) ** 0.5 + # bilateral if the two regions are x-mirror images (x flips, y/z stay) + bilateral = abs(cen[0][0] + cen[1][0]) < 0.06 and abs(cen[0][1] - cen[1][1]) < 0.08 and abs(cen[0][2] - cen[1][2]) < 0.08 + if sep > 0.18 and not bilateral: + q2 += 1 + print(f" ⚠ {name(c)} sep={sep:.2f} cen0=({cen[0][0]:.2f},{cen[0][1]:.2f},{cen[0][2]:.2f}) cen1=({cen[1][0]:.2f},{cen[1][1]:.2f},{cen[1][2]:.2f})") + print(f" {'⚠' if q2 else '✓'} {q2} concept(s) with non-bilateral split geometry") + + # ── QA-3: smoke tests ────────────────────────────────────────────────────── + print("── QA-3 per-organ compartment smoke tests ──") + fails = 0 + + def assert_layer(label_pat, want_layers, exclude=None): + nonlocal fails + pat = re.compile(label_pat, re.I) + exc = re.compile(exclude, re.I) if exclude else None + hits = [c for c in concepts if pat.search(name(c)) and not (exc and exc.search(name(c)))] + bad = [c for c in hits if layer_of(c["tissue"]) not in want_layers] + want = "/".join(LAYER_NAME[w] for w in want_layers) + if not hits: + print(f" ? {label_pat!r}: no concepts matched (skipped)") + return + if bad: + fails += 1 + print(f" ✗ {label_pat!r} should be {want}: {len(bad)}/{len(hits)} in wrong compartment, e.g.:") + for c in bad[:4]: + print(f" {name(c)} → {LAYER_NAME[layer_of(c['tissue'])]} (tissue={c['tissue']}, mat={c['material']})") + else: + print(f" ✓ {label_pat!r}: all {len(hits)} in {want}") + + # liver parenchyma → organ (exclude the true hepatic/portal vessels) + assert_layer(r"hepatovenous segment|caudate lobe of liver", {3}) + # eyeball structures → organ (skin-layer flesh ok too); must NOT be vessel + assert_layer(r"sclera|cornea|retina|vitreous|^.*\biris\b|choroid(?! plexus)|eyeball", {1, 3}, exclude=r"plexus") + # brain → nervous + assert_layer(r"\bbrain\b|cerebral cortex|cerebellum", {6}, exclude=r"artery|vein|vessel") + # femur → skeleton + assert_layer(r"\bfemur\b", {4}) + # biceps → muscle + assert_layer(r"biceps", {2}) + # aorta / vena cava trunks → vessel (exclude organ-supply *branches* of the aorta, + # which correctly carry their target organ's tissue) + assert_layer(r"\baorta\b|vena cava", {5}, exclude=r"branch|oesophageal|bronchial") + + print(f"\nsummary: QA-1 flagged {len(q1)} · QA-2 flagged {q2} · QA-3 {'FAILED ' + str(fails) if fails else 'passed'}") + sys.exit(1 if fails else 0) + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "soa") diff --git a/crates/osint-bake/tools/bake_body_soa.py b/crates/osint-bake/tools/bake_body_soa.py new file mode 100644 index 000000000..e913a0ac4 --- /dev/null +++ b/crates/osint-bake/tools/bake_body_soa.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Body SoA-column emitter (option-2 rebake, LOCKED design 2026-06-27). + +Supersedes bake_body_v3.py's AoS BSO1. Emits the geometry as **struct-of-arrays +columns** (raw little-endian, 64-byte padded — MultiLaneColumn-shaped) plus the +per-concept cascade table and the label/material **codebooks** (indices, never +raw text). A standalone Rust stage (`body_soa` bin) then mints the address GUID +(part_of:is_a), encodes the 2 helices, and assembles the SoA wire. + +Full resolution: every OBJ vertex (≈4.2 M verts / 6.68 M tris) — NO decimation. + +Per-VERTEX columns (SoA row = vertex; node_row links to its concept): + body.pos f32×3 · N normalized [-1,1] XYZ (location; Z=slice, X·Y=in-slice grid) + body.nrm f32×3 · N smooth normal (helix-normal source for the Rust stage) + body.row u32 · N concept index (the SoA-linked identity → concept table) + body.idx u32×3 · T triangle vertex indices + +Per-CONCEPT table (body.concepts.json), 1 row per is_a-meshed structure: + {row, fma, part_of[6], is_a[6], material, label, centroid, v_start, v_count} + part_of[k]/is_a[k] = sibling rank at HHTL level k (Rust packs the 8:8 address tiers) + material = index into the Doppler/material codebook · label = index into the label codebook + +Codebooks (content stores, resolved via ClassView — wire carries only the index): + body.labels.json [text, …] unique concept names + body.materials.json [{id,name,doppler,rgb}, …] 6 flow/solid prototypes + +Data: BodyParts3D 4.0 (DBCLS), CC-BY 4.0 / CC-BY-SA 2.1 JP. +Usage: python3 bake_body_soa.py +""" +import collections +import json +import os +import struct +import sys + +from bake_torso_splat import ( + ATTRIBUTION, ISA_ROOT, bfs, isa_dn, load_isa, tissue_color, tissue_of, +) + +# ── Doppler / material prototypes (the "texture for tubes") — radiologykey +# abdominal-vessel signatures + solid fallbacks. Wire stores the index. ── +MATERIALS = [ + {"id": 0, "name": "low_resistance_artery", "doppler": "continuous_forward_diastolic", "rgb": [201, 58, 52]}, + {"id": 1, "name": "high_resistance_artery", "doppler": "triphasic", "rgb": [176, 42, 38]}, + {"id": 2, "name": "portal_venous", "doppler": "continuous_undulating_hepatopetal", "rgb": [70, 110, 180]}, + {"id": 3, "name": "systemic_venous", "doppler": "phasic_respiratory_cardiac", "rgb": [66, 95, 176]}, + {"id": 4, "name": "solid_tissue", "doppler": "none", "rgb": [200, 150, 140]}, + {"id": 5, "name": "neural", "doppler": "none", "rgb": [226, 205, 88]}, +] +TISSUE_MATERIAL = { + "artery": 0, "vein": 3, "vessel": 3, "nerve": 5, + # heart is a chambered ORGAN, not a tube — mapping it to artery(0) made the slicer- + # fill sweep a PCA-centerline lumen rod through it. Fall through to solid_tissue + # (like every other solid: bone/cartilage/muscle/organs/skin/flesh). +} + + +def read_obj_mesh(path): + vs, vn, faces = [], [], [] + with open(path, "rb") as f: + for ln in f: + if ln[:2] == b"v ": + p = ln.split(); vs.append((float(p[1]), float(p[2]), float(p[3]))) + elif ln[:3] == b"vn ": + p = ln.split(); vn.append((float(p[1]), float(p[2]), float(p[3]))) + elif ln[:2] == b"f ": + p = ln.split() + try: + idx = [int(t.split(b"/")[0]) - 1 for t in p[1:4]] + except (ValueError, IndexError): + continue + if len(idx) == 3 and all(0 <= k < len(vs) for k in idx): + faces.append((idx[0], idx[1], idx[2])) + ns = vn if len(vn) == len(vs) else [(0.0, 0.0, 1.0)] * len(vs) + return vs, ns, faces + + +def load_partof(scratch_or_up): + """part_of inclusion tree (regional containment) → parent/children maps.""" + cand = [ + os.path.join(scratch_or_up, "partof_inclusion_relation_list.txt"), + os.path.join(scratch_or_up, "partof", "partof_inclusion_relation_list.txt"), + ] + path = next((p for p in cand if os.path.exists(p)), None) + parent, children = {}, collections.defaultdict(list) + if path: + with open(path, encoding="utf-8") as f: + next(f) + for ln in f: + p, _pn, c, _cn = ln.rstrip("\n").split("\t") + parent[c] = p + children[p].append(c) + for v in children.values(): + v.sort() + return parent, children + + +def sibrank6(node, parent, children): + """ancestor sibling-rank chain root→self, 6 levels (1-based; 0 = root/absent).""" + chain, cur = [], node + while cur is not None: + chain.append(cur); cur = parent.get(cur) + chain.reverse() + out = [] + for n in chain[:6]: + p = parent.get(n) + if p is None: + out.append(0) + else: + sibs = children[p] + out.append(((sibs.index(n) + 1) & 0xFF) if n in sibs else 0) + while len(out) < 6: + out.append(0) + return out + + +def main(scratch, out_dir): + up = "/root/.claude/uploads/2e96121c-3007-5a1a-9af1-10b1dfd06f58" + parent_isa, children_isa, name, elems, canon = load_isa(scratch) + parent_pof, children_pof = load_partof(scratch) + if not parent_pof: + parent_pof, children_pof = load_partof(up) + order, depth = bfs(ISA_ROOT, children_isa) + have = set(order) + isa_obj = os.path.join(scratch, "isa_BP3D_4.0_obj_99") + pof_obj = os.path.join(scratch, "partof", "partof_BP3D_4.0_obj_99") + + def obj_path(fj): + p = os.path.join(isa_obj, fj + ".obj") + return p if os.path.exists(p) else os.path.join(pof_obj, fj + ".obj") + + concepts = sorted((c for c in elems if c in have), key=lambda c: -depth[c]) + owner = {} + for c in concepts: + for fj in elems[c]: + owner.setdefault(fj, c) + meshes_of = collections.defaultdict(list) + for fj, c in owner.items(): + meshes_of[c].append(fj) + for v in meshes_of.values(): + v.sort() + kept = [c for c in order if c in meshes_of] + row_of = {c: r for r, c in enumerate(kept)} + + labels, label_idx = [], {} + def label_index(s): + i = label_idx.get(s) + if i is None: + i = len(labels); label_idx[s] = i; labels.append(s) + return i + + tcache = {} + px, py, pz, nx, ny, nz, crow = ([] for _ in range(7)) + tris = [] + concept_rows = [] + + for c in kept: + r = row_of[c] + nm = canon.get(c, name.get(c, c)) + tissue = tissue_of(c, parent_isa, name, canon, tcache) + # ── semantic-class corrections: structures named for their vasculature that + # tissue_of mis-tags as vessels, turning whole ORGANS blue + slicer-filled ── + nm_l = nm.lower() + # Liver parenchyma = Couinaud "hepatovenous segment N" (named for venous + # drainage) → was tagged 'vein'. It's solid liver; only the true hepatic/portal + # *veins* (all carry 'vein' in their name) stay vessels. + if "hepatovenous segment" in nm_l: + tissue = "liver" + # Eyeball vascular layers — iris + choroid — were tagged 'vessel', so the eyes + # rendered blue. They're part of a sense ORGAN → solid/organ. NB the brain's + # "choroid plexus" is NOT the eye, so it is excluded. + elif "iris" in nm_l or ("choroid" in nm_l and "plexus" not in nm_l): + tissue = "viscus" + material = TISSUE_MATERIAL.get(tissue, 4) + v_start = len(px) + for fj in meshes_of[c]: + p = obj_path(fj) + if not os.path.exists(p): + continue + vs, ns, faces = read_obj_mesh(p) + if not faces: + continue + base = len(px) + for (x, y, z), (ax, ay, az) in zip(vs, ns): + px.append(x); py.append(y); pz.append(z) + nx.append(ax); ny.append(ay); nz.append(az) + crow.append(r) + for (a, b, cc) in faces: + tris.append((base + a, base + b, base + cc)) + concept_rows.append({ + "row": r, "fma": c, "name_idx": label_index(nm), "material": material, + "tissue": tissue, "depth": depth[c], + "part_of": sibrank6(c, parent_pof, children_pof), + "is_a": sibrank6(c, parent_isa, children_isa), + "is_a_dn": isa_dn(c, parent_isa, name, tissue), + "v_start": v_start, "v_count": len(px) - v_start, + "parent_row": row_of.get(parent_isa.get(c), -1), + }) + + if not px: + sys.exit("no geometry") + + # normalize XYZ to [-1,1] centred (location columns, GPU/slicer native) + cx = (min(px) + max(px)) / 2; cy = (min(py) + max(py)) / 2; cz = (min(pz) + max(pz)) / 2 + half = max(max(px) - min(px), max(py) - min(py), max(pz) - min(pz)) / 2 or 1.0 + inv = 1.0 / half + nvert, ntri = len(px), len(tris) + + os.makedirs(out_dir, exist_ok=True) + def pad64(f): + n = f.tell() % 64 + if n: f.write(b"\x00" * (64 - n)) + + # per-concept centroid (in normalized space) for BlockBounds / address identity + for nd in concept_rows: + s, cc = nd["v_start"], nd["v_count"] + if cc: + xs = [(px[i] - cx) * inv for i in range(s, s + cc)] + ys = [(py[i] - cy) * inv for i in range(s, s + cc)] + zs = [(pz[i] - cz) * inv for i in range(s, s + cc)] + nd["centroid"] = [sum(xs) / cc, sum(ys) / cc, sum(zs) / cc] + else: + nd["centroid"] = [0.0, 0.0, 0.0] + + with open(os.path.join(out_dir, "body.pos"), "wb") as f: + for i in range(nvert): + f.write(struct.pack("<3f", (px[i] - cx) * inv, (py[i] - cy) * inv, (pz[i] - cz) * inv)) + pad64(f) + with open(os.path.join(out_dir, "body.nrm"), "wb") as f: + for i in range(nvert): + m = (nx[i] * nx[i] + ny[i] * ny[i] + nz[i] * nz[i]) ** 0.5 or 1.0 + f.write(struct.pack("<3f", nx[i] / m, ny[i] / m, nz[i] / m)) + pad64(f) + with open(os.path.join(out_dir, "body.row"), "wb") as f: + for i in range(nvert): + f.write(struct.pack(" 0` welds only EXACT-coincident points (a light +dedup, still "all points"); `cell_mm = 0` keeps every vertex verbatim. + +It reuses the SAME is_a classifier (load_isa / tissue_of / isa_dn / colours) as the +splat + decimated-mesh bakes, so tissue, names and colours are identical. The ONE +addition over `bake_torso_mesh.py`: per concept it emits the **is_a ancestor sibling +-rank chain** (`cascade`: up to 5 tier identity bytes, root->leaf) so the Rust side +can mint the `CLASSID_FMA_V3` NodeGuid on the (part_of:is_a) cascade exactly the way +`crates/osint-bake/src/bin/fma.rs` mints the heart slice — the partonomy IS the key. + +Outputs (into ): + body.spm1 SPM1 geometry (same wire as torso.mesh; full-res) + body.nodes.json concept table: per node {row, fma, name, tissue, rgb, opacity, + depth, parent_row, v_start, v_count, cascade:[tier ids], identity} + +The Rust `body` bin reads both and writes body.soa (BSO1 = V3 node table + SPM1 +block). Geometry/data: BodyParts3D, (c) The Database Center for Life Science, +CC-BY 4.0 / CC-BY-SA 2.1 JP. Attribution shown in-view (licence requirement). + +Usage: python3 bake_body_v3.py [cell_mm=0] +""" +import collections +import json +import os +import struct +import sys + +from bake_torso_splat import ( + ATTRIBUTION, CONTAINER_ID, ISA_ROOT, TISSUE_OPACITY, + bfs, isa_dn, load_isa, tissue_color, tissue_of, +) + +# Compartment LAYER id (the per-vertex byte-19 gating key the /body viewer's buttons +# toggle) — mirrors fma's cockpit_bake layer_of / FmaBody.tsx LAYERS, but maps the +# FINER is_a tissues onto the same 8 layers (1 skin·2 muscle·3 organ·4 skeleton· +# 5 vessel·6 nerve·7 connective·8 other). This is what makes /body compartmentalized +# like /fma-body instead of a single depth-peel floor like /torso-live. +LAYER_OF = { + "skin": 1, "flesh": 1, + "muscle": 2, + "heart": 3, "lung": 3, "liver": 3, "kidney": 3, "gi": 3, "gland": 3, "viscus": 3, + "bone": 4, "cartilage": 4, + "artery": 5, "vein": 5, "vessel": 5, + "nerve": 6, +} # default → 8 "other" + + +def read_obj_mesh(path): + """(verts, normals aligned to verts, faces). BodyParts3D uses `f v//vn` with + vn index == v index, so vn is 1:1 with v; fall back to +Z if counts disagree.""" + vs, vn, faces = [], [], [] + with open(path, "rb") as f: + for ln in f: + if ln[:2] == b"v ": + p = ln.split(); vs.append((float(p[1]), float(p[2]), float(p[3]))) + elif ln[:3] == b"vn ": + p = ln.split(); vn.append((float(p[1]), float(p[2]), float(p[3]))) + elif ln[:2] == b"f ": + p = ln.split() + try: + idx = [int(t.split(b"/")[0]) - 1 for t in p[1:4]] + except (ValueError, IndexError): + continue + if len(idx) == 3 and all(0 <= k < len(vs) for k in idx): + faces.append((idx[0], idx[1], idx[2])) + ns = vn if len(vn) == len(vs) else [(0.0, 0.0, 1.0)] * len(vs) + return vs, ns, faces + + +def weld_exact(verts, normals, faces, inv_h): + """Light weld: collapse only points that fall in the SAME 1/inv_h cell (inv_h + large => only exact-coincident points merge). inv_h <= 0 => keep every vertex.""" + if inv_h <= 0: + return verts, normals, faces + cell_of, acc, remap = {}, [], [0] * len(verts) + ox = min(v[0] for v in verts); oy = min(v[1] for v in verts); oz = min(v[2] for v in verts) + for i, (x, y, z) in enumerate(verts): + key = (round((x - ox) * inv_h), round((y - oy) * inv_h), round((z - oz) * inv_h)) + j = cell_of.get(key) + nx, ny, nz = normals[i] + if j is None: + j = len(acc); cell_of[key] = j + acc.append([x, y, z, nx, ny, nz, 1]) + else: + a = acc[j] + a[0] += x; a[1] += y; a[2] += z; a[3] += nx; a[4] += ny; a[5] += nz; a[6] += 1 + remap[i] = j + nv, nn = [], [] + for a in acc: + c = a[6] + nv.append((a[0] / c, a[1] / c, a[2] / c)) + nl = (a[3] * a[3] + a[4] * a[4] + a[5] * a[5]) ** 0.5 or 1.0 + nn.append((a[3] / nl, a[4] / nl, a[5] / nl)) + nf = [(remap[a], remap[b], remap[c]) for (a, b, c) in faces + if remap[a] != remap[b] and remap[b] != remap[c] and remap[a] != remap[c]] + return nv, nn, nf + + +def cascade_of(c, parent, children, depth, rank_cache): + """The is_a ancestor sibling-rank chain root->self, up to 5 tier identity bytes. + Tier k = 1-based rank of the ancestor at depth k among its parent's children + (deterministic: children sorted by FMA id). Mirrors fma.rs's HHTL [mixin:id] + identities; the mixin (kind by depth) is assigned Rust-side.""" + chain = [] + cur = c + while cur is not None: + chain.append(cur) + cur = parent.get(cur) + chain.reverse() # root .. self + + def rank(node): + p = parent.get(node) + if p is None: + return 0 + r = rank_cache.get(node) + if r is None: + sibs = sorted(children[p]) + for k, s in enumerate(sibs): + rank_cache[s] = (k + 1) & 0xFF + r = rank_cache.get(node, 0) + return r + + return [rank(n) for n in chain[:5]] + + +def main(scratch, out_dir, cell_mm=0.0): + parent, children, name, elems, canon = load_isa(scratch) + order, depth = bfs(ISA_ROOT, children) + have = set(order) + isa_obj = os.path.join(scratch, "isa_BP3D_4.0_obj_99") + pof_obj = os.path.join(scratch, "partof", "partof_BP3D_4.0_obj_99") + + def obj_path(fj): + p = os.path.join(isa_obj, fj + ".obj") + return p if os.path.exists(p) else os.path.join(pof_obj, fj + ".obj") + + # deepest-first claim so the finest is_a type owns each shared mesh (= the splat/mesh) + concepts = sorted((c for c in elems if c in have), key=lambda c: -depth[c]) + owner = {} + for c in concepts: + for fj in elems[c]: + owner.setdefault(fj, c) + meshes_of = collections.defaultdict(list) + for fj, c in owner.items(): + meshes_of[c].append(fj) + for v in meshes_of.values(): + v.sort() + kept = [c for c in order if c in meshes_of] + + inv_h = 1.0 / float(cell_mm) if cell_mm and cell_mm > 0 else 0.0 + tcache, rank_cache = {}, {} + px, py, pz, nx, ny, nz, cr, cg, cb, cop, crow = ([] for _ in range(11)) + tris = [] + nodes = [] + row_of = {c: r for r, c in enumerate(kept)} + + for c in kept: + r = row_of[c] + nm = canon.get(c, name.get(c, c)) + tissue = tissue_of(c, parent, name, canon, tcache) + col = tissue_color(tissue, r) + layer_id = LAYER_OF.get(tissue, 8) # byte-19 = compartment layer (not opacity) + v_start = len(px) + for fj in meshes_of[c]: + p = obj_path(fj) + if not os.path.exists(p): + continue + vs, ns, faces = read_obj_mesh(p) + if not faces: + continue + nv, nn, nf = weld_exact(vs, ns, faces, inv_h) + base = len(px) + for (x, y, z), (ax, ay, az) in zip(nv, nn): + px.append(x); py.append(y); pz.append(z) + nx.append(ax); ny.append(ay); nz.append(az) + cr.append(col[0]); cg.append(col[1]); cb.append(col[2]) + cop.append(layer_id); crow.append(r) + for (a, b, cc) in nf: + tris.append((base + a, base + b, base + cc)) + pa, seen = parent.get(c), 0 + while pa is not None and pa not in row_of and seen < 24: + pa = parent.get(pa); seen += 1 + nodes.append({ + "row": r, "fma": c, "name": nm, "tissue": tissue, "depth": depth[c], + "parent_row": row_of.get(pa, -1), "container": CONTAINER_ID[tissue], + "rgb": list(col), "layer": layer_id, "opacity": round(TISSUE_OPACITY[tissue], 3), + "is_a": isa_dn(c, parent, name, tissue), + "cascade": cascade_of(c, parent, children, depth, rank_cache), + "v_start": v_start, "v_count": len(px) - v_start, "fj": meshes_of[c], + }) + + if not px: + sys.exit("no geometry gathered") + + cx = (min(px) + max(px)) / 2; cy = (min(py) + max(py)) / 2; cz = (min(pz) + max(pz)) / 2 + half = max(max(px) - min(px), max(py) - min(py), max(pz) - min(pz)) / 2 or 1.0 + inv = 1.0 / half + for i in range(len(px)): + px[i] = (px[i] - cx) * inv; py[i] = (py[i] - cy) * inv; pz[i] = (pz[i] - cz) * inv + + nvert = len(px); ntri = len(tris) + bmin = (min(px), min(py), min(pz)); bmax = (max(px), max(py), max(pz)) + + def qi8(v): + return max(-127, min(127, int(round(v * 127)))) + + os.makedirs(out_dir, exist_ok=True) + spm1 = os.path.join(out_dir, "body.spm1") + with open(spm1, "wb") as f: + f.write(b"SPM1") + f.write(struct.pack("= (1 << 20): + f.write(buf); buf = bytearray() + f.write(buf) + buf = bytearray() + for (a, b, c) in tris: + buf += struct.pack("= (1 << 20): + f.write(buf); buf = bytearray() + f.write(buf) + + with open(os.path.join(out_dir, "body.nodes.json"), "w", encoding="utf-8") as f: + json.dump({"attribution": ATTRIBUTION, "decomposition": "is_a (BodyParts3D 4.0)", + "verts": nvert, "tris": ntri, "cell_mm": cell_mm, "nodes": nodes}, f) + + tissue_hist = collections.Counter(nd["tissue"] for nd in nodes) + print(f"baked {spm1}: {nvert:,} verts, {ntri:,} tris, {len(nodes)} concepts, " + f"cell_mm={cell_mm} (0 = ALL points)", file=sys.stderr) + print(f" tissues: {dict(tissue_hist)}", file=sys.stderr) + + +if __name__ == "__main__": + a = sys.argv + main(a[1], a[2], float(a[3]) if len(a) > 3 else 0.0) diff --git a/crates/osint-bake/tools/body-soa-wire/Cargo.toml b/crates/osint-bake/tools/body-soa-wire/Cargo.toml new file mode 100644 index 000000000..c88ea12e3 --- /dev/null +++ b/crates/osint-bake/tools/body-soa-wire/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "soabake" +version = "0.1.0" +edition = "2021" +publish = false +[workspace] +[dependencies] +# Relative to the sibling-clone layout (lance-graph + ndarray next to q2 — the same +# /build/q2 + /build/lance-graph + /build/ndarray the Dockerfile clones, and the local +# /home/user/{q2,lance-graph,ndarray} dev layout). Rebuildable from any checkout. +lance-graph-contract = { path = "../../../../../lance-graph/crates/lance-graph-contract", features = ["guid-v3-tail"] } +ndarray = { path = "../../../../../ndarray", default-features = false, features = ["std", "splat3d"] } +serde_json = "1" +[profile.release] +opt-level = 3 diff --git a/crates/osint-bake/tools/body-soa-wire/src/main.rs b/crates/osint-bake/tools/body-soa-wire/src/main.rs new file mode 100644 index 000000000..2ebc81956 --- /dev/null +++ b/crates/osint-bake/tools/body-soa-wire/src/main.rs @@ -0,0 +1,184 @@ +//! Body SoA-wire builder (option-2 rebake, stage 2). Reads bake_body_soa.py's +//! columns + concept table, mints the address GUID (classid 0x1000 + (part_of:is_a) +//! 8:8 cascade + identity), encodes the 2 helices via ndarray helix_orient +//! (helix-pos = dir from origin; helix-normal = self orientation), and writes one +//! BSO2 SoA wire. Native x86 → helix encode runs full F32x16 SIMD. +//! +//! BSO2 layout (LE): magic | ver u16 | n_concepts u32 | n_verts u32 | n_tris u32 +//! | concept GUID col [16·NC] | material u8 [NC] | label u32 [NC] +//! | centroid 3f [NC] | (v_start,v_count) 2u32 [NC] +//! | pos 3×F16 [NV] | helix 6B [NV] (pos3|nrm3) | row u32 [NV] | idx 3u32 [NT] +//! | labels_json u32 len + bytes | materials_json u32 len + bytes +//! +//! Position precision history (per-vertex `pos`, all 6 B/vertex below ver 3): +//! ver 3 = 3× f32 (12 B). +//! ver 4 = 3× BF16 (7-bit mantissa). HALF the size, but BF16's step is ~2⁻⁸ of a +//! coordinate's magnitude → ~3 mm near the head (y≈0.85) → a visible STAIRCASE +//! (Treppeneffekt) on small smooth structures like the eye/brain. REJECTED for +//! quality. +//! ver 5 = 3× F16 / IEEE half (10-bit mantissa). SAME 6 B/vertex as BF16, but the +//! step is ~2⁻¹¹ of magnitude → ~0.4 mm near the head, sub-visual — no staircase. +//! Coords are all in [-1,1], well inside F16's range. The renderer widens back to +//! f32 via a 64K half→f32 LUT. Conversion uses ndarray's tested `F16::from_f32`. +use lance_graph_contract::canonical_node::{classid_read_mode, NodeGuid}; +use ndarray::hpc::quantized::F16; +use ndarray::hpc::splat3d::helix_orient; + +const CLASSID_FMA: u32 = NodeGuid::CLASSID_FMA_V3; // 0x1000_0A01 + +fn tile(part_of: u8, is_a: u8) -> u16 { ((part_of as u16) << 8) | is_a as u16 } + +// 8 compartment layers (skin/muscle/organ/skeleton/vessel/nerve/connective/other) — +// the toggle granularity, separate from the Doppler material. Maps the finer is_a +// tissue onto the same scheme /fma-body uses. +fn layer_of(tissue: &str) -> u8 { + match tissue { + "skin" | "flesh" => 1, + "muscle" => 2, + "heart" | "lung" | "liver" | "kidney" | "gi" | "gland" | "viscus" => 3, + "bone" | "cartilage" => 4, + "artery" | "vein" | "vessel" => 5, + "nerve" => 6, + _ => 8, + } +} + +fn read_f32s(path: &str) -> Vec { + let b = std::fs::read(path).unwrap_or_else(|e| panic!("read {path}: {e}")); + b.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect() +} +fn read_u32s(path: &str) -> Vec { + let b = std::fs::read(path).unwrap_or_else(|e| panic!("read {path}: {e}")); + b.chunks_exact(4).map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect() +} + +fn main() { + let dir = std::env::args().nth(1).unwrap_or_else(|| "soa".into()); + let out = std::env::args().nth(2).unwrap_or_else(|| "body.soa".into()); + let j: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(format!("{dir}/body.concepts.json")).unwrap()).unwrap(); + let concepts = j["concepts"].as_array().unwrap(); + let nc = concepts.len(); + let nv = j["verts"].as_u64().unwrap() as usize; + let nt = j["tris"].as_u64().unwrap() as usize; + + let tail = classid_read_mode(CLASSID_FMA).tail_variant; + let arr6 = |v: &serde_json::Value, k: &str| -> [u8; 6] { + let a = v[k].as_array().unwrap(); + std::array::from_fn(|i| a.get(i).and_then(|x| x.as_u64()).unwrap_or(0) as u8) + }; + + // ── per-concept columns: address GUID, material, label, centroid, (v_start,v_count) ── + let mut guid = Vec::with_capacity(nc * 16); + let mut material = Vec::with_capacity(nc); + let mut layer = Vec::with_capacity(nc); + let mut label = Vec::with_capacity(nc * 4); + let mut centroid = Vec::with_capacity(nc * 12); + let mut vrange = Vec::with_capacity(nc * 8); + for c in concepts { + let row = c["row"].as_u64().unwrap() as u32; + let po = arr6(c, "part_of"); + let ia = arr6(c, "is_a"); + // tiers 0..4 = (part_of:is_a) 8:8; identity (tier 5) = row → unique key + let key = NodeGuid::mint_for( + tail, CLASSID_FMA, + tile(po[0], ia[0]), tile(po[1], ia[1]), tile(po[2], ia[2]), tile(po[3], ia[3]), + u32::from(tile(po[4], ia[4])), row, + ); + guid.extend_from_slice(key.as_bytes()); + material.push(c["material"].as_u64().unwrap_or(4) as u8); + layer.push(layer_of(c["tissue"].as_str().unwrap_or(""))); + label.extend_from_slice(&(c["name_idx"].as_u64().unwrap_or(0) as u32).to_le_bytes()); + let cen = c["centroid"].as_array().unwrap(); + for k in 0..3 { centroid.extend_from_slice(&(cen[k].as_f64().unwrap() as f32).to_le_bytes()); } + vrange.extend_from_slice(&(c["v_start"].as_u64().unwrap() as u32).to_le_bytes()); + vrange.extend_from_slice(&(c["v_count"].as_u64().unwrap() as u32).to_le_bytes()); + } + + // ── per-vertex columns: pos (kept) + helix (2× 3-byte) ── + let pos = read_f32s(&format!("{dir}/body.pos")); + let nrm = read_f32s(&format!("{dir}/body.nrm")); + let row = read_u32s(&format!("{dir}/body.row")); + let idx = std::fs::read(format!("{dir}/body.idx")).unwrap(); + assert!(pos.len() >= nv * 3 && nrm.len() >= nv * 3); + let mut helix = Vec::with_capacity(nv * 6); + let mut max_norm_err = 0.0f64; + for i in 0..nv { + let p = [pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]]; + let n = [nrm[i * 3], nrm[i * 3 + 1], nrm[i * 3 + 2]]; + // helix-pos: direction from origin (0,0,0) to the vertex (depth = |p|, derived by trig) + let pm = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt().max(1e-6); + let dir = [p[0] / pm, p[1] / pm, p[2] / pm]; + helix.extend_from_slice(&helix_orient::encode(dir, 3)); + // helix-normal: self orientation (compressed normal — replaces the f32 nrm column) + helix.extend_from_slice(&helix_orient::encode(n, 3)); + if i % 97 == 0 { max_norm_err = max_norm_err.max(helix_orient::angle_error_deg(n, 3)); } + } + + // ── per-concept BlockBounds (centroid + radius) for server-side HHTL LOD ── + // Recomputed from the `row` column (covers original + vessel-fill verts), so + // the tiny `{out}.blocks` asset (nc × 16 B) the cockpit-server embeds matches + // exactly what /api/body/lod's `cascade_blocks` runs over. center3 f32 | radius f32. + let mut bsum = vec![[0f64; 3]; nc]; + let mut bcnt = vec![0u32; nc]; + for i in 0..nv { + let r = row[i] as usize; + if r < nc { bsum[r][0] += pos[i*3] as f64; bsum[r][1] += pos[i*3+1] as f64; bsum[r][2] += pos[i*3+2] as f64; bcnt[r] += 1; } + } + let bcen: Vec<[f32; 3]> = (0..nc).map(|r| if bcnt[r] > 0 { + [(bsum[r][0]/bcnt[r] as f64) as f32, (bsum[r][1]/bcnt[r] as f64) as f32, (bsum[r][2]/bcnt[r] as f64) as f32] + } else { [0.0; 3] }).collect(); + let mut brad = vec![0f32; nc]; + for i in 0..nv { + let r = row[i] as usize; + if r < nc { + let c = bcen[r]; + let d = (pos[i*3]-c[0]).powi(2) + (pos[i*3+1]-c[1]).powi(2) + (pos[i*3+2]-c[2]).powi(2); + if d > brad[r] { brad[r] = d; } + } + } + // emit centroids in the RENDERER's display space (the BodyV3 remap (x,y,z)->(-x,z,y)), + // so the client posts its three.js camera directly — block space == rendered space. + // radius is reflection/permutation-invariant (max distance), so it is unchanged. + let mut blocks = Vec::with_capacity(nc * 16); + for r in 0..nc { + let c = bcen[r]; + for v in [-c[0], c[2], c[1]] { blocks.extend_from_slice(&v.to_le_bytes()); } + blocks.extend_from_slice(&brad[r].sqrt().max(1e-4).to_le_bytes()); + } + let blocks_out = format!("{}.blocks", out.strip_suffix(".soa").unwrap_or(&out)); + std::fs::write(&blocks_out, &blocks).unwrap(); + + // ── assemble BSO2 ── + let mut o = Vec::with_capacity(nc * 40 + nv * 22 + idx.len() + 64); + o.extend_from_slice(b"BSO2"); + o.extend_from_slice(&5u16.to_le_bytes()); // ver 5 = F16 (IEEE half) positions + o.extend_from_slice(&(nc as u32).to_le_bytes()); + o.extend_from_slice(&(nv as u32).to_le_bytes()); + o.extend_from_slice(&(nt as u32).to_le_bytes()); + o.extend_from_slice(&guid); o.extend_from_slice(&material); o.extend_from_slice(&layer); + o.extend_from_slice(&label); o.extend_from_slice(¢roid); o.extend_from_slice(&vrange); + // pos → F16 / IEEE half (10-bit mantissa; ndarray's tested F16::from_f32 RNE) + let pos_f16: Vec = pos[..nv * 3].iter().flat_map(|&f| F16::from_f32(f).0.to_le_bytes()).collect(); + o.extend_from_slice(&pos_f16); + o.extend_from_slice(&helix); + o.extend_from_slice(&row[..nv].iter().flat_map(|r| r.to_le_bytes()).collect::>()); + o.extend_from_slice(&idx[..nt * 12]); + for f in ["body.labels.json", "body.materials.json"] { + let blob = std::fs::read(format!("{dir}/{f}")).unwrap(); + o.extend_from_slice(&(blob.len() as u32).to_le_bytes()); + o.extend_from_slice(&blob); + } + std::fs::write(&out, &o).unwrap(); + + // verify GUID[0] decodes as V3 (classid 0x1000, 8:8 part_of:is_a tiers, unique identity) + let le16 = |o: usize| u16::from_le_bytes(guid[o..o + 2].try_into().unwrap()); + println!("── BSO2 ──"); + println!(" concepts {nc} · verts {nv} · tris {nt}"); + println!(" address GUID[0]: classid={:#010x} heel(po:isa)={:#06x} hip={:#06x} identity={}", + u32::from_le_bytes(guid[0..4].try_into().unwrap()), le16(4), le16(6), le16(14)); + println!(" helix: 2× 3-byte/vertex ({} B col); max normal angle err ~{:.3}°", helix.len(), max_norm_err); + println!(" pos: F16 (ver 5) — {} B col (was {} B as f32); 10-bit mantissa, no staircase", nv * 6, nv * 12); + println!(" blocks: wrote {blocks_out} ({} B = {nc}×16 BlockBounds)", blocks.len()); + println!(" wrote {out} ({} B)", o.len()); +} diff --git a/crates/osint-bake/tools/fill_body_soa.py b/crates/osint-bake/tools/fill_body_soa.py new file mode 100644 index 000000000..31ca44dcd --- /dev/null +++ b/crates/osint-bake/tools/fill_body_soa.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Slicer-fill pass — solid lumen cores for vessels (option-2 rebake, geometry stage). + +Reads bake_body_soa.py's SoA columns and, for each VESSEL concept (Doppler material +0..3: low/high-res artery · portal · systemic venous), extracts a centerline and +generates a solid inner-core tube swept along it — the "3D-printing slicer" lumen +fill. The core renders SOLID (opaque material) inside the translucent #17 wall, so a +vessel reads as a filled flowing tube, not a hollow shell. Appends the fill geometry +to the columns (tagged with the source concept's row → inherits its material/layer), +updates body.concepts.json verts/tris, and the Rust stage re-emits BSO2. + +Centerline (no medial-axis engine; tractable + faithful for tubular structures): + PCA principal axis → project verts → bin into segments → per-bin cross-section + centroid + median radius → polyline centerline → ring-sweep a core at CORE·radius. + +CRITICAL — connected components first: a single FMA vessel concept's mesh is often +SEVERAL disconnected anatomical blobs (e.g. left+right hand/foot vessels under one +concept; a thigh vein row that also tags toe vertices). Fitting ONE PCA axis over all +of them and ring-sweeping bridges the blobs with a solid tube THROUGH EMPTY SPACE — +the "out-of-body" tubes. So we split each concept into spatially-connected components +(grid flood-fill) and fill each component on its own centerline; disconnected blobs +never get bridged. + +Usage: python3 fill_body_soa.py (rewrites the columns in place) +""" +import json +import math +import os +import struct +import sys + +K = 8 # ring resolution (octagon core) +BINS = 14 # centerline segments along the axis +CORE = 0.62 # inner-core radius fraction (under the wall) +RMAX = 0.020 # ABSOLUTE diameter boundary: max cross-section radius in normalized + # [-1,1] body units (~34 mm dia — covers the aorta; clamps balloons). +RMIN = 0.0008 # floor so capillaries still get a visible core +CELL = 0.015 # connected-component grid cell (~13 mm). A continuous vessel keeps + # adjacent cells occupied (26-neighbour reach ~26 mm bridges sampling + # gaps); blobs farther apart (hands ~300 mm, thigh→toe >100 mm) split. +VESSEL_MATERIALS = {0, 1, 2, 3} + + +def princ_axis(pts, mean): + """principal axis via power iteration on the 3x3 covariance.""" + cxx = cxy = cxz = cyy = cyz = czz = 0.0 + for (x, y, z) in pts: + dx, dy, dz = x - mean[0], y - mean[1], z - mean[2] + cxx += dx * dx; cxy += dx * dy; cxz += dx * dz + cyy += dy * dy; cyz += dy * dz; czz += dz * dz + n = len(pts) or 1 + c = [[cxx / n, cxy / n, cxz / n], [cxy / n, cyy / n, cyz / n], [cxz / n, cyz / n, czz / n]] + v = [1.0, 0.3, 0.1] + for _ in range(24): + nv = [c[0][0]*v[0]+c[0][1]*v[1]+c[0][2]*v[2], + c[1][0]*v[0]+c[1][1]*v[1]+c[1][2]*v[2], + c[2][0]*v[0]+c[2][1]*v[1]+c[2][2]*v[2]] + m = math.sqrt(sum(a*a for a in nv)) or 1.0 + v = [a / m for a in nv] + return v + + +def ortho_frame(axis): + """two unit vectors perpendicular to axis (for the ring).""" + a = axis + ref = [1.0, 0.0, 0.0] if abs(a[0]) < 0.9 else [0.0, 1.0, 0.0] + u = [a[1]*ref[2]-a[2]*ref[1], a[2]*ref[0]-a[0]*ref[2], a[0]*ref[1]-a[1]*ref[0]] + m = math.sqrt(sum(c*c for c in u)) or 1.0 + u = [c/m for c in u] + w = [a[1]*u[2]-a[2]*u[1], a[2]*u[0]-a[0]*u[2], a[0]*u[1]-a[1]*u[0]] + return u, w + + +def components(pts, cell): + """connected components of pts via a coarse grid (26-neighbour flood fill). + Points are in one component iff a chain of occupied neighbouring cells links them, + so disconnected blobs sharing one concept are never bridged by the fill.""" + grid = {} + for i, p in enumerate(pts): + key = (int(math.floor(p[0]/cell)), int(math.floor(p[1]/cell)), int(math.floor(p[2]/cell))) + grid.setdefault(key, []).append(i) + seen = set() + comps = [] + for start in list(grid.keys()): + if start in seen: + continue + stack = [start]; seen.add(start); cells = [] + while stack: + c = stack.pop(); cells.append(c) + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for dz in (-1, 0, 1): + nb = (c[0]+dx, c[1]+dy, c[2]+dz) + if nb in grid and nb not in seen: + seen.add(nb); stack.append(nb) + comps.append([pts[i] for c in cells for i in grid[c]]) + return comps + + +def fill_one(pts, crow, fpx, fnx, frow, ftri, base): + """Centerline-fill one connected blob; append a solid core to the shared columns. + Returns 1 if a core was generated, else 0.""" + mean = [sum(p[k] for p in pts)/len(pts) for k in range(3)] + axis = princ_axis(pts, mean) + u, w = ortho_frame(axis) + ts = [(p[0]-mean[0])*axis[0] + (p[1]-mean[1])*axis[1] + (p[2]-mean[2])*axis[2] for p in pts] + tmin, tmax = min(ts), max(ts) + span = (tmax - tmin) or 1e-4 + # bin along the axis; each ring = its bin's own centroid (follows the curve) and + # MEDIAN perpendicular distance from that centroid, clamped to [RMIN, RMAX]. + binned = [[] for _ in range(BINS)] + for p, t in zip(pts, ts): + b = min(BINS-1, int((t - tmin)/span*BINS)) + binned[b].append(p) + rings = [] + for bp in binned: + if len(bp) < 1: + continue + n = len(bp) + cen = [sum(q[k] for q in bp)/n for k in range(3)] + dists = [] + for q in bp: + dx, dy, dz = q[0]-cen[0], q[1]-cen[1], q[2]-cen[2] + axial = dx*axis[0] + dy*axis[1] + dz*axis[2] + perp2 = (dx*dx + dy*dy + dz*dz) - axial*axial + dists.append(math.sqrt(perp2) if perp2 > 0.0 else 0.0) + dists.sort() + rad = min(RMAX, max(RMIN, dists[len(dists)//2] * CORE)) + rings.append((cen, rad)) + if len(rings) < 2: + return 0 + ring_start = [] + for (cen, rad) in rings: + ring_start.append(base + len(fpx)//3) + for k in range(K): + ang = 2*math.pi*k/K + nx = math.cos(ang)*u[0] + math.sin(ang)*w[0] + ny = math.cos(ang)*u[1] + math.sin(ang)*w[1] + nz = math.cos(ang)*u[2] + math.sin(ang)*w[2] + fpx += [cen[0]+rad*nx, cen[1]+rad*ny, cen[2]+rad*nz] + fnx += [nx, ny, nz] # radial normal (helix-normal IS radial for tubes) + frow.append(crow) + for s in range(len(rings)-1): + a0, a1 = ring_start[s], ring_start[s+1] + for k in range(K): + kn = (k+1) % K + ftri += [a0+k, a0+kn, a1+k, a1+k, a0+kn, a1+kn] + return 1 + + +def main(d): + doc = json.load(open(os.path.join(d, "body.concepts.json"))) + concepts = doc["concepts"]; nV = doc["verts"]; nT = doc["tris"] + pos = list(struct.unpack(f"<{nV*3}f", open(os.path.join(d, "body.pos"), "rb").read()[:nV*12])) + row = list(struct.unpack(f"<{nV}I", open(os.path.join(d, "body.row"), "rb").read()[:nV*4])) + + # group vertex indices by concept row (one O(nV) pass) + by_row = {} + for i in range(nV): + by_row.setdefault(row[i], []).append(i) + + fpx, fnx, frow, ftri = [], [], [], [] # fill: pos(3·), normal(3·), row, tris (combined index space) + base = nV + vessels = 0 + for c in concepts: + if c["material"] not in VESSEL_MATERIALS: + continue + idxs = by_row.get(c["row"], []) + if len(idxs) < K * 2: + continue + cpts = [(pos[i*3], pos[i*3+1], pos[i*3+2]) for i in idxs] + # split into connected components so disconnected blobs are never bridged + for comp in components(cpts, CELL): + if len(comp) < K * 2: + continue + vessels += fill_one(comp, c["row"], fpx, fnx, frow, ftri, base) + + if not fpx: + print("no vessel fill generated", file=sys.stderr); return + nfv = len(fpx)//3; nft = len(ftri)//3 + # truncate each column to its EXACT data size (drop bake's 64-byte tail pad), + # then append the fill — so the Rust read stays aligned (no padding mid-stream). + def trunc_append(name, exact, data): + with open(os.path.join(d, name), "r+b") as f: + f.truncate(exact); f.seek(exact); f.write(data) + trunc_append("body.pos", nV*12, struct.pack(f"<{len(fpx)}f", *fpx)) + trunc_append("body.nrm", nV*12, struct.pack(f"<{len(fnx)}f", *fnx)) + trunc_append("body.row", nV*4, struct.pack(f"<{len(frow)}I", *frow)) + trunc_append("body.idx", nT*12, struct.pack(f"<{len(ftri)}I", *ftri)) + doc["verts"] = nV + nfv; doc["tris"] = nT + nft + json.dump(doc, open(os.path.join(d, "body.concepts.json"), "w", encoding="utf-8")) + print(f"slicer-fill: {vessels} vessel components → +{nfv:,} core verts / +{nft:,} core tris " + f"(total {doc['verts']:,} verts / {doc['tris']:,} tris)", file=sys.stderr) + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "soa") diff --git a/crates/osint-bake/tools/helix_orient.py b/crates/osint-bake/tools/helix_orient.py new file mode 100644 index 000000000..4fbd7387e --- /dev/null +++ b/crates/osint-bake/tools/helix_orient.py @@ -0,0 +1,100 @@ +"""helix_orient — deterministic surfel/gaussian orientation as a 1–3 byte code. + +The orientation half of the place/residue substrate (lance-graph crates/helix). +Encodes a unit direction (surfel normal / gaussian axis) as residual-VQ on the +sphere — the SAME RVQ machinery as palette256, on S² instead of the line: + + ENCODING golden-spiral (spherical-Fibonacci) place index, residual-refined. + DECODING Fisher-2z normalized — comparable WITHOUT materialization (O(1) LUT). + +Measured on 12,130 real torso surfels (torso.mesh) / 56,141 (torso.splat): + 1 byte -> 4.87° encode, 48.3 dB render PSNR (visually lossless) + 2 bytes -> 0.97° (beats the 8192-dir target at 2.24°) + 3 bytes -> 0.07° encode, 84.5 dB render PSNR (numerically near-identical) + compare-without-materialization vs true angle: Pearson 0.9917 / Spearman 0.9924 +Replaces a trained 3DGS quaternion (16 B, per-scene) with 3 deterministic bytes. +""" +import math + +_GA = math.pi * (3 - math.sqrt(5)) # golden angle +_K = 256 # one byte per residual level + + +def _codebook(half_angle): + """256 golden-spiral directions over a spherical cap of `half_angle` about +z + (full sphere when half_angle = pi). The deterministic, regenerable template — + never stored, only the chosen index is.""" + out, ymin = [], math.cos(half_angle) + for n in range(_K): + y = 1 - (1 - ymin) * (n + 0.5) / _K + r = math.sqrt(max(0.0, 1 - y * y)) + a = n * _GA + out.append((r * math.cos(a), r * math.sin(a), y)) + return out + + +_FULL = _codebook(math.pi) +_CAPS = [_codebook(0.40), _codebook(0.03)] # residual caps, ~16x finer per level + + +def _dot(p, q): + return p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + + +def _nearest(p, cb): + bi, bd = 0, -2.0 + for j, c in enumerate(cb): + d = _dot(p, c) + if d > bd: + bd, bi = d, j + return bi + + +def _rot(p, k, t): # Rodrigues: rotate p about unit axis k by t + c, s = math.cos(t), math.sin(t) + kxp = (k[1]*p[2]-k[2]*p[1], k[2]*p[0]-k[0]*p[2], k[0]*p[1]-k[1]*p[0]) + kd = _dot(k, p) + return (p[0]*c + kxp[0]*s + k[0]*kd*(1-c), + p[1]*c + kxp[1]*s + k[1]*kd*(1-c), + p[2]*c + kxp[2]*s + k[2]*kd*(1-c)) + + +def _align(a): # axis,angle rotating a -> +z + az = max(-1.0, min(1.0, a[2])) + v = (a[1], -a[0], 0.0) + s = math.hypot(v[0], v[1]) + if s < 1e-9: + return ((1.0, 0.0, 0.0), 0.0 if az > 0 else math.pi) + return ((v[0]/s, v[1]/s, 0.0), math.acos(az)) + + +def encode(normal, levels=3): + """Unit direction -> tuple of `levels` byte indices (1..3). The real helix + encoder is O(1) inverse placement; this exact nearest-search is the reference.""" + code, n = [], normal + cbs = [_FULL] + _CAPS + frames = [] + for lvl in range(levels): + c = _nearest(n, cbs[lvl]) + code.append(c) + if lvl + 1 < levels: + k, t = _align(cbs[lvl][c]) + n = _rot(n, k, t) + frames.append((k, t)) + return tuple(code) + + +def decode(code): + """byte indices -> reconstructed unit direction.""" + cbs = [_FULL] + _CAPS + d = cbs[len(code) - 1][code[-1]] + for lvl in range(len(code) - 2, -1, -1): + k, t = _align(cbs[lvl][code[lvl]]) + d = _rot(d, k, -t) + m = math.sqrt(_dot(d, d)) or 1.0 + return (d[0]/m, d[1]/m, d[2]/m) + + +def angle_error_deg(normal, levels=3): + d = decode(encode(normal, levels)) + return math.degrees(math.acos(max(-1.0, min(1.0, _dot(normal, d))))) diff --git a/crates/stubs/q2-ndarray/Cargo.toml b/crates/stubs/q2-ndarray/Cargo.toml index 05574c1a7..3aabc9f85 100644 --- a/crates/stubs/q2-ndarray/Cargo.toml +++ b/crates/stubs/q2-ndarray/Cargo.toml @@ -31,4 +31,11 @@ matrixmultiply-threading = ["ndarray-simd", "ndarray/matrixmultiply-threading"] hpc = ["ndarray-simd", "blas", "rayon", "matrixmultiply-threading"] [dependencies] -ndarray = { git = "https://github.com/AdaWorldAPI/ndarray.git", branch = "master", optional = true } +# The REAL AdaWorldAPI/ndarray fork — the SAME local checkout lance-graph compiles +# (it reaches it as `../../../ndarray`; from here that is `../../../../ndarray`). +# Not a second git@master clone: a git source would recursively fetch ndarray's +# `crates/burn/upstream` submodule (AdaWorldAPI/burn.git) — an ML framework the +# ndarray workspace `exclude`s and never builds — and would resolve a DISTINCT +# ndarray crate from lance-graph's path copy, so one binary would carry two +# ndarrays. The path makes both consumers unify on the one updated fork. +ndarray = { path = "../../../../ndarray", optional = true }