1use ogeom_core::ogeom_bail;
23use ogeom_math::{Point, Vector};
24use ogeom_topo::Triangulation;
25use std::fmt::Write as _;
26
27#[derive(Debug, Clone)]
29pub struct ExportMesh<'a> {
30 pub mesh: &'a Triangulation,
32 pub colour: Option<[f64; 4]>,
34 pub name: Option<String>,
36}
37
38impl<'a> ExportMesh<'a> {
39 #[must_use]
41 pub fn plain(mesh: &'a Triangulation) -> Self {
42 Self {
43 mesh,
44 colour: None,
45 name: None,
46 }
47 }
48}
49
50#[must_use]
59pub fn write_glb(meshes: &[ExportMesh<'_>]) -> Vec<u8> {
60 let mut binary: Vec<u8> = Vec::new();
61 let mut accessors = String::new();
62 let mut buffer_views = String::new();
63 let mut mesh_json = String::new();
64 let mut node_json = String::new();
65 let mut material_json = String::new();
66 let mut accessor_count = 0_usize;
67 let mut view_count = 0_usize;
68 let mut mesh_count = 0_usize;
69 let mut material_count = 0_usize;
70
71 for export in meshes {
72 let mesh = export.mesh;
73 if mesh.triangles.is_empty() || mesh.positions.is_empty() {
74 continue;
75 }
76 let comma = |s: &mut String| {
77 if !s.is_empty() {
78 s.push(',');
79 }
80 };
81
82 let position_offset = binary.len();
84 let (mut low, mut high) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
85 for p in &mesh.positions {
86 for (k, v) in [p.x, p.y, p.z].into_iter().enumerate() {
87 low[k] = low[k].min(v);
88 high[k] = high[k].max(v);
89 #[allow(clippy::cast_possible_truncation)]
90 binary.extend_from_slice(&(v as f32).to_le_bytes());
91 }
92 }
93 comma(&mut buffer_views);
94 let _ = write!(
95 buffer_views,
96 r#"{{"buffer":0,"byteOffset":{position_offset},"byteLength":{}}}"#,
97 binary.len() - position_offset
98 );
99 let position_view = view_count;
100 view_count += 1;
101 comma(&mut accessors);
102 #[allow(clippy::cast_possible_truncation)]
103 let (lo, hi) = (low.map(|v| v as f32), high.map(|v| v as f32));
104 let _ = write!(
105 accessors,
106 r#"{{"bufferView":{position_view},"componentType":5126,"count":{},"type":"VEC3","min":[{},{},{}],"max":[{},{},{}]}}"#,
107 mesh.positions.len(),
108 lo[0],
109 lo[1],
110 lo[2],
111 hi[0],
112 hi[1],
113 hi[2],
114 );
115 let position_accessor = accessor_count;
116 accessor_count += 1;
117
118 let normal_offset = binary.len();
120 for n in &mesh.normals {
121 let magnitude = n.magnitude();
122 let unit = if magnitude > 0.0 {
123 [n.x / magnitude, n.y / magnitude, n.z / magnitude]
124 } else {
125 [0.0, 0.0, 1.0]
126 };
127 for v in unit {
128 #[allow(clippy::cast_possible_truncation)]
129 binary.extend_from_slice(&(v as f32).to_le_bytes());
130 }
131 }
132 comma(&mut buffer_views);
133 let _ = write!(
134 buffer_views,
135 r#"{{"buffer":0,"byteOffset":{normal_offset},"byteLength":{}}}"#,
136 binary.len() - normal_offset
137 );
138 let normal_view = view_count;
139 view_count += 1;
140 comma(&mut accessors);
141 let _ = write!(
142 accessors,
143 r#"{{"bufferView":{normal_view},"componentType":5126,"count":{},"type":"VEC3"}}"#,
144 mesh.normals.len(),
145 );
146 let normal_accessor = accessor_count;
147 accessor_count += 1;
148
149 let index_offset = binary.len();
151 for t in &mesh.triangles {
152 for &k in t {
153 binary.extend_from_slice(&k.to_le_bytes());
154 }
155 }
156 comma(&mut buffer_views);
157 let _ = write!(
158 buffer_views,
159 r#"{{"buffer":0,"byteOffset":{index_offset},"byteLength":{}}}"#,
160 binary.len() - index_offset
161 );
162 let index_view = view_count;
163 view_count += 1;
164 comma(&mut accessors);
165 let _ = write!(
166 accessors,
167 r#"{{"bufferView":{index_view},"componentType":5125,"count":{},"type":"SCALAR"}}"#,
168 mesh.triangles.len() * 3,
169 );
170 let index_accessor = accessor_count;
171 accessor_count += 1;
172
173 let material = export.colour.map(|[r, g, b, a]| {
175 comma(&mut material_json);
176 let _ = write!(
177 material_json,
178 r#"{{"pbrMetallicRoughness":{{"baseColorFactor":[{r},{g},{b},{a}],"metallicFactor":0.1,"roughnessFactor":0.8}}}}"#,
179 );
180 material_count += 1;
181 material_count - 1
182 });
183
184 comma(&mut mesh_json);
185 let material_field = material.map_or(String::new(), |m| format!(r#","material":{m}"#));
186 let _ = write!(
187 mesh_json,
188 r#"{{"primitives":[{{"attributes":{{"POSITION":{position_accessor},"NORMAL":{normal_accessor}}},"indices":{index_accessor}{material_field}}}]}}"#,
189 );
190 comma(&mut node_json);
191 let name_field = export.name.as_ref().map_or(String::new(), |n| {
192 format!(r#","name":"{}""#, escape_json(n))
193 });
194 let _ = write!(node_json, r#"{{"mesh":{mesh_count}{name_field}}}"#);
195 mesh_count += 1;
196 }
197
198 let node_indices: Vec<String> = (0..mesh_count).map(|i| i.to_string()).collect();
199 let json = format!(
200 r#"{{"asset":{{"version":"2.0","generator":"ogeom"}},"scene":0,"scenes":[{{"nodes":[{}]}}],"nodes":[{node_json}],"meshes":[{mesh_json}],"materials":[{material_json}],"accessors":[{accessors}],"bufferViews":[{buffer_views}],"buffers":[{{"byteLength":{}}}]}}"#,
201 node_indices.join(","),
202 binary.len(),
203 );
204 let json = json.replace(r#","materials":[],"#, ",");
206
207 let mut json_bytes = json.into_bytes();
210 while !json_bytes.len().is_multiple_of(4) {
211 json_bytes.push(b' ');
212 }
213 while !binary.len().is_multiple_of(4) {
214 binary.push(0);
215 }
216 let total = 12 + 8 + json_bytes.len() + 8 + binary.len();
217 let mut out = Vec::with_capacity(total);
218 out.extend_from_slice(&0x4654_6C67_u32.to_le_bytes()); out.extend_from_slice(&2_u32.to_le_bytes());
220 out.extend_from_slice(&u32::try_from(total).unwrap_or(u32::MAX).to_le_bytes());
221 out.extend_from_slice(
222 &u32::try_from(json_bytes.len())
223 .unwrap_or(u32::MAX)
224 .to_le_bytes(),
225 );
226 out.extend_from_slice(&0x4E4F_534A_u32.to_le_bytes()); out.extend_from_slice(&json_bytes);
228 out.extend_from_slice(
229 &u32::try_from(binary.len())
230 .unwrap_or(u32::MAX)
231 .to_le_bytes(),
232 );
233 out.extend_from_slice(&0x004E_4942_u32.to_le_bytes()); out.extend_from_slice(&binary);
235 out
236}
237
238fn escape_json(s: &str) -> String {
239 s.chars()
240 .flat_map(|c| match c {
241 '"' => vec!['\\', '"'],
242 '\\' => vec!['\\', '\\'],
243 c if c.is_control() => format!("\\u{:04x}", c as u32).chars().collect(),
244 c => vec![c],
245 })
246 .collect()
247}
248
249#[must_use]
258pub fn write_obj(meshes: &[ExportMesh<'_>]) -> String {
259 let mut out = String::from("# ogeom\n");
260 let mut base = 1_usize;
261 let mut normal_base = 1_usize;
262 for (i, export) in meshes.iter().enumerate() {
263 let mesh = export.mesh;
264 if mesh.triangles.is_empty() {
265 continue;
266 }
267 let name = export.name.clone().unwrap_or_else(|| format!("mesh-{i}"));
268 let _ = writeln!(out, "o {name}");
269 for p in &mesh.positions {
270 let _ = writeln!(out, "v {} {} {}", p.x, p.y, p.z);
271 }
272 let normals = has_normals(mesh);
273 if normals {
274 for n in &mesh.normals {
275 let _ = writeln!(out, "vn {} {} {}", n.x, n.y, n.z);
276 }
277 }
278 for t in &mesh.triangles {
279 let [a, b, c] = t.map(|k| k as usize + base);
280 if normals {
281 let [na, nb, nc] = t.map(|k| k as usize + normal_base);
282 let _ = writeln!(out, "f {a}//{na} {b}//{nb} {c}//{nc}");
283 } else {
284 let _ = writeln!(out, "f {a} {b} {c}");
285 }
286 }
287 base += mesh.positions.len();
288 if normals {
289 normal_base += mesh.normals.len();
290 }
291 }
292 out
293}
294
295fn has_normals(mesh: &Triangulation) -> bool {
298 !mesh.normals.is_empty() && mesh.normals.len() == mesh.positions.len()
299}
300
301#[must_use]
309pub fn write_ply(export: &ExportMesh<'_>) -> String {
310 let mesh = export.mesh;
311 let colour = export.colour.map(|[r, g, b, _]| {
312 [
313 (r.clamp(0.0, 1.0) * 255.0).round(),
314 (g.clamp(0.0, 1.0) * 255.0).round(),
315 (b.clamp(0.0, 1.0) * 255.0).round(),
316 ]
317 });
318 let mut out = String::from("ply\nformat ascii 1.0\ncomment ogeom\n");
319 let _ = writeln!(out, "element vertex {}", mesh.positions.len());
320 let normals = has_normals(mesh);
321 out.push_str("property float x\nproperty float y\nproperty float z\n");
322 if normals {
323 out.push_str("property float nx\nproperty float ny\nproperty float nz\n");
324 }
325 if colour.is_some() {
326 out.push_str("property uchar red\nproperty uchar green\nproperty uchar blue\n");
327 }
328 let _ = writeln!(out, "element face {}", mesh.triangles.len());
329 out.push_str("property list uchar uint vertex_indices\nend_header\n");
330 for (k, p) in mesh.positions.iter().enumerate() {
331 let _ = write!(out, "{} {} {}", p.x, p.y, p.z);
332 if normals {
333 let n = mesh.normals[k];
334 let _ = write!(out, " {} {} {}", n.x, n.y, n.z);
335 }
336 if let Some([r, g, b]) = colour {
337 let _ = write!(out, " {r} {g} {b}");
338 }
339 out.push('\n');
340 }
341 for t in &mesh.triangles {
342 let _ = writeln!(out, "3 {} {} {}", t[0], t[1], t[2]);
343 }
344 out
345}
346
347pub use crate::obj::{ObjImport, read_obj, read_obj_file, read_obj_with_materials};
350pub use crate::ply::{PlyImport, read_ply_bytes, write_ply_binary};
351
352#[derive(Debug, Clone, PartialEq)]
360pub enum MeshColours {
361 PerVertex(Vec<[f64; 4]>),
363 PerTriangle(Vec<[f64; 4]>),
365 PerCorner(Vec<[[f64; 4]; 3]>),
367}
368
369impl MeshColours {
370 #[must_use]
373 pub fn at(&self, mesh: &Triangulation, triangle: usize, corner: usize) -> Option<[f64; 4]> {
374 match self {
375 Self::PerVertex(colours) => {
376 let vertex = *mesh.triangles.get(triangle)?.get(corner)?;
377 colours.get(vertex as usize).copied()
378 }
379 Self::PerTriangle(colours) => (corner < 3).then(|| colours.get(triangle).copied())?,
380 Self::PerCorner(colours) => colours.get(triangle)?.get(corner).copied(),
381 }
382 }
383
384 pub(crate) fn from_corners(
387 triangles: &[[u32; 3]],
388 vertex_count: usize,
389 corners: Vec<[[f64; 4]; 3]>,
390 ) -> Self {
391 let mut per_vertex: Vec<Option<[f64; 4]>> = vec![None; vertex_count];
392 let mut agree = true;
393 'corners: for (triangle, colours) in triangles.iter().zip(&corners) {
394 for (&v, colour) in triangle.iter().zip(colours) {
395 match per_vertex.get_mut(v as usize) {
396 Some(slot @ None) => *slot = Some(*colour),
397 Some(Some(held)) if held == colour => {}
398 _ => {
399 agree = false;
400 break 'corners;
401 }
402 }
403 }
404 }
405 if agree && let Some(vertex) = per_vertex.into_iter().collect::<Option<Vec<_>>>() {
406 return Self::PerVertex(vertex);
407 }
408 if corners.iter().all(|[a, b, c]| a == b && b == c) {
409 return Self::PerTriangle(corners.into_iter().map(|[a, _, _]| a).collect());
410 }
411 Self::PerCorner(corners)
412 }
413}
414
415pub(crate) fn held_is_unset(slot: &ogeom_math::Vector) -> bool {
422 !(0.999..=1.001).contains(&slot.magnitude())
423}
424
425pub fn read_ply(text: &str) -> ogeom_core::OgeomResult<Triangulation> {
435 Ok(read_ply_bytes(text.as_bytes())?.mesh())
436}
437
438#[must_use]
448pub fn write_vrml(meshes: &[ExportMesh<'_>]) -> String {
449 let mut out = String::from("#VRML V2.0 utf8\n\n");
450 for export in meshes {
451 if export.mesh.triangles.is_empty() {
452 continue;
453 }
454 if let Some(name) = &export.name {
455 let _ = writeln!(out, "# {name}");
456 }
457 out.push_str("Shape {\n");
458 if let Some([r, g, b, a]) = export.colour {
459 out.push_str(" appearance Appearance {\n material Material {\n");
460 let _ = writeln!(out, " diffuseColor {r} {g} {b}");
461 if a < 1.0 {
462 let _ = writeln!(out, " transparency {}", 1.0 - a);
463 }
464 out.push_str(" }\n }\n");
465 }
466 out.push_str(" geometry IndexedFaceSet {\n coord Coordinate {\n point [\n");
467 for p in &export.mesh.positions {
468 let _ = writeln!(out, " {} {} {},", p.x, p.y, p.z);
469 }
470 out.push_str(" ]\n }\n coordIndex [\n");
471 for [a, b, c] in &export.mesh.triangles {
472 let _ = writeln!(out, " {a} {b} {c} -1,");
473 }
474 out.push_str(" ]\n solid TRUE\n }\n}\n\n");
475 }
476 out
477}
478
479#[cfg(test)]
480#[allow(clippy::unwrap_used, clippy::expect_used)]
481mod tests {
482 use super::*;
483 use ogeom_core::Tolerances;
484 use ogeom_math::Frame;
485 use ogeom_topo::Model;
486
487 const T: Tolerances = Tolerances::millimetres();
488
489 fn box_mesh() -> Triangulation {
490 let mut model = Model::new();
491 let solid = ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
492 .unwrap()
493 .shape;
494 ogeom_mesh::triangulate(&model, &solid, ogeom_mesh::Deflection::default(), T).unwrap()
495 }
496
497 fn bare_tetrahedron() -> Triangulation {
500 Triangulation {
501 positions: vec![
502 Point::new(0.0, 0.0, 0.0),
503 Point::new(1.0, 0.0, 0.0),
504 Point::new(0.0, 1.0, 0.0),
505 Point::new(0.0, 0.0, 1.0),
506 ],
507 normals: Vec::new(),
508 parameters: Vec::new(),
509 triangles: vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]],
510 deflection_met: true,
511 }
512 }
513
514 #[test]
515 fn a_mesh_without_normals_writes_every_vertex_to_ply_and_reads_back() {
516 let tri = bare_tetrahedron();
517 let text = write_ply(&ExportMesh::plain(&tri));
518 assert!(!text.contains("property float nx"));
519 let back = read_ply_bytes(text.as_bytes()).unwrap().mesh();
520 assert_eq!(back.positions, tri.positions);
521 assert_eq!(back.triangles, tri.triangles);
522 }
523
524 #[test]
525 fn a_mesh_without_normals_writes_plain_obj_faces_and_reads_back() {
526 let tri = bare_tetrahedron();
527 let text = write_obj(&[ExportMesh::plain(&tri)]);
528 assert!(!text.contains("vn ") && !text.contains("//"));
529 let back = read_obj(&text).unwrap();
530 assert_eq!(back.positions, tri.positions);
531 assert_eq!(back.triangles, tri.triangles);
532 }
533
534 #[test]
535 fn obj_faces_index_the_normals_of_their_own_mesh_after_a_bare_one() {
536 let bare = bare_tetrahedron();
537 let boxed = box_mesh();
538 let text = write_obj(&[ExportMesh::plain(&bare), ExportMesh::plain(&boxed)]);
539 let first_box_face = text
542 .lines()
543 .filter(|l| l.starts_with("f "))
544 .nth(bare.triangles.len())
545 .unwrap();
546 let a = boxed.triangles[0][0] as usize;
547 assert!(first_box_face.starts_with(&format!("f {}//{} ", a + 5, a + 1)));
548 assert_eq!(
549 text.lines().filter(|l| l.starts_with("vn ")).count(),
550 boxed.normals.len()
551 );
552 }
553
554 #[test]
555 fn a_glb_frames_its_chunks_and_counts_its_geometry() {
556 let mesh = box_mesh();
557 let glb = write_glb(&[ExportMesh {
558 mesh: &mesh,
559 colour: Some([0.8, 0.2, 0.1, 1.0]),
560 name: Some("box".into()),
561 }]);
562 assert_eq!(&glb[0..4], b"glTF");
564 assert_eq!(u32::from_le_bytes(glb[4..8].try_into().unwrap()), 2);
565 assert_eq!(
566 u32::from_le_bytes(glb[8..12].try_into().unwrap()) as usize,
567 glb.len()
568 );
569 let json_length = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;
571 assert_eq!(&glb[16..20], b"JSON");
572 let json = core::str::from_utf8(&glb[20..20 + json_length]).unwrap();
573 assert!(json.contains(r#""version":"2.0""#));
574 assert!(json.contains(r#""POSITION":0"#));
575 assert!(json.contains(r#""indices":2"#));
576 assert!(json.contains("baseColorFactor"));
577 assert!(json.contains(r#""name":"box""#));
578 assert!(json.contains(r#""min":["#), "POSITION carries bounds");
579 let expected =
582 mesh.positions.len() * 12 + mesh.normals.len() * 12 + mesh.triangles.len() * 12;
583 let padded = expected + (4 - expected % 4) % 4;
584 let binary_length =
585 u32::from_le_bytes(glb[20 + json_length..24 + json_length].try_into().unwrap());
586 assert_eq!(binary_length as usize, padded);
587 }
588
589 #[test]
590 fn an_obj_counts_its_records_and_shares_its_index_space() {
591 let mesh = box_mesh();
592 let one = ExportMesh::plain(&mesh);
593 let text = write_obj(&[one.clone(), one]);
594 assert_eq!(
595 text.lines().filter(|l| l.starts_with("v ")).count(),
596 mesh.positions.len() * 2
597 );
598 assert_eq!(
599 text.lines().filter(|l| l.starts_with("f ")).count(),
600 mesh.triangles.len() * 2
601 );
602 let last_face = text.lines().rev().find(|l| l.starts_with("f ")).unwrap();
604 let first_index: usize = last_face
605 .split_whitespace()
606 .nth(1)
607 .unwrap()
608 .split('/')
609 .next()
610 .unwrap()
611 .parse()
612 .unwrap();
613 assert!(first_index > mesh.positions.len());
614 }
615
616 #[test]
617 fn a_ply_declares_what_it_carries() {
618 let mesh = box_mesh();
619 let text = write_ply(&ExportMesh {
620 mesh: &mesh,
621 colour: Some([0.0, 0.5, 1.0, 1.0]),
622 name: None,
623 });
624 assert!(text.starts_with("ply\nformat ascii 1.0\n"));
625 assert!(text.contains(&format!("element vertex {}", mesh.positions.len())));
626 assert!(text.contains(&format!("element face {}", mesh.triangles.len())));
627 assert!(text.contains("property uchar red"));
628 let body_faces = text
629 .lines()
630 .skip_while(|l| *l != "end_header")
631 .filter(|l| l.starts_with("3 "))
632 .count();
633 assert_eq!(body_faces, mesh.triangles.len());
634 }
635}
636
637#[derive(Debug, Clone)]
645pub struct ImportedMesh {
646 pub mesh: Triangulation,
648 pub colour: Option<[f64; 4]>,
650 pub name: Option<String>,
652}
653
654pub fn read_glb(bytes: &[u8]) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
681 let (json, binary) = split_glb(bytes)?;
682 let document = crate::json::parse(&json)?;
683 read_gltf_document(&document, binary.as_deref())
684}
685
686pub fn read_gltf(text: &str) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
696 let document = crate::json::parse(text)?;
697 read_gltf_document(&document, None)
698}
699
700fn split_glb(bytes: &[u8]) -> ogeom_core::OgeomResult<(String, Option<Vec<u8>>)> {
702 let word = |at: usize| -> ogeom_core::OgeomResult<u32> {
703 let Some(slice) = bytes.get(at..at + 4) else {
704 ogeom_bail!(Construction, "the file ends inside its own header");
705 };
706 Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
707 };
708 if word(0)? != 0x4654_6C67 {
709 ogeom_bail!(
710 Construction,
711 "this is not a GLB: the first four bytes are not `glTF`"
712 );
713 }
714 let version = word(4)?;
715 if version != 2 {
716 ogeom_bail!(Construction, "GLB version {version} is not glTF 2.0");
717 }
718 let mut json = None;
719 let mut binary = None;
720 let mut at = 12;
721 while at + 8 <= bytes.len() {
722 let length = word(at)? as usize;
723 let kind = word(at + 4)?;
724 let start = at + 8;
725 let Some(chunk) = bytes.get(start..start + length) else {
726 ogeom_bail!(
727 Construction,
728 "a chunk claims {length} bytes it does not have"
729 );
730 };
731 match kind {
732 0x4E4F_534A => {
733 let Ok(text) = core::str::from_utf8(chunk) else {
734 ogeom_bail!(Construction, "the JSON chunk is not UTF-8");
735 };
736 json = Some(text.trim_end_matches(['\0', ' ']).to_owned());
737 }
738 0x004E_4942 => binary = Some(chunk.to_vec()),
739 _ => {}
741 }
742 at = start + length.next_multiple_of(4);
743 }
744 let Some(json) = json else {
745 ogeom_bail!(Construction, "the GLB carries no JSON chunk");
746 };
747 Ok((json, binary))
748}
749
750fn buffers(
752 document: &crate::json::Json,
753 binary: Option<&[u8]>,
754) -> ogeom_core::OgeomResult<Vec<Vec<u8>>> {
755 let mut out = Vec::new();
756 for buffer in document.get("buffers").map(|b| b.items()).unwrap_or(&[]) {
757 match buffer.get("uri").and_then(crate::json::Json::text) {
758 None => {
759 let Some(chunk) = binary else {
762 ogeom_bail!(
763 Construction,
764 "a buffer with no uri wants the GLB's binary chunk, and \
765 there is none"
766 );
767 };
768 out.push(chunk.to_vec());
769 }
770 Some(uri) if uri.starts_with("data:") => {
771 let Some((_, payload)) = uri.split_once("base64,") else {
772 ogeom_bail!(
773 Construction,
774 "a data uri that is not base64 is not something this reads"
775 );
776 };
777 out.push(from_base64(payload)?);
778 }
779 Some(uri) => ogeom_bail!(
780 Construction,
781 "the buffer points at `{uri}`, an external file this reader is \
782 not given; hand it a GLB or a document with data uris"
783 ),
784 }
785 }
786 Ok(out)
787}
788
789fn from_base64(text: &str) -> ogeom_core::OgeomResult<Vec<u8>> {
791 let value = |c: u8| -> Option<u32> {
792 match c {
793 b'A'..=b'Z' => Some(u32::from(c - b'A')),
794 b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
795 b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
796 b'+' => Some(62),
797 b'/' => Some(63),
798 _ => None,
799 }
800 };
801 let mut out = Vec::with_capacity(text.len() / 4 * 3);
802 let mut acc = 0_u32;
803 let mut held = 0_u32;
804 for c in text.bytes() {
805 if c == b'=' || c.is_ascii_whitespace() {
806 continue;
807 }
808 let Some(v) = value(c) else {
809 ogeom_bail!(Construction, "`{}` is not base64", c as char);
810 };
811 acc = (acc << 6) | v;
812 held += 6;
813 if held >= 8 {
814 held -= 8;
815 #[allow(clippy::cast_possible_truncation, reason = "masked to a byte")]
816 out.push(((acc >> held) & 0xFF) as u8);
817 }
818 }
819 Ok(out)
820}
821
822fn read_gltf_document(
824 document: &crate::json::Json,
825 binary: Option<&[u8]>,
826) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
827 use crate::json::Json;
828 let buffers = buffers(document, binary)?;
829 let views = document.get("bufferViews").map_or(&[][..], Json::items);
830 let accessors = document.get("accessors").map_or(&[][..], Json::items);
831 let meshes = document.get("meshes").map_or(&[][..], Json::items);
832 let nodes = document.get("nodes").map_or(&[][..], Json::items);
833 let materials = document.get("materials").map_or(&[][..], Json::items);
834
835 let scene = document
838 .index_at("scene")
839 .and_then(|i| document.get("scenes")?.items().get(i))
840 .and_then(|s| s.get("nodes"))
841 .map(Json::items);
842 let roots: Vec<usize> = match scene {
843 Some(list) => list.iter().filter_map(Json::index).collect(),
844 None => (0..nodes.len()).collect(),
845 };
846
847 let mut out = Vec::new();
848 let mut pending: Vec<(usize, Placement)> = roots
849 .into_iter()
850 .rev()
851 .map(|i| (i, Placement::IDENTITY))
852 .collect();
853 let mut seen = vec![false; nodes.len()];
854 while let Some((index, parent)) = pending.pop() {
855 let Some(node) = nodes.get(index) else {
856 ogeom_bail!(Construction, "node {index} is not in this document");
857 };
858 if seen.get(index).copied().unwrap_or(false) {
861 ogeom_bail!(Construction, "node {index} is its own descendant");
862 }
863 if let Some(flag) = seen.get_mut(index) {
864 *flag = true;
865 }
866 let here = parent.then(node_transform(node)?);
867 for child in node.get("children").map_or(&[][..], Json::items) {
868 let Some(child) = child.index() else {
869 ogeom_bail!(Construction, "a child index is not an index");
870 };
871 pending.push((child, here));
872 }
873 let Some(mesh_index) = node.index_at("mesh") else {
874 continue;
875 };
876 let Some(mesh) = meshes.get(mesh_index) else {
877 ogeom_bail!(Construction, "mesh {mesh_index} is not in this document");
878 };
879 let name = node
880 .get("name")
881 .and_then(Json::text)
882 .or_else(|| mesh.get("name").and_then(Json::text))
883 .map(str::to_owned);
884 for primitive in mesh.get("primitives").map_or(&[][..], Json::items) {
885 let built = read_primitive(primitive, accessors, views, &buffers, materials, here)?;
886 if let Some((mesh, colour)) = built {
887 out.push(ImportedMesh {
888 mesh,
889 colour,
890 name: name.clone(),
891 });
892 }
893 }
894 }
895 Ok(out)
896}
897
898#[derive(Debug, Clone, Copy)]
906pub(crate) struct Placement {
907 pub(crate) columns: [Vector; 3],
908 pub(crate) translation: Vector,
909}
910
911impl Placement {
912 pub(crate) const IDENTITY: Self = Self {
913 columns: [
914 Vector::new(1.0, 0.0, 0.0),
915 Vector::new(0.0, 1.0, 0.0),
916 Vector::new(0.0, 0.0, 1.0),
917 ],
918 translation: Vector::new(0.0, 0.0, 0.0),
919 };
920
921 pub(crate) fn then(self, inner: Self) -> Self {
923 let map = |v: Vector| self.columns[0] * v.x + self.columns[1] * v.y + self.columns[2] * v.z;
924 Self {
925 columns: inner.columns.map(map),
926 translation: map(inner.translation) + self.translation,
927 }
928 }
929
930 pub(crate) fn point(self, p: Point) -> Point {
931 Point::ORIGIN
932 + self.columns[0] * p.x
933 + self.columns[1] * p.y
934 + self.columns[2] * p.z
935 + self.translation
936 }
937
938 pub(crate) fn mirrors(self) -> bool {
941 let [a, b, c] = self.columns;
942 a.cross(b).dot(c) < 0.0
943 }
944
945 fn normal(self, n: Vector) -> Vector {
952 let [a, b, c] = self.columns;
953 let cofactors = [b.cross(c), c.cross(a), a.cross(b)];
954 let out = cofactors[0] * n.x + cofactors[1] * n.y + cofactors[2] * n.z;
955 let magnitude = out.magnitude();
956 if magnitude == 0.0 {
957 n
958 } else if self.mirrors() {
959 -out / magnitude
960 } else {
961 out / magnitude
962 }
963 }
964}
965
966fn node_transform(node: &crate::json::Json) -> ogeom_core::OgeomResult<Placement> {
968 use crate::json::Json;
969 let triple = |value: &Json, what: &str| -> ogeom_core::OgeomResult<[f64; 3]> {
970 let v: Vec<f64> = value.items().iter().filter_map(Json::number).collect();
971 let [x, y, z] = v[..] else {
972 ogeom_bail!(Construction, "a node {what} has three numbers");
973 };
974 Ok([x, y, z])
975 };
976 if let Some(matrix) = node.get("matrix") {
977 let values: Vec<f64> = matrix.items().iter().filter_map(Json::number).collect();
978 if values.len() != 16 {
979 ogeom_bail!(Construction, "a node matrix has sixteen numbers");
980 }
981 return Ok(Placement {
983 columns: [
984 Vector::new(values[0], values[1], values[2]),
985 Vector::new(values[4], values[5], values[6]),
986 Vector::new(values[8], values[9], values[10]),
987 ],
988 translation: Vector::new(values[12], values[13], values[14]),
989 });
990 }
991 let mut placement = Placement::IDENTITY;
993 if let Some(scale) = node.get("scale") {
994 let [x, y, z] = triple(scale, "scale")?;
995 placement = Placement {
996 columns: [
997 Vector::new(x, 0.0, 0.0),
998 Vector::new(0.0, y, 0.0),
999 Vector::new(0.0, 0.0, z),
1000 ],
1001 translation: Vector::new(0.0, 0.0, 0.0),
1002 }
1003 .then(placement);
1004 }
1005 if let Some(rotation) = node.get("rotation") {
1006 let q: Vec<f64> = rotation.items().iter().filter_map(Json::number).collect();
1007 let [x, y, z, w] = q[..] else {
1008 ogeom_bail!(Construction, "a node rotation is a quaternion of four");
1009 };
1010 placement = quaternion_placement(x, y, z, w).then(placement);
1011 }
1012 if let Some(translation) = node.get("translation") {
1013 let [x, y, z] = triple(translation, "translation")?;
1014 placement = Placement {
1015 translation: Vector::new(x, y, z),
1016 ..Placement::IDENTITY
1017 }
1018 .then(placement);
1019 }
1020 Ok(placement)
1021}
1022
1023fn quaternion_placement(x: f64, y: f64, z: f64, w: f64) -> Placement {
1025 let n = x.mul_add(x, y.mul_add(y, z.mul_add(z, w * w))).sqrt();
1026 let (x, y, z, w) = if n > 0.0 {
1027 (x / n, y / n, z / n, w / n)
1028 } else {
1029 (0.0, 0.0, 0.0, 1.0)
1030 };
1031 Placement {
1032 columns: [
1033 Vector::new(
1034 2.0f64.mul_add(-y.mul_add(y, z * z), 1.0),
1035 2.0 * x.mul_add(y, z * w),
1036 2.0 * x.mul_add(z, -(y * w)),
1037 ),
1038 Vector::new(
1039 2.0 * x.mul_add(y, -(z * w)),
1040 2.0f64.mul_add(-x.mul_add(x, z * z), 1.0),
1041 2.0 * y.mul_add(z, x * w),
1042 ),
1043 Vector::new(
1044 2.0 * x.mul_add(z, y * w),
1045 2.0 * y.mul_add(z, -(x * w)),
1046 2.0f64.mul_add(-x.mul_add(x, y * y), 1.0),
1047 ),
1048 ],
1049 translation: Vector::new(0.0, 0.0, 0.0),
1050 }
1051}
1052
1053fn read_primitive(
1059 primitive: &crate::json::Json,
1060 accessors: &[crate::json::Json],
1061 views: &[crate::json::Json],
1062 buffers: &[Vec<u8>],
1063 materials: &[crate::json::Json],
1064 placement: Placement,
1065) -> ogeom_core::OgeomResult<Option<(Triangulation, Option<[f64; 4]>)>> {
1066 use crate::json::Json;
1067 if primitive
1068 .get("extensions")
1069 .and_then(|e| e.get("KHR_draco_mesh_compression"))
1070 .is_some()
1071 {
1072 ogeom_bail!(
1073 Construction,
1074 "this primitive's geometry is Draco-compressed, which is a codec \
1075 this reader does not carry"
1076 );
1077 }
1078 let mode = primitive.index_at("mode").unwrap_or(4);
1081 if mode != 4 {
1082 ogeom_bail!(
1083 Construction,
1084 "primitive mode {mode} is not triangles; only triangle meshes read \
1085 back as a triangulation"
1086 );
1087 }
1088 let Some(attributes) = primitive.get("attributes") else {
1089 return Ok(None);
1090 };
1091 let Some(position_index) = attributes.index_at("POSITION") else {
1092 return Ok(None);
1093 };
1094 let positions_raw = read_accessor(position_index, accessors, views, buffers)?;
1095 if positions_raw.components != 3 {
1096 ogeom_bail!(Construction, "POSITION is a VEC3");
1097 }
1098 let positions: Vec<Point> = positions_raw
1099 .values
1100 .as_chunks::<3>()
1101 .0
1102 .iter()
1103 .map(|v| placement.point(Point::new(v[0], v[1], v[2])))
1104 .collect();
1105
1106 let normals: Vec<Vector> = match attributes.index_at("NORMAL") {
1107 None => Vec::new(),
1108 Some(index) => {
1109 let raw = read_accessor(index, accessors, views, buffers)?;
1110 if raw.components != 3 {
1111 ogeom_bail!(Construction, "NORMAL is a VEC3");
1112 }
1113 if raw.count != positions.len() {
1114 ogeom_bail!(
1115 Construction,
1116 "the primitive has {} positions and {} normals",
1117 positions.len(),
1118 raw.count
1119 );
1120 }
1121 raw.values
1122 .as_chunks::<3>()
1123 .0
1124 .iter()
1125 .map(|v| placement.normal(Vector::new(v[0], v[1], v[2])))
1126 .collect()
1127 }
1128 };
1129
1130 let indices: Vec<u32> = match primitive.index_at("indices") {
1131 None => (0..u32::try_from(positions.len()).unwrap_or(u32::MAX)).collect(),
1134 Some(index) => {
1135 let raw = read_accessor(index, accessors, views, buffers)?;
1136 if raw.components != 1 {
1137 ogeom_bail!(Construction, "an index accessor is a SCALAR");
1138 }
1139 let mut out = Vec::with_capacity(raw.values.len());
1140 for v in raw.values {
1141 #[expect(
1142 clippy::cast_possible_truncation,
1143 clippy::cast_sign_loss,
1144 reason = "range-checked against the vertex count below"
1145 )]
1146 let k = v as u32;
1147 if f64::from(k) != v || (k as usize) >= positions.len() {
1148 ogeom_bail!(
1149 Construction,
1150 "index {v} names no vertex among {}",
1151 positions.len()
1152 );
1153 }
1154 out.push(k);
1155 }
1156 out
1157 }
1158 };
1159 let mirrored = placement.mirrors();
1161 let triangles: Vec<[u32; 3]> = indices
1162 .as_chunks::<3>()
1163 .0
1164 .iter()
1165 .map(|c| {
1166 if mirrored {
1167 [c[0], c[2], c[1]]
1168 } else {
1169 [c[0], c[1], c[2]]
1170 }
1171 })
1172 .collect();
1173 if triangles.is_empty() {
1174 return Ok(None);
1175 }
1176
1177 let colour = primitive
1178 .index_at("material")
1179 .and_then(|i| materials.get(i))
1180 .and_then(|m| m.get("pbrMetallicRoughness"))
1181 .and_then(|p| p.get("baseColorFactor"))
1182 .and_then(|f| {
1183 let v: Vec<f64> = f.items().iter().filter_map(Json::number).collect();
1184 <[f64; 4]>::try_from(v).ok()
1185 });
1186
1187 let normals = if normals.is_empty() {
1191 normals_from_triangles(&positions, &triangles)
1192 } else {
1193 normals
1194 };
1195 Ok(Some((
1196 Triangulation {
1197 parameters: vec![(0.0, 0.0); positions.len()],
1198 positions,
1199 normals,
1200 triangles,
1201 deflection_met: false,
1204 },
1205 colour,
1206 )))
1207}
1208
1209pub(crate) fn normals_from_triangles(positions: &[Point], triangles: &[[u32; 3]]) -> Vec<Vector> {
1211 let mut out = vec![Vector::new(0.0, 0.0, 0.0); positions.len()];
1212 for [a, b, c] in triangles {
1213 let (pa, pb, pc) = (
1214 positions[*a as usize],
1215 positions[*b as usize],
1216 positions[*c as usize],
1217 );
1218 let n = (pb - pa).cross(pc - pa);
1221 for &k in &[*a, *b, *c] {
1222 out[k as usize] += n;
1223 }
1224 }
1225 for n in &mut out {
1226 let magnitude = n.magnitude();
1227 *n = if magnitude > 0.0 {
1228 *n / magnitude
1229 } else {
1230 Vector::new(0.0, 0.0, 1.0)
1231 };
1232 }
1233 out
1234}
1235
1236struct AccessorValues {
1238 values: Vec<f64>,
1240 components: usize,
1242 count: usize,
1244}
1245
1246fn read_accessor(
1252 index: usize,
1253 accessors: &[crate::json::Json],
1254 views: &[crate::json::Json],
1255 buffers: &[Vec<u8>],
1256) -> ogeom_core::OgeomResult<AccessorValues> {
1257 let Some(accessor) = accessors.get(index) else {
1258 ogeom_bail!(Construction, "accessor {index} is not in this document");
1259 };
1260 let Some(kind) = accessor.get("type").and_then(crate::json::Json::text) else {
1261 ogeom_bail!(Construction, "accessor {index} states no type");
1262 };
1263 let components = match kind {
1264 "SCALAR" => 1,
1265 "VEC2" => 2,
1266 "VEC3" => 3,
1267 "VEC4" | "MAT2" => 4,
1268 "MAT3" => 9,
1269 "MAT4" => 16,
1270 other => ogeom_bail!(Construction, "`{other}` is not an accessor type"),
1271 };
1272 let Some(component_type) = accessor.index_at("componentType") else {
1273 ogeom_bail!(Construction, "accessor {index} states no componentType");
1274 };
1275 let normalized = accessor.get("normalized") == Some(&crate::json::Json::Bool(true));
1276 let count = accessor.index_at("count").unwrap_or(0);
1277 let held: usize = buffers.iter().map(Vec::len).sum();
1282 let Some(numbers) = count
1283 .checked_mul(components)
1284 .filter(|n| *n <= held.max(1 << 16))
1285 else {
1286 ogeom_bail!(
1287 Construction,
1288 "accessor {index} asks for {count} elements of {components} from {held} bytes"
1289 );
1290 };
1291 let mut values = vec![0.0; numbers];
1292
1293 if let Some(view_index) = accessor.index_at("bufferView") {
1294 let offset = accessor.index_at("byteOffset").unwrap_or(0);
1295 read_into(
1296 &mut values,
1297 view_index,
1298 offset,
1299 component_type,
1300 components,
1301 count,
1302 normalized,
1303 views,
1304 buffers,
1305 )?;
1306 }
1307
1308 if let Some(sparse) = accessor.get("sparse") {
1312 let sparse_count = sparse.index_at("count").unwrap_or(0).min(count);
1313 let Some(indices) = sparse.get("indices") else {
1314 ogeom_bail!(Construction, "a sparse accessor names its indices");
1315 };
1316 let Some(sparse_values) = sparse.get("values") else {
1317 ogeom_bail!(Construction, "a sparse accessor names its values");
1318 };
1319 let Some(index_type) = indices.index_at("componentType") else {
1320 ogeom_bail!(Construction, "a sparse index has a componentType");
1321 };
1322 let Some(index_view) = indices.index_at("bufferView") else {
1323 ogeom_bail!(Construction, "a sparse index has a bufferView");
1324 };
1325 let mut which = vec![0.0; sparse_count];
1326 read_into(
1327 &mut which,
1328 index_view,
1329 indices.index_at("byteOffset").unwrap_or(0),
1330 index_type,
1331 1,
1332 sparse_count,
1333 false,
1334 views,
1335 buffers,
1336 )?;
1337 let Some(value_view) = sparse_values.index_at("bufferView") else {
1338 ogeom_bail!(Construction, "a sparse value block has a bufferView");
1339 };
1340 let mut replacement = vec![0.0; sparse_count * components];
1341 read_into(
1342 &mut replacement,
1343 value_view,
1344 sparse_values.index_at("byteOffset").unwrap_or(0),
1345 component_type,
1346 components,
1347 sparse_count,
1348 normalized,
1349 views,
1350 buffers,
1351 )?;
1352 for (slot, target) in which.iter().enumerate() {
1353 #[expect(
1354 clippy::cast_possible_truncation,
1355 clippy::cast_sign_loss,
1356 reason = "range-checked against the element count below"
1357 )]
1358 let at = *target as usize;
1359 if f64::from(u32::try_from(at).unwrap_or(u32::MAX)) != *target || at >= count {
1360 ogeom_bail!(
1361 Construction,
1362 "a sparse index names element {target} of {count}"
1363 );
1364 }
1365 for k in 0..components {
1366 values[at * components + k] = replacement[slot * components + k];
1367 }
1368 }
1369 }
1370
1371 Ok(AccessorValues {
1372 values,
1373 components,
1374 count,
1375 })
1376}
1377
1378#[allow(clippy::too_many_arguments)]
1380fn read_into(
1381 out: &mut [f64],
1382 view_index: usize,
1383 accessor_offset: usize,
1384 component_type: usize,
1385 components: usize,
1386 count: usize,
1387 normalized: bool,
1388 views: &[crate::json::Json],
1389 buffers: &[Vec<u8>],
1390) -> ogeom_core::OgeomResult<()> {
1391 let Some(view) = views.get(view_index) else {
1392 ogeom_bail!(
1393 Construction,
1394 "buffer view {view_index} is not in this document"
1395 );
1396 };
1397 let buffer_index = view.index_at("buffer").unwrap_or(0);
1398 let Some(buffer) = buffers.get(buffer_index) else {
1399 ogeom_bail!(
1400 Construction,
1401 "buffer {buffer_index} is not in this document"
1402 );
1403 };
1404 let view_offset = view.index_at("byteOffset").unwrap_or(0);
1405 let width = match component_type {
1406 5120 | 5121 => 1,
1407 5122 | 5123 => 2,
1408 5125 | 5126 => 4,
1409 other => ogeom_bail!(Construction, "{other} is not a glTF component type"),
1410 };
1411 let element = components * width;
1414 let stride = view.index_at("byteStride").unwrap_or(element);
1415 if stride < element {
1416 ogeom_bail!(
1417 Construction,
1418 "a stride of {stride} is shorter than the {element} bytes an \
1419 element occupies"
1420 );
1421 }
1422 for i in 0..count {
1423 let base = view_offset + accessor_offset + i * stride;
1424 for k in 0..components {
1425 let at = base + k * width;
1426 let Some(slice) = buffer.get(at..at + width) else {
1427 ogeom_bail!(
1428 Construction,
1429 "the buffer ends at {} where the accessor wants byte {at}",
1430 buffer.len()
1431 );
1432 };
1433 out[i * components + k] = component_value(component_type, slice, normalized);
1434 }
1435 }
1436 Ok(())
1437}
1438
1439fn component_value(component_type: usize, bytes: &[u8], normalized: bool) -> f64 {
1445 match component_type {
1446 5120 => {
1447 let v = f64::from(bytes[0] as i8);
1448 if normalized { (v / 127.0).max(-1.0) } else { v }
1449 }
1450 5121 => {
1451 let v = f64::from(bytes[0]);
1452 if normalized { v / 255.0 } else { v }
1453 }
1454 5122 => {
1455 let v = f64::from(i16::from_le_bytes([bytes[0], bytes[1]]));
1456 if normalized {
1457 (v / 32767.0).max(-1.0)
1458 } else {
1459 v
1460 }
1461 }
1462 5123 => {
1463 let v = f64::from(u16::from_le_bytes([bytes[0], bytes[1]]));
1464 if normalized { v / 65535.0 } else { v }
1465 }
1466 5125 => f64::from(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])),
1467 _ => f64::from(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])),
1468 }
1469}