1use std::collections::HashMap;
19
20use crate::frame_layout::pack_float_object;
21use crate::{q_hash, NQuin};
22use serde_json::Value;
23
24pub 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#[derive(Debug, PartialEq, Eq)]
50pub enum AssetError {
51 Empty,
53 UnknownFormat,
55 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#[derive(Debug, Clone, PartialEq)]
74pub struct Mesh {
75 pub positions: Vec<[f32; 3]>,
77 pub triangles: Vec<[u32; 3]>,
79 pub min: [f32; 3],
81 pub max: [f32; 3],
83}
84
85impl Mesh {
86 #[inline]
88 pub fn vertex_count(&self) -> usize {
89 self.positions.len()
90 }
91
92 #[inline]
94 pub fn triangle_count(&self) -> usize {
95 self.triangles.len()
96 }
97
98 #[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 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
139pub 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) } 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 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
186fn 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
197pub 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 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 _ => {} }
264 }
265
266 Mesh::build(positions, triangles)
267}
268
269pub 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 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
339const GLB_MAGIC: u32 = 0x4654_6C67; const CHUNK_JSON: u32 = 0x4E4F_534A; const CHUNK_BIN: u32 = 0x004E_4942; fn 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
349pub 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 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; }
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
444fn 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, 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
492fn 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
539pub 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
610pub 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#[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
668pub 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 parity: NQuin::calculate_parity(subject, predicate, object, context, metadata),
711 }
712}
713
714#[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 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); 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 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 let mut b = vec![0u8; 80];
785 b.extend_from_slice(&1u32.to_le_bytes());
786 b.extend_from_slice(&[0u8; 12]); 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]); 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 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 assert!(quins
826 .iter()
827 .any(|q| q.predicate == P_RDF_TYPE && q.object == C_MESH));
828 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 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 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 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}