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]
256pub fn write_obj(meshes: &[ExportMesh<'_>]) -> String {
257 let mut out = String::from("# ogeom\n");
258 let mut base = 1_usize;
259 for (i, export) in meshes.iter().enumerate() {
260 let mesh = export.mesh;
261 if mesh.triangles.is_empty() {
262 continue;
263 }
264 let name = export.name.clone().unwrap_or_else(|| format!("mesh-{i}"));
265 let _ = writeln!(out, "o {name}");
266 for p in &mesh.positions {
267 let _ = writeln!(out, "v {} {} {}", p.x, p.y, p.z);
268 }
269 for n in &mesh.normals {
270 let _ = writeln!(out, "vn {} {} {}", n.x, n.y, n.z);
271 }
272 for t in &mesh.triangles {
273 let [a, b, c] = t.map(|k| k as usize + base);
274 let _ = writeln!(out, "f {a}//{a} {b}//{b} {c}//{c}");
275 }
276 base += mesh.positions.len();
277 }
278 out
279}
280
281#[must_use]
288pub fn write_ply(export: &ExportMesh<'_>) -> String {
289 let mesh = export.mesh;
290 let colour = export.colour.map(|[r, g, b, _]| {
291 [
292 (r.clamp(0.0, 1.0) * 255.0).round(),
293 (g.clamp(0.0, 1.0) * 255.0).round(),
294 (b.clamp(0.0, 1.0) * 255.0).round(),
295 ]
296 });
297 let mut out = String::from("ply\nformat ascii 1.0\ncomment ogeom\n");
298 let _ = writeln!(out, "element vertex {}", mesh.positions.len());
299 out.push_str(
300 "property float x\nproperty float y\nproperty float z\n\
301 property float nx\nproperty float ny\nproperty float nz\n",
302 );
303 if colour.is_some() {
304 out.push_str("property uchar red\nproperty uchar green\nproperty uchar blue\n");
305 }
306 let _ = writeln!(out, "element face {}", mesh.triangles.len());
307 out.push_str("property list uchar uint vertex_indices\nend_header\n");
308 for (p, n) in mesh.positions.iter().zip(&mesh.normals) {
309 let _ = write!(out, "{} {} {} {} {} {}", p.x, p.y, p.z, n.x, n.y, n.z);
310 if let Some([r, g, b]) = colour {
311 let _ = write!(out, " {r} {g} {b}");
312 }
313 out.push('\n');
314 }
315 for t in &mesh.triangles {
316 let _ = writeln!(out, "3 {} {} {}", t[0], t[1], t[2]);
317 }
318 out
319}
320
321pub fn read_obj(text: &str) -> ogeom_core::OgeomResult<Triangulation> {
339 let mut positions: Vec<ogeom_math::Point> = Vec::new();
340 let mut normals: Vec<ogeom_math::Vector> = Vec::new();
341 let mut triangles: Vec<[u32; 3]> = Vec::new();
342 let mut normal_of: Vec<Option<usize>> = Vec::new();
343
344 for line in text.lines() {
345 let line = line.split('#').next().unwrap_or("").trim();
346 let mut fields = line.split_whitespace();
347 match fields.next() {
348 Some("v") => {
349 let coords = triple(&mut fields, "a vertex")?;
350 positions.push(ogeom_math::Point::new(coords[0], coords[1], coords[2]));
351 normal_of.push(None);
352 }
353 Some("vn") => {
354 let coords = triple(&mut fields, "a normal")?;
355 normals.push(ogeom_math::Vector::new(coords[0], coords[1], coords[2]));
356 }
357 Some("f") => {
358 let mut corners: Vec<(usize, Option<usize>)> = Vec::new();
359 for field in fields {
360 corners.push(corner(field, positions.len(), normals.len())?);
361 }
362 if corners.len() < 3 {
363 ogeom_core::ogeom_bail!(
364 Construction,
365 "a face of {} corners is not a face",
366 corners.len()
367 );
368 }
369 for i in 1..corners.len() - 1 {
370 let fan = [corners[0], corners[i], corners[i + 1]];
371 let mut indices = [0_u32; 3];
372 for (k, (vertex, normal)) in fan.iter().enumerate() {
373 if let Some(n) = normal {
374 normal_of[*vertex] = Some(*n);
375 }
376 indices[k] = u32::try_from(*vertex).unwrap_or(u32::MAX);
377 }
378 triangles.push(indices);
379 }
380 }
381 _ => {}
382 }
383 }
384 if positions.is_empty() {
385 ogeom_core::ogeom_bail!(Construction, "the file carries no vertices");
386 }
387 let mut resolved = vec![ogeom_math::Vector::ZERO; positions.len()];
390 for (i, held) in normal_of.iter().enumerate() {
391 if let Some(n) = held.and_then(|n| normals.get(n)) {
392 resolved[i] = *n;
393 }
394 }
395 for triangle in &triangles {
396 let [a, b, c] = triangle.map(|i| positions[i as usize]);
397 let face = (b - a).cross(c - a);
398 for index in triangle {
399 let slot = &mut resolved[*index as usize];
400 if held_is_unset(slot) {
401 *slot += face;
402 }
403 }
404 }
405 let normals = resolved
406 .into_iter()
407 .map(|n| {
408 let m = n.magnitude();
409 if m > 0.0 {
410 n / m
411 } else {
412 ogeom_math::Vector::Z
413 }
414 })
415 .collect::<Vec<_>>();
416 let parameters = vec![(0.0, 0.0); positions.len()];
417 Ok(Triangulation {
418 positions,
419 normals,
420 parameters,
421 triangles,
422 deflection_met: true,
423 })
424}
425
426fn held_is_unset(slot: &ogeom_math::Vector) -> bool {
433 !(0.999..=1.001).contains(&slot.magnitude())
434}
435
436fn triple<'a>(
438 fields: &mut impl Iterator<Item = &'a str>,
439 what: &str,
440) -> ogeom_core::OgeomResult<[f64; 3]> {
441 let mut out = [0.0; 3];
442 for slot in &mut out {
443 let Some(field) = fields.next() else {
444 ogeom_core::ogeom_bail!(Construction, "{what} needs three coordinates");
445 };
446 let Ok(value) = field.parse::<f64>() else {
447 ogeom_core::ogeom_bail!(
448 Construction,
449 "{what} carries {field}, which is not a number"
450 );
451 };
452 *slot = value;
453 }
454 Ok(out)
455}
456
457fn corner(
460 field: &str,
461 vertices: usize,
462 normals: usize,
463) -> ogeom_core::OgeomResult<(usize, Option<usize>)> {
464 let mut parts = field.split('/');
465 let resolve = |text: &str, count: usize| -> Option<usize> {
466 let index = text.parse::<isize>().ok()?;
467 let zero = if index > 0 {
468 usize::try_from(index).ok()?.checked_sub(1)?
469 } else if index < 0 {
470 count.checked_sub(usize::try_from(-index).ok()?)?
471 } else {
472 return None;
473 };
474 (zero < count).then_some(zero)
475 };
476 let Some(vertex) = parts.next().and_then(|t| resolve(t, vertices)) else {
477 ogeom_core::ogeom_bail!(
478 Construction,
479 "a face names vertex {field}, which is not there"
480 );
481 };
482 let _texture = parts.next();
483 let normal = parts.next().and_then(|t| resolve(t, normals));
484 Ok((vertex, normal))
485}
486
487pub fn read_ply(text: &str) -> ogeom_core::OgeomResult<Triangulation> {
500 let mut lines = text.lines();
501 if lines.next().map(str::trim) != Some("ply") {
502 ogeom_core::ogeom_bail!(Construction, "this is not a PLY file");
503 }
504 let mut counts: Vec<(String, usize, Vec<String>)> = Vec::new();
506 for line in lines.by_ref() {
507 let line = line.trim();
508 let mut fields = line.split_whitespace();
509 match fields.next() {
510 Some("format") => {
511 let kind = fields.next().unwrap_or("");
512 if kind != "ascii" {
513 ogeom_core::ogeom_bail!(
514 Construction,
515 "this PLY is {kind}; the text dialect is what is read here"
516 );
517 }
518 }
519 Some("element") => {
520 let name = fields.next().unwrap_or("").to_string();
521 let count = fields
522 .next()
523 .and_then(|t| t.parse::<usize>().ok())
524 .unwrap_or(0);
525 counts.push((name, count, Vec::new()));
526 }
527 Some("property") => {
528 if let Some((_, _, properties)) = counts.last_mut() {
529 let last = line.split_whitespace().last().unwrap_or("");
530 properties.push(last.to_string());
531 }
532 }
533 Some("end_header") => break,
534 _ => {}
535 }
536 }
537
538 let mut positions: Vec<ogeom_math::Point> = Vec::new();
539 let mut normals: Vec<ogeom_math::Vector> = Vec::new();
540 let mut triangles: Vec<[u32; 3]> = Vec::new();
541 let mut body = lines.filter(|l| !l.trim().is_empty());
542 for (name, count, properties) in &counts {
543 for _ in 0..*count {
544 let Some(line) = body.next() else {
545 ogeom_core::ogeom_bail!(
546 Construction,
547 "the header promised {count} of {name} and the body ran out"
548 );
549 };
550 let fields: Vec<&str> = line.split_whitespace().collect();
551 if name == "vertex" {
552 let read = |what: &str| -> Option<f64> {
553 let at = properties.iter().position(|p| p == what)?;
554 fields.get(at)?.parse::<f64>().ok()
555 };
556 let (Some(x), Some(y), Some(z)) = (read("x"), read("y"), read("z")) else {
557 ogeom_core::ogeom_bail!(Construction, "a vertex is missing a coordinate");
558 };
559 positions.push(ogeom_math::Point::new(x, y, z));
560 normals.push(match (read("nx"), read("ny"), read("nz")) {
561 (Some(a), Some(b), Some(c)) => ogeom_math::Vector::new(a, b, c),
562 _ => ogeom_math::Vector::ZERO,
563 });
564 } else if name == "face" {
565 let Some(count) = fields.first().and_then(|t| t.parse::<usize>().ok()) else {
566 ogeom_core::ogeom_bail!(Construction, "a face does not say how many corners");
567 };
568 let corners: Vec<u32> = fields
569 .iter()
570 .skip(1)
571 .take(count)
572 .filter_map(|t| t.parse::<u32>().ok())
573 .collect();
574 if corners.len() != count {
575 ogeom_core::ogeom_bail!(
576 Construction,
577 "a face of {count} corners lists {} of them",
578 corners.len()
579 );
580 }
581 for i in 1..corners.len().saturating_sub(1) {
582 triangles.push([corners[0], corners[i], corners[i + 1]]);
583 }
584 }
585 }
586 }
587 if positions.is_empty() {
588 ogeom_core::ogeom_bail!(Construction, "the file carries no vertices");
589 }
590 let mut resolved = normals;
592 resolved.resize(positions.len(), ogeom_math::Vector::ZERO);
593 for triangle in &triangles {
594 let [a, b, c] = triangle.map(|i| positions[i as usize]);
595 let face = (b - a).cross(c - a);
596 for index in triangle {
597 let slot = &mut resolved[*index as usize];
598 if held_is_unset(slot) {
599 *slot += face;
600 }
601 }
602 }
603 let normals = resolved
604 .into_iter()
605 .map(|n| {
606 let m = n.magnitude();
607 if m > 0.0 {
608 n / m
609 } else {
610 ogeom_math::Vector::Z
611 }
612 })
613 .collect::<Vec<_>>();
614 let parameters = vec![(0.0, 0.0); positions.len()];
615 Ok(Triangulation {
616 positions,
617 normals,
618 parameters,
619 triangles,
620 deflection_met: true,
621 })
622}
623
624#[must_use]
634pub fn write_vrml(meshes: &[ExportMesh<'_>]) -> String {
635 let mut out = String::from("#VRML V2.0 utf8\n\n");
636 for export in meshes {
637 if export.mesh.triangles.is_empty() {
638 continue;
639 }
640 if let Some(name) = &export.name {
641 let _ = writeln!(out, "# {name}");
642 }
643 out.push_str("Shape {\n");
644 if let Some([r, g, b, a]) = export.colour {
645 out.push_str(" appearance Appearance {\n material Material {\n");
646 let _ = writeln!(out, " diffuseColor {r} {g} {b}");
647 if a < 1.0 {
648 let _ = writeln!(out, " transparency {}", 1.0 - a);
649 }
650 out.push_str(" }\n }\n");
651 }
652 out.push_str(" geometry IndexedFaceSet {\n coord Coordinate {\n point [\n");
653 for p in &export.mesh.positions {
654 let _ = writeln!(out, " {} {} {},", p.x, p.y, p.z);
655 }
656 out.push_str(" ]\n }\n coordIndex [\n");
657 for [a, b, c] in &export.mesh.triangles {
658 let _ = writeln!(out, " {a} {b} {c} -1,");
659 }
660 out.push_str(" ]\n solid TRUE\n }\n}\n\n");
661 }
662 out
663}
664
665#[cfg(test)]
666#[allow(clippy::unwrap_used, clippy::expect_used)]
667mod tests {
668 use super::*;
669 use ogeom_core::Tolerances;
670 use ogeom_math::Frame;
671 use ogeom_topo::Model;
672
673 const T: Tolerances = Tolerances::millimetres();
674
675 fn box_mesh() -> Triangulation {
676 let mut model = Model::new();
677 let solid = ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
678 .unwrap()
679 .shape;
680 ogeom_mesh::triangulate(&model, &solid, ogeom_mesh::Deflection::default(), T).unwrap()
681 }
682
683 #[test]
684 fn a_glb_frames_its_chunks_and_counts_its_geometry() {
685 let mesh = box_mesh();
686 let glb = write_glb(&[ExportMesh {
687 mesh: &mesh,
688 colour: Some([0.8, 0.2, 0.1, 1.0]),
689 name: Some("box".into()),
690 }]);
691 assert_eq!(&glb[0..4], b"glTF");
693 assert_eq!(u32::from_le_bytes(glb[4..8].try_into().unwrap()), 2);
694 assert_eq!(
695 u32::from_le_bytes(glb[8..12].try_into().unwrap()) as usize,
696 glb.len()
697 );
698 let json_length = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;
700 assert_eq!(&glb[16..20], b"JSON");
701 let json = core::str::from_utf8(&glb[20..20 + json_length]).unwrap();
702 assert!(json.contains(r#""version":"2.0""#));
703 assert!(json.contains(r#""POSITION":0"#));
704 assert!(json.contains(r#""indices":2"#));
705 assert!(json.contains("baseColorFactor"));
706 assert!(json.contains(r#""name":"box""#));
707 assert!(json.contains(r#""min":["#), "POSITION carries bounds");
708 let expected =
711 mesh.positions.len() * 12 + mesh.normals.len() * 12 + mesh.triangles.len() * 12;
712 let padded = expected + (4 - expected % 4) % 4;
713 let binary_length =
714 u32::from_le_bytes(glb[20 + json_length..24 + json_length].try_into().unwrap());
715 assert_eq!(binary_length as usize, padded);
716 }
717
718 #[test]
719 fn an_obj_counts_its_records_and_shares_its_index_space() {
720 let mesh = box_mesh();
721 let one = ExportMesh::plain(&mesh);
722 let text = write_obj(&[one.clone(), one]);
723 assert_eq!(
724 text.lines().filter(|l| l.starts_with("v ")).count(),
725 mesh.positions.len() * 2
726 );
727 assert_eq!(
728 text.lines().filter(|l| l.starts_with("f ")).count(),
729 mesh.triangles.len() * 2
730 );
731 let last_face = text.lines().rev().find(|l| l.starts_with("f ")).unwrap();
733 let first_index: usize = last_face
734 .split_whitespace()
735 .nth(1)
736 .unwrap()
737 .split('/')
738 .next()
739 .unwrap()
740 .parse()
741 .unwrap();
742 assert!(first_index > mesh.positions.len());
743 }
744
745 #[test]
746 fn a_ply_declares_what_it_carries() {
747 let mesh = box_mesh();
748 let text = write_ply(&ExportMesh {
749 mesh: &mesh,
750 colour: Some([0.0, 0.5, 1.0, 1.0]),
751 name: None,
752 });
753 assert!(text.starts_with("ply\nformat ascii 1.0\n"));
754 assert!(text.contains(&format!("element vertex {}", mesh.positions.len())));
755 assert!(text.contains(&format!("element face {}", mesh.triangles.len())));
756 assert!(text.contains("property uchar red"));
757 let body_faces = text
758 .lines()
759 .skip_while(|l| *l != "end_header")
760 .filter(|l| l.starts_with("3 "))
761 .count();
762 assert_eq!(body_faces, mesh.triangles.len());
763 }
764}
765
766#[derive(Debug, Clone)]
774pub struct ImportedMesh {
775 pub mesh: Triangulation,
777 pub colour: Option<[f64; 4]>,
779 pub name: Option<String>,
781}
782
783pub fn read_glb(bytes: &[u8]) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
810 let (json, binary) = split_glb(bytes)?;
811 let document = crate::json::parse(&json)?;
812 read_gltf_document(&document, binary.as_deref())
813}
814
815pub fn read_gltf(text: &str) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
825 let document = crate::json::parse(text)?;
826 read_gltf_document(&document, None)
827}
828
829fn split_glb(bytes: &[u8]) -> ogeom_core::OgeomResult<(String, Option<Vec<u8>>)> {
831 let word = |at: usize| -> ogeom_core::OgeomResult<u32> {
832 let Some(slice) = bytes.get(at..at + 4) else {
833 ogeom_bail!(Construction, "the file ends inside its own header");
834 };
835 Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
836 };
837 if word(0)? != 0x4654_6C67 {
838 ogeom_bail!(
839 Construction,
840 "this is not a GLB: the first four bytes are not `glTF`"
841 );
842 }
843 let version = word(4)?;
844 if version != 2 {
845 ogeom_bail!(Construction, "GLB version {version} is not glTF 2.0");
846 }
847 let mut json = None;
848 let mut binary = None;
849 let mut at = 12;
850 while at + 8 <= bytes.len() {
851 let length = word(at)? as usize;
852 let kind = word(at + 4)?;
853 let start = at + 8;
854 let Some(chunk) = bytes.get(start..start + length) else {
855 ogeom_bail!(
856 Construction,
857 "a chunk claims {length} bytes it does not have"
858 );
859 };
860 match kind {
861 0x4E4F_534A => {
862 let Ok(text) = core::str::from_utf8(chunk) else {
863 ogeom_bail!(Construction, "the JSON chunk is not UTF-8");
864 };
865 json = Some(text.trim_end_matches(['\0', ' ']).to_owned());
866 }
867 0x004E_4942 => binary = Some(chunk.to_vec()),
868 _ => {}
870 }
871 at = start + length.next_multiple_of(4);
872 }
873 let Some(json) = json else {
874 ogeom_bail!(Construction, "the GLB carries no JSON chunk");
875 };
876 Ok((json, binary))
877}
878
879fn buffers(
881 document: &crate::json::Json,
882 binary: Option<&[u8]>,
883) -> ogeom_core::OgeomResult<Vec<Vec<u8>>> {
884 let mut out = Vec::new();
885 for buffer in document.get("buffers").map(|b| b.items()).unwrap_or(&[]) {
886 match buffer.get("uri").and_then(crate::json::Json::text) {
887 None => {
888 let Some(chunk) = binary else {
891 ogeom_bail!(
892 Construction,
893 "a buffer with no uri wants the GLB's binary chunk, and \
894 there is none"
895 );
896 };
897 out.push(chunk.to_vec());
898 }
899 Some(uri) if uri.starts_with("data:") => {
900 let Some((_, payload)) = uri.split_once("base64,") else {
901 ogeom_bail!(
902 Construction,
903 "a data uri that is not base64 is not something this reads"
904 );
905 };
906 out.push(from_base64(payload)?);
907 }
908 Some(uri) => ogeom_bail!(
909 Construction,
910 "the buffer points at `{uri}`, an external file this reader is \
911 not given; hand it a GLB or a document with data uris"
912 ),
913 }
914 }
915 Ok(out)
916}
917
918fn from_base64(text: &str) -> ogeom_core::OgeomResult<Vec<u8>> {
920 let value = |c: u8| -> Option<u32> {
921 match c {
922 b'A'..=b'Z' => Some(u32::from(c - b'A')),
923 b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
924 b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
925 b'+' => Some(62),
926 b'/' => Some(63),
927 _ => None,
928 }
929 };
930 let mut out = Vec::with_capacity(text.len() / 4 * 3);
931 let mut acc = 0_u32;
932 let mut held = 0_u32;
933 for c in text.bytes() {
934 if c == b'=' || c.is_ascii_whitespace() {
935 continue;
936 }
937 let Some(v) = value(c) else {
938 ogeom_bail!(Construction, "`{}` is not base64", c as char);
939 };
940 acc = (acc << 6) | v;
941 held += 6;
942 if held >= 8 {
943 held -= 8;
944 #[allow(clippy::cast_possible_truncation, reason = "masked to a byte")]
945 out.push(((acc >> held) & 0xFF) as u8);
946 }
947 }
948 Ok(out)
949}
950
951fn read_gltf_document(
953 document: &crate::json::Json,
954 binary: Option<&[u8]>,
955) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
956 use crate::json::Json;
957 let buffers = buffers(document, binary)?;
958 let views = document.get("bufferViews").map_or(&[][..], Json::items);
959 let accessors = document.get("accessors").map_or(&[][..], Json::items);
960 let meshes = document.get("meshes").map_or(&[][..], Json::items);
961 let nodes = document.get("nodes").map_or(&[][..], Json::items);
962 let materials = document.get("materials").map_or(&[][..], Json::items);
963
964 let scene = document
967 .index_at("scene")
968 .and_then(|i| document.get("scenes")?.items().get(i))
969 .and_then(|s| s.get("nodes"))
970 .map(Json::items);
971 let roots: Vec<usize> = match scene {
972 Some(list) => list.iter().filter_map(Json::index).collect(),
973 None => (0..nodes.len()).collect(),
974 };
975
976 let mut out = Vec::new();
977 let mut pending: Vec<(usize, Placement)> = roots
978 .into_iter()
979 .rev()
980 .map(|i| (i, Placement::IDENTITY))
981 .collect();
982 let mut seen = vec![false; nodes.len()];
983 while let Some((index, parent)) = pending.pop() {
984 let Some(node) = nodes.get(index) else {
985 ogeom_bail!(Construction, "node {index} is not in this document");
986 };
987 if seen.get(index).copied().unwrap_or(false) {
990 ogeom_bail!(Construction, "node {index} is its own descendant");
991 }
992 if let Some(flag) = seen.get_mut(index) {
993 *flag = true;
994 }
995 let here = parent.then(node_transform(node)?);
996 for child in node.get("children").map_or(&[][..], Json::items) {
997 let Some(child) = child.index() else {
998 ogeom_bail!(Construction, "a child index is not an index");
999 };
1000 pending.push((child, here));
1001 }
1002 let Some(mesh_index) = node.index_at("mesh") else {
1003 continue;
1004 };
1005 let Some(mesh) = meshes.get(mesh_index) else {
1006 ogeom_bail!(Construction, "mesh {mesh_index} is not in this document");
1007 };
1008 let name = node
1009 .get("name")
1010 .and_then(Json::text)
1011 .or_else(|| mesh.get("name").and_then(Json::text))
1012 .map(str::to_owned);
1013 for primitive in mesh.get("primitives").map_or(&[][..], Json::items) {
1014 let built = read_primitive(primitive, accessors, views, &buffers, materials, here)?;
1015 if let Some((mesh, colour)) = built {
1016 out.push(ImportedMesh {
1017 mesh,
1018 colour,
1019 name: name.clone(),
1020 });
1021 }
1022 }
1023 }
1024 Ok(out)
1025}
1026
1027#[derive(Debug, Clone, Copy)]
1035pub(crate) struct Placement {
1036 pub(crate) columns: [Vector; 3],
1037 pub(crate) translation: Vector,
1038}
1039
1040impl Placement {
1041 pub(crate) const IDENTITY: Self = Self {
1042 columns: [
1043 Vector::new(1.0, 0.0, 0.0),
1044 Vector::new(0.0, 1.0, 0.0),
1045 Vector::new(0.0, 0.0, 1.0),
1046 ],
1047 translation: Vector::new(0.0, 0.0, 0.0),
1048 };
1049
1050 pub(crate) fn then(self, inner: Self) -> Self {
1052 let map = |v: Vector| self.columns[0] * v.x + self.columns[1] * v.y + self.columns[2] * v.z;
1053 Self {
1054 columns: inner.columns.map(map),
1055 translation: map(inner.translation) + self.translation,
1056 }
1057 }
1058
1059 pub(crate) fn point(self, p: Point) -> Point {
1060 Point::ORIGIN
1061 + self.columns[0] * p.x
1062 + self.columns[1] * p.y
1063 + self.columns[2] * p.z
1064 + self.translation
1065 }
1066
1067 fn normal(self, n: Vector) -> Vector {
1074 let [a, b, c] = self.columns;
1075 let cofactors = [b.cross(c), c.cross(a), a.cross(b)];
1076 let out = cofactors[0] * n.x + cofactors[1] * n.y + cofactors[2] * n.z;
1077 let magnitude = out.magnitude();
1078 if magnitude > 0.0 { out / magnitude } else { n }
1079 }
1080}
1081
1082fn node_transform(node: &crate::json::Json) -> ogeom_core::OgeomResult<Placement> {
1084 use crate::json::Json;
1085 let triple = |value: &Json, what: &str| -> ogeom_core::OgeomResult<[f64; 3]> {
1086 let v: Vec<f64> = value.items().iter().filter_map(Json::number).collect();
1087 let [x, y, z] = v[..] else {
1088 ogeom_bail!(Construction, "a node {what} has three numbers");
1089 };
1090 Ok([x, y, z])
1091 };
1092 if let Some(matrix) = node.get("matrix") {
1093 let values: Vec<f64> = matrix.items().iter().filter_map(Json::number).collect();
1094 if values.len() != 16 {
1095 ogeom_bail!(Construction, "a node matrix has sixteen numbers");
1096 }
1097 return Ok(Placement {
1099 columns: [
1100 Vector::new(values[0], values[1], values[2]),
1101 Vector::new(values[4], values[5], values[6]),
1102 Vector::new(values[8], values[9], values[10]),
1103 ],
1104 translation: Vector::new(values[12], values[13], values[14]),
1105 });
1106 }
1107 let mut placement = Placement::IDENTITY;
1109 if let Some(scale) = node.get("scale") {
1110 let [x, y, z] = triple(scale, "scale")?;
1111 placement = Placement {
1112 columns: [
1113 Vector::new(x, 0.0, 0.0),
1114 Vector::new(0.0, y, 0.0),
1115 Vector::new(0.0, 0.0, z),
1116 ],
1117 translation: Vector::new(0.0, 0.0, 0.0),
1118 }
1119 .then(placement);
1120 }
1121 if let Some(rotation) = node.get("rotation") {
1122 let q: Vec<f64> = rotation.items().iter().filter_map(Json::number).collect();
1123 let [x, y, z, w] = q[..] else {
1124 ogeom_bail!(Construction, "a node rotation is a quaternion of four");
1125 };
1126 placement = quaternion_placement(x, y, z, w).then(placement);
1127 }
1128 if let Some(translation) = node.get("translation") {
1129 let [x, y, z] = triple(translation, "translation")?;
1130 placement = Placement {
1131 translation: Vector::new(x, y, z),
1132 ..Placement::IDENTITY
1133 }
1134 .then(placement);
1135 }
1136 Ok(placement)
1137}
1138
1139fn quaternion_placement(x: f64, y: f64, z: f64, w: f64) -> Placement {
1141 let n = x.mul_add(x, y.mul_add(y, z.mul_add(z, w * w))).sqrt();
1142 let (x, y, z, w) = if n > 0.0 {
1143 (x / n, y / n, z / n, w / n)
1144 } else {
1145 (0.0, 0.0, 0.0, 1.0)
1146 };
1147 Placement {
1148 columns: [
1149 Vector::new(
1150 2.0f64.mul_add(-y.mul_add(y, z * z), 1.0),
1151 2.0 * x.mul_add(y, z * w),
1152 2.0 * x.mul_add(z, -(y * w)),
1153 ),
1154 Vector::new(
1155 2.0 * x.mul_add(y, -(z * w)),
1156 2.0f64.mul_add(-x.mul_add(x, z * z), 1.0),
1157 2.0 * y.mul_add(z, x * w),
1158 ),
1159 Vector::new(
1160 2.0 * x.mul_add(z, y * w),
1161 2.0 * y.mul_add(z, -(x * w)),
1162 2.0f64.mul_add(-x.mul_add(x, y * y), 1.0),
1163 ),
1164 ],
1165 translation: Vector::new(0.0, 0.0, 0.0),
1166 }
1167}
1168
1169fn read_primitive(
1175 primitive: &crate::json::Json,
1176 accessors: &[crate::json::Json],
1177 views: &[crate::json::Json],
1178 buffers: &[Vec<u8>],
1179 materials: &[crate::json::Json],
1180 placement: Placement,
1181) -> ogeom_core::OgeomResult<Option<(Triangulation, Option<[f64; 4]>)>> {
1182 use crate::json::Json;
1183 if primitive
1184 .get("extensions")
1185 .and_then(|e| e.get("KHR_draco_mesh_compression"))
1186 .is_some()
1187 {
1188 ogeom_bail!(
1189 Construction,
1190 "this primitive's geometry is Draco-compressed, which is a codec \
1191 this reader does not carry"
1192 );
1193 }
1194 let mode = primitive.index_at("mode").unwrap_or(4);
1197 if mode != 4 {
1198 ogeom_bail!(
1199 Construction,
1200 "primitive mode {mode} is not triangles; only triangle meshes read \
1201 back as a triangulation"
1202 );
1203 }
1204 let Some(attributes) = primitive.get("attributes") else {
1205 return Ok(None);
1206 };
1207 let Some(position_index) = attributes.index_at("POSITION") else {
1208 return Ok(None);
1209 };
1210 let positions_raw = read_accessor(position_index, accessors, views, buffers)?;
1211 if positions_raw.components != 3 {
1212 ogeom_bail!(Construction, "POSITION is a VEC3");
1213 }
1214 let positions: Vec<Point> = positions_raw
1215 .values
1216 .as_chunks::<3>()
1217 .0
1218 .iter()
1219 .map(|v| placement.point(Point::new(v[0], v[1], v[2])))
1220 .collect();
1221
1222 let normals: Vec<Vector> = match attributes.index_at("NORMAL") {
1223 None => Vec::new(),
1224 Some(index) => {
1225 let raw = read_accessor(index, accessors, views, buffers)?;
1226 if raw.components != 3 {
1227 ogeom_bail!(Construction, "NORMAL is a VEC3");
1228 }
1229 if raw.count != positions.len() {
1230 ogeom_bail!(
1231 Construction,
1232 "the primitive has {} positions and {} normals",
1233 positions.len(),
1234 raw.count
1235 );
1236 }
1237 raw.values
1238 .as_chunks::<3>()
1239 .0
1240 .iter()
1241 .map(|v| placement.normal(Vector::new(v[0], v[1], v[2])))
1242 .collect()
1243 }
1244 };
1245
1246 let indices: Vec<u32> = match primitive.index_at("indices") {
1247 None => (0..u32::try_from(positions.len()).unwrap_or(u32::MAX)).collect(),
1250 Some(index) => {
1251 let raw = read_accessor(index, accessors, views, buffers)?;
1252 if raw.components != 1 {
1253 ogeom_bail!(Construction, "an index accessor is a SCALAR");
1254 }
1255 let mut out = Vec::with_capacity(raw.values.len());
1256 for v in raw.values {
1257 #[expect(
1258 clippy::cast_possible_truncation,
1259 clippy::cast_sign_loss,
1260 reason = "range-checked against the vertex count below"
1261 )]
1262 let k = v as u32;
1263 if f64::from(k) != v || (k as usize) >= positions.len() {
1264 ogeom_bail!(
1265 Construction,
1266 "index {v} names no vertex among {}",
1267 positions.len()
1268 );
1269 }
1270 out.push(k);
1271 }
1272 out
1273 }
1274 };
1275 let triangles: Vec<[u32; 3]> = indices
1276 .as_chunks::<3>()
1277 .0
1278 .iter()
1279 .map(|c| [c[0], c[1], c[2]])
1280 .collect();
1281 if triangles.is_empty() {
1282 return Ok(None);
1283 }
1284
1285 let colour = primitive
1286 .index_at("material")
1287 .and_then(|i| materials.get(i))
1288 .and_then(|m| m.get("pbrMetallicRoughness"))
1289 .and_then(|p| p.get("baseColorFactor"))
1290 .and_then(|f| {
1291 let v: Vec<f64> = f.items().iter().filter_map(Json::number).collect();
1292 <[f64; 4]>::try_from(v).ok()
1293 });
1294
1295 let normals = if normals.is_empty() {
1299 normals_from_triangles(&positions, &triangles)
1300 } else {
1301 normals
1302 };
1303 Ok(Some((
1304 Triangulation {
1305 parameters: vec![(0.0, 0.0); positions.len()],
1306 positions,
1307 normals,
1308 triangles,
1309 deflection_met: false,
1312 },
1313 colour,
1314 )))
1315}
1316
1317pub(crate) fn normals_from_triangles(positions: &[Point], triangles: &[[u32; 3]]) -> Vec<Vector> {
1319 let mut out = vec![Vector::new(0.0, 0.0, 0.0); positions.len()];
1320 for [a, b, c] in triangles {
1321 let (pa, pb, pc) = (
1322 positions[*a as usize],
1323 positions[*b as usize],
1324 positions[*c as usize],
1325 );
1326 let n = (pb - pa).cross(pc - pa);
1329 for &k in &[*a, *b, *c] {
1330 out[k as usize] += n;
1331 }
1332 }
1333 for n in &mut out {
1334 let magnitude = n.magnitude();
1335 *n = if magnitude > 0.0 {
1336 *n / magnitude
1337 } else {
1338 Vector::new(0.0, 0.0, 1.0)
1339 };
1340 }
1341 out
1342}
1343
1344struct AccessorValues {
1346 values: Vec<f64>,
1348 components: usize,
1350 count: usize,
1352}
1353
1354fn read_accessor(
1360 index: usize,
1361 accessors: &[crate::json::Json],
1362 views: &[crate::json::Json],
1363 buffers: &[Vec<u8>],
1364) -> ogeom_core::OgeomResult<AccessorValues> {
1365 let Some(accessor) = accessors.get(index) else {
1366 ogeom_bail!(Construction, "accessor {index} is not in this document");
1367 };
1368 let Some(kind) = accessor.get("type").and_then(crate::json::Json::text) else {
1369 ogeom_bail!(Construction, "accessor {index} states no type");
1370 };
1371 let components = match kind {
1372 "SCALAR" => 1,
1373 "VEC2" => 2,
1374 "VEC3" => 3,
1375 "VEC4" | "MAT2" => 4,
1376 "MAT3" => 9,
1377 "MAT4" => 16,
1378 other => ogeom_bail!(Construction, "`{other}` is not an accessor type"),
1379 };
1380 let Some(component_type) = accessor.index_at("componentType") else {
1381 ogeom_bail!(Construction, "accessor {index} states no componentType");
1382 };
1383 let normalized = accessor.get("normalized") == Some(&crate::json::Json::Bool(true));
1384 let count = accessor.index_at("count").unwrap_or(0);
1385 let mut values = vec![0.0; count * components];
1386
1387 if let Some(view_index) = accessor.index_at("bufferView") {
1388 let offset = accessor.index_at("byteOffset").unwrap_or(0);
1389 read_into(
1390 &mut values,
1391 view_index,
1392 offset,
1393 component_type,
1394 components,
1395 count,
1396 normalized,
1397 views,
1398 buffers,
1399 )?;
1400 }
1401
1402 if let Some(sparse) = accessor.get("sparse") {
1406 let sparse_count = sparse.index_at("count").unwrap_or(0);
1407 let Some(indices) = sparse.get("indices") else {
1408 ogeom_bail!(Construction, "a sparse accessor names its indices");
1409 };
1410 let Some(sparse_values) = sparse.get("values") else {
1411 ogeom_bail!(Construction, "a sparse accessor names its values");
1412 };
1413 let Some(index_type) = indices.index_at("componentType") else {
1414 ogeom_bail!(Construction, "a sparse index has a componentType");
1415 };
1416 let Some(index_view) = indices.index_at("bufferView") else {
1417 ogeom_bail!(Construction, "a sparse index has a bufferView");
1418 };
1419 let mut which = vec![0.0; sparse_count];
1420 read_into(
1421 &mut which,
1422 index_view,
1423 indices.index_at("byteOffset").unwrap_or(0),
1424 index_type,
1425 1,
1426 sparse_count,
1427 false,
1428 views,
1429 buffers,
1430 )?;
1431 let Some(value_view) = sparse_values.index_at("bufferView") else {
1432 ogeom_bail!(Construction, "a sparse value block has a bufferView");
1433 };
1434 let mut replacement = vec![0.0; sparse_count * components];
1435 read_into(
1436 &mut replacement,
1437 value_view,
1438 sparse_values.index_at("byteOffset").unwrap_or(0),
1439 component_type,
1440 components,
1441 sparse_count,
1442 normalized,
1443 views,
1444 buffers,
1445 )?;
1446 for (slot, target) in which.iter().enumerate() {
1447 #[expect(
1448 clippy::cast_possible_truncation,
1449 clippy::cast_sign_loss,
1450 reason = "range-checked against the element count below"
1451 )]
1452 let at = *target as usize;
1453 if f64::from(u32::try_from(at).unwrap_or(u32::MAX)) != *target || at >= count {
1454 ogeom_bail!(
1455 Construction,
1456 "a sparse index names element {target} of {count}"
1457 );
1458 }
1459 for k in 0..components {
1460 values[at * components + k] = replacement[slot * components + k];
1461 }
1462 }
1463 }
1464
1465 Ok(AccessorValues {
1466 values,
1467 components,
1468 count,
1469 })
1470}
1471
1472#[allow(clippy::too_many_arguments)]
1474fn read_into(
1475 out: &mut [f64],
1476 view_index: usize,
1477 accessor_offset: usize,
1478 component_type: usize,
1479 components: usize,
1480 count: usize,
1481 normalized: bool,
1482 views: &[crate::json::Json],
1483 buffers: &[Vec<u8>],
1484) -> ogeom_core::OgeomResult<()> {
1485 let Some(view) = views.get(view_index) else {
1486 ogeom_bail!(
1487 Construction,
1488 "buffer view {view_index} is not in this document"
1489 );
1490 };
1491 let buffer_index = view.index_at("buffer").unwrap_or(0);
1492 let Some(buffer) = buffers.get(buffer_index) else {
1493 ogeom_bail!(
1494 Construction,
1495 "buffer {buffer_index} is not in this document"
1496 );
1497 };
1498 let view_offset = view.index_at("byteOffset").unwrap_or(0);
1499 let width = match component_type {
1500 5120 | 5121 => 1,
1501 5122 | 5123 => 2,
1502 5125 | 5126 => 4,
1503 other => ogeom_bail!(Construction, "{other} is not a glTF component type"),
1504 };
1505 let element = components * width;
1508 let stride = view.index_at("byteStride").unwrap_or(element);
1509 if stride < element {
1510 ogeom_bail!(
1511 Construction,
1512 "a stride of {stride} is shorter than the {element} bytes an \
1513 element occupies"
1514 );
1515 }
1516 for i in 0..count {
1517 let base = view_offset + accessor_offset + i * stride;
1518 for k in 0..components {
1519 let at = base + k * width;
1520 let Some(slice) = buffer.get(at..at + width) else {
1521 ogeom_bail!(
1522 Construction,
1523 "the buffer ends at {} where the accessor wants byte {at}",
1524 buffer.len()
1525 );
1526 };
1527 out[i * components + k] = component_value(component_type, slice, normalized);
1528 }
1529 }
1530 Ok(())
1531}
1532
1533fn component_value(component_type: usize, bytes: &[u8], normalized: bool) -> f64 {
1539 match component_type {
1540 5120 => {
1541 let v = f64::from(bytes[0] as i8);
1542 if normalized { (v / 127.0).max(-1.0) } else { v }
1543 }
1544 5121 => {
1545 let v = f64::from(bytes[0]);
1546 if normalized { v / 255.0 } else { v }
1547 }
1548 5122 => {
1549 let v = f64::from(i16::from_le_bytes([bytes[0], bytes[1]]));
1550 if normalized {
1551 (v / 32767.0).max(-1.0)
1552 } else {
1553 v
1554 }
1555 }
1556 5123 => {
1557 let v = f64::from(u16::from_le_bytes([bytes[0], bytes[1]]));
1558 if normalized { v / 65535.0 } else { v }
1559 }
1560 5125 => f64::from(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])),
1561 _ => f64::from(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])),
1562 }
1563}