Skip to main content

qualia_core_db/render/
assets.rs

1//! Asset-import bridge — `OBJ` / `STL` mesh → `NQuin` stream (Phase 1.3,
2//! `RENDERER_IMPLEMENTATION_PLAN.md`).
3//!
4//! Pure `&[u8]` → (geometry, semantic NQuins). **No `std::fs`** — wasm-safe (migration review
5//! §2.1): the shell / CLI reads the file (or runs the OS picker) and hands the bytes down. This is
6//! the ingest path (not a hot path), so `Vec` / `HashMap` are fine — the same convention as
7//! `kml_bridge`.
8//!
9//! Two layers, per STELLAR §E ("artefacts carry their geometry, and are *semantically known*"):
10//!   * [`Mesh`] — raw geometry (vertex positions + triangle indices + bounding box): the data the
11//!     GPU vertex/index buffers (Phase 1.2) will consume.
12//!   * [`mesh_to_nquins`] — the **semantic** layer: the asset is *known* (type, counts, bounding
13//!     box, centroid, source format) as NQuins in the one identity space — not just points/pixels.
14//!
15//! Hot-path rendering (depth-stencil, mesh buffers, projection) is the GPU half of Phase 1 and is
16//! verified on hardware; this module is the CPU half and is unit-tested here.
17
18use std::collections::HashMap;
19
20use crate::frame_layout::pack_float_object;
21use crate::{q_hash, NQuin};
22use serde_json::Value;
23
24// ── Named-graph context + predicate / class hashes (one identity space; `q_hash`) ──────────────
25pub const GEOMETRY_CONTEXT: u64 = q_hash("urn:qualia:context:geometry");
26
27const P_RDF_TYPE: u64 = q_hash("http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
28const C_MESH: u64 = q_hash("urn:qualia:geometry:Mesh");
29const P_VERTEX_COUNT: u64 = q_hash("urn:qualia:geometry:vertexCount");
30const P_TRIANGLE_COUNT: u64 = q_hash("urn:qualia:geometry:triangleCount");
31const P_SOURCE_FORMAT: u64 = q_hash("urn:qualia:geometry:sourceFormat");
32const P_BBOX_MIN_X: u64 = q_hash("urn:qualia:geometry:bboxMinX");
33const P_BBOX_MIN_Y: u64 = q_hash("urn:qualia:geometry:bboxMinY");
34const P_BBOX_MIN_Z: u64 = q_hash("urn:qualia:geometry:bboxMinZ");
35const P_BBOX_MAX_X: u64 = q_hash("urn:qualia:geometry:bboxMaxX");
36const P_BBOX_MAX_Y: u64 = q_hash("urn:qualia:geometry:bboxMaxY");
37const P_BBOX_MAX_Z: u64 = q_hash("urn:qualia:geometry:bboxMaxZ");
38const P_CENTROID_X: u64 = q_hash("urn:qualia:geometry:centroidX");
39const P_CENTROID_Y: u64 = q_hash("urn:qualia:geometry:centroidY");
40const P_CENTROID_Z: u64 = q_hash("urn:qualia:geometry:centroidZ");
41const P_SOURCE_DIGEST: u64 = q_hash("urn:qualia:geometry:sourceDigest");
42const P_COMPILED_DIGEST: u64 = q_hash("urn:qualia:geometry:compiledDigest");
43const P_BODY_SYSTEM: u64 = q_hash("urn:qualia:geometry:bodySystem");
44const P_ANATOMY_MODEL: u64 = q_hash("urn:qualia:geometry:anatomyModel");
45const P_GESTATIONAL_AGE_DAYS: u64 = q_hash("urn:qualia:geometry:gestationalAgeDays");
46const P_CARNEGIE_STAGE: u64 = q_hash("urn:qualia:geometry:carnegieStage");
47
48/// Error type for asset import.
49#[derive(Debug, PartialEq, Eq)]
50pub enum AssetError {
51    /// The bytes parsed but produced no geometry.
52    Empty,
53    /// The format could not be recognised from the bytes (and no usable hint was given).
54    UnknownFormat,
55    /// A structural parse failure, with a human-readable reason.
56    Parse(String),
57}
58
59impl std::fmt::Display for AssetError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            AssetError::Empty => write!(f, "asset import: no geometry produced"),
63            AssetError::UnknownFormat => write!(f, "asset import: unrecognised format"),
64            AssetError::Parse(s) => write!(f, "asset import: parse error: {s}"),
65        }
66    }
67}
68
69impl std::error::Error for AssetError {}
70
71/// Raw triangle-mesh geometry. The hot-path GPU buffers (Phase 1.2) consume `positions` +
72/// `triangles`; the bounding box is precomputed for the projection / culling layer (§E §4).
73#[derive(Debug, Clone, PartialEq)]
74pub struct Mesh {
75    /// Vertex positions in model space.
76    pub positions: Vec<[f32; 3]>,
77    /// Triangle vertex indices into `positions` (CCW winding as authored).
78    pub triangles: Vec<[u32; 3]>,
79    /// Axis-aligned bounding-box minimum corner.
80    pub min: [f32; 3],
81    /// Axis-aligned bounding-box maximum corner.
82    pub max: [f32; 3],
83}
84
85impl Mesh {
86    /// Number of vertices.
87    #[inline]
88    pub fn vertex_count(&self) -> usize {
89        self.positions.len()
90    }
91
92    /// Number of triangles.
93    #[inline]
94    pub fn triangle_count(&self) -> usize {
95        self.triangles.len()
96    }
97
98    /// Bounding-box centre (the simple centroid used by the projection/culling layer).
99    #[inline]
100    pub fn centroid(&self) -> [f32; 3] {
101        [
102            0.5 * (self.min[0] + self.max[0]),
103            0.5 * (self.min[1] + self.max[1]),
104            0.5 * (self.min[2] + self.max[2]),
105        ]
106    }
107
108    /// Build a mesh from positions + triangles, computing the bounding box. Validates that every
109    /// index is in range.
110    fn build(positions: Vec<[f32; 3]>, triangles: Vec<[u32; 3]>) -> Result<Mesh, AssetError> {
111        if positions.is_empty() || triangles.is_empty() {
112            return Err(AssetError::Empty);
113        }
114        let n = positions.len() as u32;
115        for t in &triangles {
116            if t[0] >= n || t[1] >= n || t[2] >= n {
117                return Err(AssetError::Parse(format!(
118                    "triangle index out of range (verts={n}, tri={t:?})"
119                )));
120            }
121        }
122        let mut min = [f32::INFINITY; 3];
123        let mut max = [f32::NEG_INFINITY; 3];
124        for p in &positions {
125            for k in 0..3 {
126                min[k] = min[k].min(p[k]);
127                max[k] = max[k].max(p[k]);
128            }
129        }
130        Ok(Mesh {
131            positions,
132            triangles,
133            min,
134            max,
135        })
136    }
137}
138
139// ── Format detection + dispatch ───────────────────────────────────────────────────────────────
140
141/// Import a mesh, sniffing the format from the bytes (or trusting an explicit lowercase
142/// extension hint like `"obj"` / `"stl"`).
143pub fn import_asset(bytes: &[u8], hint: Option<&str>) -> Result<Mesh, AssetError> {
144    match hint {
145        Some(h) if h.eq_ignore_ascii_case("obj") => return import_obj(bytes),
146        Some(h) if h.eq_ignore_ascii_case("stl") => return import_stl(bytes),
147        Some(h) if h.eq_ignore_ascii_case("glb") || h.eq_ignore_ascii_case("gltf") => {
148            return import_glb(bytes)
149        }
150        _ => {}
151    }
152    if looks_like_glb(bytes) {
153        import_glb(bytes) // unambiguous "glTF" magic — check first
154    } else if looks_like_binary_stl(bytes) || looks_like_ascii_stl(bytes) {
155        import_stl(bytes)
156    } else if looks_like_obj(bytes) {
157        import_obj(bytes)
158    } else {
159        Err(AssetError::UnknownFormat)
160    }
161}
162
163fn looks_like_obj(bytes: &[u8]) -> bool {
164    // An OBJ has `v ` (vertex) lines and usually `f ` (face) lines; comments start with `#`.
165    let text = match core::str::from_utf8(bytes) {
166        Ok(t) => t,
167        Err(_) => return false,
168    };
169    text.lines().any(|l| {
170        let l = l.trim_start();
171        l.starts_with("v ") || l.starts_with("f ") || l.starts_with("vn ") || l.starts_with("vt ")
172    })
173}
174
175fn looks_like_ascii_stl(bytes: &[u8]) -> bool {
176    let prefix = &bytes[..bytes.len().min(512)];
177    match core::str::from_utf8(prefix) {
178        Ok(t) => {
179            let t = t.trim_start();
180            t.starts_with("solid") && t.contains("facet")
181        }
182        Err(_) => false,
183    }
184}
185
186/// Binary STL has no magic; it is detected structurally: an 80-byte header, a `u32` triangle
187/// count, then exactly `50 * count` bytes. (Some exporters start a *binary* file with "solid",
188/// which is why the size check — not the prefix — is authoritative.)
189fn looks_like_binary_stl(bytes: &[u8]) -> bool {
190    if bytes.len() < 84 {
191        return false;
192    }
193    let count = u32::from_le_bytes([bytes[80], bytes[81], bytes[82], bytes[83]]) as usize;
194    bytes.len() == 84 + count * 50
195}
196
197// ── Wavefront OBJ ─────────────────────────────────────────────────────────────────────────────
198
199/// Parse a Wavefront `.obj`: `v x y z` vertices and `f` faces (polygons fan-triangulated).
200/// Face tokens may be `v`, `v/vt`, `v//vn`, or `v/vt/vn`; indices are 1-based and may be negative
201/// (relative to the current vertex count). `vt` / `vn` / `vp` / groups are ignored for geometry.
202pub fn import_obj(bytes: &[u8]) -> Result<Mesh, AssetError> {
203    let text = core::str::from_utf8(bytes)
204        .map_err(|_| AssetError::Parse("OBJ is not valid UTF-8".into()))?;
205
206    let mut positions: Vec<[f32; 3]> = Vec::new();
207    let mut triangles: Vec<[u32; 3]> = Vec::new();
208
209    for (lineno, raw) in text.lines().enumerate() {
210        let line = raw.trim();
211        if line.is_empty() || line.starts_with('#') {
212            continue;
213        }
214        let mut tok = line.split_whitespace();
215        match tok.next() {
216            Some("v") => {
217                let coords: Vec<f32> = tok.filter_map(|s| s.parse::<f32>().ok()).collect();
218                if coords.len() < 3 {
219                    return Err(AssetError::Parse(format!(
220                        "OBJ line {}: vertex needs 3 coords",
221                        lineno + 1
222                    )));
223                }
224                positions.push([coords[0], coords[1], coords[2]]);
225            }
226            Some("f") => {
227                // Resolve each face vertex token to a 0-based index, then fan-triangulate.
228                let mut face: Vec<u32> = Vec::new();
229                for t in tok {
230                    let first = t.split('/').next().unwrap_or("");
231                    let idx: i64 = match first.parse() {
232                        Ok(i) => i,
233                        Err(_) => {
234                            return Err(AssetError::Parse(format!(
235                                "OBJ line {}: bad face index {t:?}",
236                                lineno + 1
237                            )))
238                        }
239                    };
240                    let zero_based = if idx > 0 {
241                        (idx - 1) as i64
242                    } else if idx < 0 {
243                        positions.len() as i64 + idx
244                    } else {
245                        return Err(AssetError::Parse(format!(
246                            "OBJ line {}: face index 0 is invalid",
247                            lineno + 1
248                        )));
249                    };
250                    if zero_based < 0 || zero_based as usize >= positions.len() {
251                        return Err(AssetError::Parse(format!(
252                            "OBJ line {}: face index {idx} out of range",
253                            lineno + 1
254                        )));
255                    }
256                    face.push(zero_based as u32);
257                }
258                for i in 1..face.len().saturating_sub(1) {
259                    triangles.push([face[0], face[i], face[i + 1]]);
260                }
261            }
262            _ => {} // vt / vn / vp / g / o / s / mtllib / usemtl — not needed for geometry
263        }
264    }
265
266    Mesh::build(positions, triangles)
267}
268
269// ── STL (binary + ASCII) ──────────────────────────────────────────────────────────────────────
270
271/// Parse a `.stl` (auto-detects binary vs ASCII). Each STL triangle contributes 3 fresh vertices
272/// (no welding) — a faithful, lossless first cut; vertex de-duplication is a later optimisation.
273pub fn import_stl(bytes: &[u8]) -> Result<Mesh, AssetError> {
274    if looks_like_binary_stl(bytes) {
275        import_stl_binary(bytes)
276    } else {
277        import_stl_ascii(bytes)
278    }
279}
280
281fn import_stl_binary(bytes: &[u8]) -> Result<Mesh, AssetError> {
282    if bytes.len() < 84 {
283        return Err(AssetError::Parse("binary STL shorter than header".into()));
284    }
285    let count = u32::from_le_bytes([bytes[80], bytes[81], bytes[82], bytes[83]]) as usize;
286    let expected = 84 + count * 50;
287    if bytes.len() != expected {
288        return Err(AssetError::Parse(format!(
289            "binary STL size {} != expected {expected} for {count} triangles",
290            bytes.len()
291        )));
292    }
293    let mut positions: Vec<[f32; 3]> = Vec::with_capacity(count * 3);
294    let mut triangles: Vec<[u32; 3]> = Vec::with_capacity(count);
295    let read_f32 = |o: usize| -> f32 {
296        f32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]])
297    };
298    for t in 0..count {
299        // 50 bytes/triangle: 12 (normal) + 3×12 (verts) + 2 (attr). Skip the normal.
300        let base = 84 + t * 50 + 12;
301        let v0 = positions.len() as u32;
302        for v in 0..3 {
303            let o = base + v * 12;
304            positions.push([read_f32(o), read_f32(o + 4), read_f32(o + 8)]);
305        }
306        triangles.push([v0, v0 + 1, v0 + 2]);
307    }
308    Mesh::build(positions, triangles)
309}
310
311fn import_stl_ascii(bytes: &[u8]) -> Result<Mesh, AssetError> {
312    let text = core::str::from_utf8(bytes)
313        .map_err(|_| AssetError::Parse("ASCII STL is not valid UTF-8".into()))?;
314    let mut positions: Vec<[f32; 3]> = Vec::new();
315    let mut triangles: Vec<[u32; 3]> = Vec::new();
316    let mut pending: Vec<[f32; 3]> = Vec::new();
317    for raw in text.lines() {
318        let line = raw.trim();
319        if let Some(rest) = line.strip_prefix("vertex ") {
320            let c: Vec<f32> = rest
321                .split_whitespace()
322                .filter_map(|s| s.parse().ok())
323                .collect();
324            if c.len() < 3 {
325                return Err(AssetError::Parse("ASCII STL: vertex needs 3 coords".into()));
326            }
327            pending.push([c[0], c[1], c[2]]);
328            if pending.len() == 3 {
329                let v0 = positions.len() as u32;
330                positions.extend_from_slice(&pending);
331                triangles.push([v0, v0 + 1, v0 + 2]);
332                pending.clear();
333            }
334        }
335    }
336    Mesh::build(positions, triangles)
337}
338
339// ── glTF Binary (GLB) ─────────────────────────────────────────────────────────────────────────
340
341const GLB_MAGIC: u32 = 0x4654_6C67; // "glTF" little-endian
342const CHUNK_JSON: u32 = 0x4E4F_534A; // "JSON"
343const CHUNK_BIN: u32 = 0x004E_4942; // "BIN\0"
344
345fn looks_like_glb(bytes: &[u8]) -> bool {
346    bytes.len() >= 12 && u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) == GLB_MAGIC
347}
348
349/// Parse a binary glTF (`.glb`): the 12-byte header, JSON chunk, and BIN chunk, then walk
350/// `meshes[].primitives[]`, reading each `POSITION` accessor (FLOAT VEC3) and the optional index
351/// accessor (u8/u16/u32 SCALAR) out of the BIN buffer. Triangle primitives only (mode 4); other
352/// modes are skipped (a faithful first cut). Embedded/external-URI buffers are not handled here —
353/// self-contained GLB binary only.
354pub fn import_glb(bytes: &[u8]) -> Result<Mesh, AssetError> {
355    if bytes.len() < 12 {
356        return Err(AssetError::Parse("GLB shorter than 12-byte header".into()));
357    }
358    if u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) != GLB_MAGIC {
359        return Err(AssetError::UnknownFormat);
360    }
361    // bytes[4..8] = version, bytes[8..12] = total length (not re-validated).
362
363    let mut json: Option<&[u8]> = None;
364    let mut bin: Option<&[u8]> = None;
365    let mut off = 12usize;
366    while off + 8 <= bytes.len() {
367        let clen = u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]])
368            as usize;
369        let ctype = u32::from_le_bytes([
370            bytes[off + 4],
371            bytes[off + 5],
372            bytes[off + 6],
373            bytes[off + 7],
374        ]);
375        let dstart = off + 8;
376        let dend = dstart
377            .checked_add(clen)
378            .ok_or_else(|| AssetError::Parse("GLB chunk length overflow".into()))?;
379        if dend > bytes.len() {
380            return Err(AssetError::Parse("GLB chunk exceeds file".into()));
381        }
382        match ctype {
383            CHUNK_JSON => json = Some(&bytes[dstart..dend]),
384            CHUNK_BIN => bin = Some(&bytes[dstart..dend]),
385            _ => {}
386        }
387        off = dend;
388    }
389
390    let json = json.ok_or_else(|| AssetError::Parse("GLB has no JSON chunk".into()))?;
391    let gltf: Value =
392        serde_json::from_slice(json).map_err(|e| AssetError::Parse(format!("glTF JSON: {e}")))?;
393    let bin = bin.unwrap_or(&[]);
394
395    let empty: Vec<Value> = Vec::new();
396    let accessors = gltf["accessors"].as_array().unwrap_or(&empty);
397    let buffer_views = gltf["bufferViews"].as_array().unwrap_or(&empty);
398    let meshes = gltf["meshes"].as_array().unwrap_or(&empty);
399
400    let mut positions: Vec<[f32; 3]> = Vec::new();
401    let mut triangles: Vec<[u32; 3]> = Vec::new();
402
403    for mesh in meshes {
404        for prim in mesh["primitives"].as_array().into_iter().flatten() {
405            if prim["mode"].as_u64().unwrap_or(4) != 4 {
406                continue; // not TRIANGLES
407            }
408            let pos_idx = prim["attributes"]["POSITION"]
409                .as_u64()
410                .ok_or_else(|| AssetError::Parse("primitive has no POSITION".into()))?
411                as usize;
412            let pos_acc = accessors
413                .get(pos_idx)
414                .ok_or_else(|| AssetError::Parse("POSITION accessor index out of range".into()))?;
415            let base = positions.len() as u32;
416            let prim_pos = read_positions(pos_acc, buffer_views, bin)?;
417            let vcount = prim_pos.len() as u32;
418            positions.extend_from_slice(&prim_pos);
419
420            match prim["indices"].as_u64() {
421                Some(idx_i) => {
422                    let idx_acc = accessors
423                        .get(idx_i as usize)
424                        .ok_or_else(|| AssetError::Parse("index accessor out of range".into()))?;
425                    let idx = read_indices(idx_acc, buffer_views, bin)?;
426                    for c in idx.chunks_exact(3) {
427                        triangles.push([base + c[0], base + c[1], base + c[2]]);
428                    }
429                }
430                None => {
431                    let mut i = 0u32;
432                    while i + 3 <= vcount {
433                        triangles.push([base + i, base + i + 1, base + i + 2]);
434                        i += 3;
435                    }
436                }
437            }
438        }
439    }
440
441    Mesh::build(positions, triangles)
442}
443
444/// Read a FLOAT VEC3 accessor (e.g. `POSITION`) out of the GLB binary buffer.
445fn read_positions(
446    accessor: &Value,
447    bvs: &[Value],
448    bin: &[u8],
449) -> Result<Vec<[f32; 3]>, AssetError> {
450    if accessor["componentType"].as_u64() != Some(5126) {
451        return Err(AssetError::Parse(
452            "POSITION componentType must be FLOAT (5126)".into(),
453        ));
454    }
455    if accessor["type"].as_str() != Some("VEC3") {
456        return Err(AssetError::Parse(
457            "POSITION accessor type must be VEC3".into(),
458        ));
459    }
460    let count = accessor["count"]
461        .as_u64()
462        .ok_or_else(|| AssetError::Parse("accessor.count missing".into()))?
463        as usize;
464    let acc_off = accessor["byteOffset"].as_u64().unwrap_or(0) as usize;
465    let bv_idx = accessor["bufferView"]
466        .as_u64()
467        .ok_or_else(|| AssetError::Parse("accessor.bufferView missing".into()))?
468        as usize;
469    let bv = bvs
470        .get(bv_idx)
471        .ok_or_else(|| AssetError::Parse("bufferView index out of range".into()))?;
472    let bv_off = bv["byteOffset"].as_u64().unwrap_or(0) as usize;
473    let stride = match bv["byteStride"].as_u64().unwrap_or(0) {
474        0 => 12, // tightly packed VEC3 f32
475        s => s as usize,
476    };
477    let start = bv_off + acc_off;
478    let mut out = Vec::with_capacity(count);
479    for i in 0..count {
480        let o = start + i * stride;
481        if o + 12 > bin.len() {
482            return Err(AssetError::Parse("POSITION read past end of BIN".into()));
483        }
484        let rd = |k: usize| {
485            f32::from_le_bytes([bin[o + k], bin[o + k + 1], bin[o + k + 2], bin[o + k + 3]])
486        };
487        out.push([rd(0), rd(4), rd(8)]);
488    }
489    Ok(out)
490}
491
492/// Read a SCALAR index accessor (u8/u16/u32) out of the GLB binary buffer, widened to `u32`.
493fn read_indices(accessor: &Value, bvs: &[Value], bin: &[u8]) -> Result<Vec<u32>, AssetError> {
494    if accessor["type"].as_str() != Some("SCALAR") {
495        return Err(AssetError::Parse(
496            "index accessor type must be SCALAR".into(),
497        ));
498    }
499    let comp = match accessor["componentType"].as_u64() {
500        Some(5121) => 1usize,
501        Some(5123) => 2,
502        Some(5125) => 4,
503        _ => {
504            return Err(AssetError::Parse(
505                "index componentType must be u8/u16/u32".into(),
506            ))
507        }
508    };
509    let count = accessor["count"]
510        .as_u64()
511        .ok_or_else(|| AssetError::Parse("accessor.count missing".into()))?
512        as usize;
513    let acc_off = accessor["byteOffset"].as_u64().unwrap_or(0) as usize;
514    let bv_idx = accessor["bufferView"]
515        .as_u64()
516        .ok_or_else(|| AssetError::Parse("accessor.bufferView missing".into()))?
517        as usize;
518    let bv = bvs
519        .get(bv_idx)
520        .ok_or_else(|| AssetError::Parse("bufferView index out of range".into()))?;
521    let bv_off = bv["byteOffset"].as_u64().unwrap_or(0) as usize;
522    let start = bv_off + acc_off;
523    let mut out = Vec::with_capacity(count);
524    for i in 0..count {
525        let o = start + i * comp;
526        if o + comp > bin.len() {
527            return Err(AssetError::Parse("index read past end of BIN".into()));
528        }
529        let v = match comp {
530            1 => bin[o] as u32,
531            2 => u16::from_le_bytes([bin[o], bin[o + 1]]) as u32,
532            _ => u32::from_le_bytes([bin[o], bin[o + 1], bin[o + 2], bin[o + 3]]),
533        };
534        out.push(v);
535    }
536    Ok(out)
537}
538
539// ── Semantic layer: Mesh → NQuins ─────────────────────────────────────────────────────────────
540
541/// Emit the **semantic** quins for a mesh asset (the asset is *known*, not just drawn): its type,
542/// vertex/triangle counts, bounding box, centroid, and source format — all in `GEOMETRY_CONTEXT`,
543/// in the one identity space. Floats use the inline-float object tag (ADR 0008); counts are raw
544/// integers. The returned lexicon maps the asset-URI / format hashes back to their strings.
545pub fn mesh_to_nquins(
546    mesh: &Mesh,
547    asset_uri: &str,
548    source_format: &str,
549) -> (Vec<NQuin>, HashMap<u64, String>) {
550    let subject = fnv_hash(asset_uri.as_bytes());
551    let mut quins: Vec<NQuin> = Vec::with_capacity(13);
552    let mut lexicon: HashMap<u64, String> = HashMap::new();
553    lexicon.insert(subject, asset_uri.to_owned());
554
555    quins.push(make_quin(subject, P_RDF_TYPE, C_MESH));
556    quins.push(make_quin(
557        subject,
558        P_VERTEX_COUNT,
559        mesh.vertex_count() as u64,
560    ));
561    quins.push(make_quin(
562        subject,
563        P_TRIANGLE_COUNT,
564        mesh.triangle_count() as u64,
565    ));
566
567    let fmt_hash = fnv_hash(source_format.as_bytes());
568    lexicon.insert(fmt_hash, source_format.to_owned());
569    quins.push(make_quin(subject, P_SOURCE_FORMAT, fmt_hash));
570
571    quins.push(make_quin(
572        subject,
573        P_BBOX_MIN_X,
574        pack_float_object(mesh.min[0]),
575    ));
576    quins.push(make_quin(
577        subject,
578        P_BBOX_MIN_Y,
579        pack_float_object(mesh.min[1]),
580    ));
581    quins.push(make_quin(
582        subject,
583        P_BBOX_MIN_Z,
584        pack_float_object(mesh.min[2]),
585    ));
586    quins.push(make_quin(
587        subject,
588        P_BBOX_MAX_X,
589        pack_float_object(mesh.max[0]),
590    ));
591    quins.push(make_quin(
592        subject,
593        P_BBOX_MAX_Y,
594        pack_float_object(mesh.max[1]),
595    ));
596    quins.push(make_quin(
597        subject,
598        P_BBOX_MAX_Z,
599        pack_float_object(mesh.max[2]),
600    ));
601
602    let c = mesh.centroid();
603    quins.push(make_quin(subject, P_CENTROID_X, pack_float_object(c[0])));
604    quins.push(make_quin(subject, P_CENTROID_Y, pack_float_object(c[1])));
605    quins.push(make_quin(subject, P_CENTROID_Z, pack_float_object(c[2])));
606
607    (quins, lexicon)
608}
609
610/// Like [`mesh_to_nquins`] but also asserts the immutable `sourceDigest` and the `.10d`
611/// `compiledDigest` — the manifest→container join (geometry-asset-ontology §4). Both are CRC-32C
612/// `u32` content hashes stored as `u64` objects: `sourceDigest` over the immutable source asset
613/// bytes, `compiledDigest` the whole-file CRC of the `.10d` container this manifest describes.
614pub fn mesh_to_nquins_with_digests(
615    mesh: &Mesh,
616    asset_uri: &str,
617    source_format: &str,
618    source_digest: u32,
619    compiled_digest: u32,
620) -> (Vec<NQuin>, HashMap<u64, String>) {
621    let (mut quins, lexicon) = mesh_to_nquins(mesh, asset_uri, source_format);
622    let subject = fnv_hash(asset_uri.as_bytes());
623    quins.push(make_quin(subject, P_SOURCE_DIGEST, source_digest as u64));
624    quins.push(make_quin(
625        subject,
626        P_COMPILED_DIGEST,
627        compiled_digest as u64,
628    ));
629    (quins, lexicon)
630}
631
632/// Like [`mesh_to_nquins_with_digests`] but also asserts the anatomy binding for a 3D-body organ:
633/// `bodySystem` (which of the 17 body systems this organ belongs to — which system's burden colours
634/// it) and `anatomyModel` (`"male"` / `"female"` — the reference model whose set it is part of, chosen
635/// from the user's declared XY/XX chromosomal basis). Both are string facts carried in the lexicon,
636/// like `sourceFormat`; a `None` field is simply not asserted.
637#[allow(clippy::too_many_arguments)]
638pub fn mesh_to_nquins_with_meta(
639    mesh: &Mesh,
640    asset_uri: &str,
641    source_format: &str,
642    source_digest: u32,
643    compiled_digest: u32,
644    body_system: Option<&str>,
645    anatomy_model: Option<&str>,
646) -> (Vec<NQuin>, HashMap<u64, String>) {
647    let (mut quins, mut lexicon) = mesh_to_nquins_with_digests(
648        mesh,
649        asset_uri,
650        source_format,
651        source_digest,
652        compiled_digest,
653    );
654    let subject = fnv_hash(asset_uri.as_bytes());
655    if let Some(sys) = body_system {
656        let h = fnv_hash(sys.as_bytes());
657        lexicon.insert(h, sys.to_owned());
658        quins.push(make_quin(subject, P_BODY_SYSTEM, h));
659    }
660    if let Some(model) = anatomy_model {
661        let h = fnv_hash(model.as_bytes());
662        lexicon.insert(h, model.to_owned());
663        quins.push(make_quin(subject, P_ANATOMY_MODEL, h));
664    }
665    (quins, lexicon)
666}
667
668/// Like [`mesh_to_nquins_with_digests`] but for a **developmental** body: also asserts the gestational
669/// `t`-axis coordinate — `gestationalAgeDays` (postfertilization) and `carnegieStage`. This is what makes
670/// a fetal `.10d` a *slice* of a 4-D developmental body: consecutive stages, ordered by gestational age,
671/// are the same body along the `t`-axis (reproductive-continuum plan §2). Both are plain `u64` objects.
672pub fn mesh_to_nquins_with_dev(
673    mesh: &Mesh,
674    asset_uri: &str,
675    source_format: &str,
676    source_digest: u32,
677    compiled_digest: u32,
678    gestational_age_days: u16,
679    carnegie_stage: u8,
680) -> (Vec<NQuin>, HashMap<u64, String>) {
681    let (mut quins, lexicon) = mesh_to_nquins_with_digests(
682        mesh,
683        asset_uri,
684        source_format,
685        source_digest,
686        compiled_digest,
687    );
688    let subject = fnv_hash(asset_uri.as_bytes());
689    quins.push(make_quin(
690        subject,
691        P_GESTATIONAL_AGE_DAYS,
692        gestational_age_days as u64,
693    ));
694    quins.push(make_quin(subject, P_CARNEGIE_STAGE, carnegie_stage as u64));
695    (quins, lexicon)
696}
697
698#[inline]
699fn make_quin(subject: u64, predicate: u64, object: u64) -> NQuin {
700    let context = GEOMETRY_CONTEXT;
701    let metadata = 0;
702    NQuin {
703        subject,
704        predicate,
705        object,
706        context,
707        metadata,
708        // Was hardcoded `0` — emitted invalid-parity geometry NQuins. Use the canonical parity so
709        // the "every runtime NQuin has valid field parity" invariant holds (visual-plan §3.1 fix).
710        parity: NQuin::calculate_parity(subject, predicate, object, context, metadata),
711    }
712}
713
714/// FNV-1a (60-bit), matching `crate::q_hash` for runtime strings so asset IRIs hashed here share
715/// the one identity space (same convention as `kml_bridge`).
716#[inline]
717fn fnv_hash(bytes: &[u8]) -> u64 {
718    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
719    for &b in bytes {
720        h ^= b as u64;
721        h = h.wrapping_mul(0x0000_0100_0000_01b3);
722    }
723    h & 0x0FFF_FFFF_FFFF_FFFF
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use crate::frame_layout::unpack_float_object;
730
731    const TRI_OBJ: &str = "# a single triangle\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
732
733    // A unit quad (two triangles via fan) + face tokens with v/vt/vn slashes.
734    const QUAD_OBJ: &str = "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1/1/1 2/2/1 3/3/1 4/4/1\n";
735
736    #[test]
737    fn obj_triangle() {
738        let m = import_obj(TRI_OBJ.as_bytes()).unwrap();
739        assert_eq!(m.vertex_count(), 3);
740        assert_eq!(m.triangle_count(), 1);
741        assert_eq!(m.min, [0.0, 0.0, 0.0]);
742        assert_eq!(m.max, [1.0, 1.0, 0.0]);
743        assert_eq!(m.triangles[0], [0, 1, 2]);
744    }
745
746    #[test]
747    fn obj_quad_fan_triangulates_and_ignores_vt_vn() {
748        let m = import_obj(QUAD_OBJ.as_bytes()).unwrap();
749        assert_eq!(m.vertex_count(), 4);
750        assert_eq!(m.triangle_count(), 2); // quad → 2 triangles
751        assert_eq!(m.triangles[0], [0, 1, 2]);
752        assert_eq!(m.triangles[1], [0, 2, 3]);
753    }
754
755    #[test]
756    fn obj_negative_indices() {
757        // -1/-2/-3 reference the three most-recent vertices.
758        let obj = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf -3 -2 -1\n";
759        let m = import_obj(obj.as_bytes()).unwrap();
760        assert_eq!(m.triangles[0], [0, 1, 2]);
761    }
762
763    #[test]
764    fn obj_out_of_range_face_is_error() {
765        let obj = "v 0 0 0\nv 1 0 0\nf 1 2 9\n";
766        assert!(matches!(
767            import_obj(obj.as_bytes()),
768            Err(AssetError::Parse(_))
769        ));
770    }
771
772    #[test]
773    fn stl_ascii_triangle() {
774        let stl = "solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n";
775        let m = import_stl(stl.as_bytes()).unwrap();
776        assert_eq!(m.vertex_count(), 3);
777        assert_eq!(m.triangle_count(), 1);
778        assert_eq!(m.max, [1.0, 1.0, 0.0]);
779    }
780
781    #[test]
782    fn stl_binary_triangle() {
783        // 80-byte header + u32(1) + one 50-byte triangle record.
784        let mut b = vec![0u8; 80];
785        b.extend_from_slice(&1u32.to_le_bytes());
786        b.extend_from_slice(&[0u8; 12]); // normal
787        for v in [[0f32, 0., 0.], [2., 0., 0.], [0., 3., 0.]] {
788            for c in v {
789                b.extend_from_slice(&c.to_le_bytes());
790            }
791        }
792        b.extend_from_slice(&[0u8; 2]); // attribute byte count
793        assert!(looks_like_binary_stl(&b));
794        let m = import_stl(&b).unwrap();
795        assert_eq!(m.vertex_count(), 3);
796        assert_eq!(m.triangle_count(), 1);
797        assert_eq!(m.max, [2.0, 3.0, 0.0]);
798    }
799
800    #[test]
801    fn dispatch_sniffs_format() {
802        assert_eq!(
803            import_asset(TRI_OBJ.as_bytes(), None)
804                .unwrap()
805                .triangle_count(),
806            1
807        );
808        assert_eq!(
809            import_asset(TRI_OBJ.as_bytes(), Some("obj"))
810                .unwrap()
811                .vertex_count(),
812            3
813        );
814    }
815
816    #[test]
817    fn mesh_to_nquins_emits_known_geometry() {
818        let m = import_obj(TRI_OBJ.as_bytes()).unwrap();
819        let (quins, lex) = mesh_to_nquins(&m, "urn:asset:tri", "obj");
820        // type + 2 counts + format + 6 bbox + 3 centroid = 13.
821        assert_eq!(quins.len(), 13);
822        let subject = fnv_hash(b"urn:asset:tri");
823        assert_eq!(lex.get(&subject).unwrap(), "urn:asset:tri");
824        // The type quin is present.
825        assert!(quins
826            .iter()
827            .any(|q| q.predicate == P_RDF_TYPE && q.object == C_MESH));
828        // bboxMaxX round-trips through the inline-float tag.
829        let max_x = quins.iter().find(|q| q.predicate == P_BBOX_MAX_X).unwrap();
830        assert_eq!(unpack_float_object(max_x.object), 1.0);
831        // Every emitted geometry NQuin has valid (non-zero, canonical) parity — regression guard for
832        // the former hardcoded `parity: 0`.
833        for q in &quins {
834            assert_eq!(
835                q.parity,
836                NQuin::calculate_parity(q.subject, q.predicate, q.object, q.context, q.metadata),
837                "geometry NQuin must carry canonical parity"
838            );
839        }
840    }
841
842    fn build_test_glb() -> Vec<u8> {
843        // BIN: 3 positions (VEC3 f32, 36 B) then 3 indices (u16, 6 B), padded to 4 -> 44 B.
844        let mut bin = Vec::new();
845        for v in [[0f32, 0., 0.], [2., 0., 0.], [0., 4., 0.]] {
846            for c in v {
847                bin.extend_from_slice(&c.to_le_bytes());
848            }
849        }
850        for i in [0u16, 1, 2] {
851            bin.extend_from_slice(&i.to_le_bytes());
852        }
853        while bin.len() % 4 != 0 {
854            bin.push(0);
855        }
856        let json = r#"{"asset":{"version":"2.0"},"buffers":[{"byteLength":44}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":36},{"buffer":0,"byteOffset":36,"byteLength":6}],"accessors":[{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3"},{"bufferView":1,"componentType":5123,"count":3,"type":"SCALAR"}],"meshes":[{"primitives":[{"attributes":{"POSITION":0},"indices":1}]}]}"#;
857        let mut jb = json.as_bytes().to_vec();
858        while jb.len() % 4 != 0 {
859            jb.push(b' ');
860        }
861        let total = 12 + 8 + jb.len() + 8 + bin.len();
862        let mut glb = Vec::new();
863        glb.extend_from_slice(&GLB_MAGIC.to_le_bytes());
864        glb.extend_from_slice(&2u32.to_le_bytes());
865        glb.extend_from_slice(&(total as u32).to_le_bytes());
866        glb.extend_from_slice(&(jb.len() as u32).to_le_bytes());
867        glb.extend_from_slice(&CHUNK_JSON.to_le_bytes());
868        glb.extend_from_slice(&jb);
869        glb.extend_from_slice(&(bin.len() as u32).to_le_bytes());
870        glb.extend_from_slice(&CHUNK_BIN.to_le_bytes());
871        glb.extend_from_slice(&bin);
872        glb
873    }
874
875    #[test]
876    fn glb_single_triangle() {
877        let glb = build_test_glb();
878        assert!(looks_like_glb(&glb));
879        let m = import_glb(&glb).unwrap();
880        assert_eq!(m.vertex_count(), 3);
881        assert_eq!(m.triangle_count(), 1);
882        assert_eq!(m.max, [2.0, 4.0, 0.0]);
883        assert_eq!(m.triangles[0], [0, 1, 2]);
884        // dispatch via the "glTF" magic
885        assert_eq!(import_asset(&glb, None).unwrap().triangle_count(), 1);
886        assert_eq!(import_asset(&glb, Some("glb")).unwrap().vertex_count(), 3);
887    }
888
889    #[test]
890    fn empty_obj_is_error() {
891        assert_eq!(import_obj(b"# nothing here\n"), Err(AssetError::Empty));
892    }
893}