brep_kernel/io/glb.rs
1//! Binary glTF 2.0 (`.glb`) writer for DISPLAY MESHES — tessellated exchange
2//! for viewers, web pages and downstream rendering. It is NOT a replacement for
3//! the STEP lane: no exact surface, no topology, no feature tree leaves here.
4//!
5//! # The container
6//!
7//! A GLB is a 12-byte header (`glTF` magic, version 2, total length) followed by
8//! chunks, each `[u32 length][u32 type][payload]`. This writer emits exactly the
9//! two the spec defines for a self-contained file: chunk 0 `JSON` (padded with
10//! spaces) and chunk 1 `BIN\0` (padded with zeros), both to a 4-byte boundary.
11//! Every accessor element here is 4 bytes wide (`f32` positions/normals, `u32`
12//! indices), so every buffer view starts 4-byte aligned by construction.
13//!
14//! The JSON is hand-rolled. The kernel takes no glTF dependency, and writing the
15//! document directly is what makes the key order — and therefore the bytes — the
16//! same on every run for the same input.
17//!
18//! # Axis convention — stated, and carried by the node, not the vertices
19//!
20//! glTF is right-handed with **+Y up**. So is this application's world: the
21//! feature pipeline builds every frame against `world_up = (0, 1, 0)`
22//! (`feature_pipeline/mod.rs`, the "standard world-up convention" the Plane
23//! feature's documentation cites), the viewport camera's default up is `+Y`
24//! (`BREP_render::view::ViewCamera`), and the ViewCube puts TOP/BOTTOM on ±Y
25//! and FRONT/BACK on ±Z. The two conventions therefore AGREE and the mapping is
26//! the identity — but it is written out as an explicit `"rotation"` on the root
27//! node ([`GlbUpAxis::YUp`] → `[0,0,0,1]`) rather than left implicit, so the
28//! file states which convention it was written under.
29//!
30//! [`GlbUpAxis::ZUp`] is the other half of that statement: a Z-up world (the
31//! artifact renderer's camera is one) rotates -90° about X, so world +Z becomes
32//! glTF +Y and world +Y becomes glTF -Z. Either way the ROTATION IS A NODE
33//! TRANSFORM: vertex data is written in the caller's own coordinates, unrotated,
34//! so a consumer that ignores the node still reads the model's own numbers and
35//! one that honours it sees the model standing up.
36//!
37//! # Units
38//!
39//! glTF's linear unit is the METRE; this application models in millimetres (the
40//! STEP writer's `MM`, the unit STL and OBJ leave implicit). The root node
41//! therefore also carries `"scale"` — `unit_scale`, `0.001` for millimetres — by
42//! the same "transform, not vertices" rule as the rotation. A viewer shows a
43//! 100 mm part as 0.1 m, which is what it is; the vertex buffer still carries
44//! `100`.
45//!
46//! # Colour
47//!
48//! Colours arrive as 8-bit **sRGB**, the spelling the metadata store and the
49//! STEP writer's [`crate::StepColors`] use. glTF's `baseColorFactor` is
50//! **linear**, so each channel is decoded with the sRGB electro-optical transfer
51//! function on `byte / 255`:
52//!
53//! ```text
54//! c <= 0.04045 -> c / 12.92
55//! c > 0.04045 -> ((c + 0.055) / 1.055) ^ 2.4
56//! ```
57//!
58//! which is the same decode the renderer applies before shading
59//! (`BREP_render::color::srgb_to_linear`), so the file carries the colour the
60//! viewport shows.
61//!
62//! Grouping: one glTF mesh per solid, one PRIMITIVE per distinct colour within
63//! it. A solid whose faces all take the body colour is one primitive; a solid
64//! carrying per-face colours ([`GlbSolid::face_colors`], keyed by the same
65//! per-triangle face id the display mesh carries) is one primitive per distinct
66//! colour, in first-appearance order. Materials are deduplicated across the
67//! whole file and named after their hex, so two bodies painted alike share one
68//! material. A group with no colour at all gets no material — the caller decides
69//! whether "uncoloured" means the viewer's default or a colour of its own.
70use crate::Mesh;
71
72/// `glTF` as a little-endian `u32` — the first four bytes of every GLB.
73pub const GLB_MAGIC: u32 = 0x4654_6C67;
74/// `JSON` chunk type.
75const CHUNK_JSON: u32 = 0x4E4F_534A;
76/// `BIN\0` chunk type.
77const CHUNK_BIN: u32 = 0x004E_4942;
78/// `ARRAY_BUFFER` — vertex attribute buffer views.
79const TARGET_ARRAY_BUFFER: u32 = 34962;
80/// `ELEMENT_ARRAY_BUFFER` — index buffer views.
81const TARGET_ELEMENT_ARRAY_BUFFER: u32 = 34963;
82/// `FLOAT` accessor component type.
83const COMPONENT_FLOAT: u32 = 5126;
84/// `UNSIGNED_INT` accessor component type (indices; core glTF 2.0).
85const COMPONENT_UNSIGNED_INT: u32 = 5125;
86
87/// Which axis of the CALLER's world points up, and therefore what rotation the
88/// root node carries to reach glTF's own right-handed +Y-up frame.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum GlbUpAxis {
91 /// +Y up — this application's world (see the module doc). Identity.
92 YUp,
93 /// +Z up — rotate -90° about X, so world +Z lands on glTF +Y.
94 ZUp,
95}
96
97impl GlbUpAxis {
98 /// The node rotation as glTF spells a quaternion: `[x, y, z, w]`.
99 pub fn node_rotation(self) -> [f64; 4] {
100 match self {
101 GlbUpAxis::YUp => [0.0, 0.0, 0.0, 1.0],
102 // -90° about X maps (x, y, z) -> (x, z, -y): +Z up becomes +Y up and
103 // the world's +Y (into the scene) becomes glTF's -Z. det = +1, so
104 // handedness — and therefore the sign of every volume — is kept.
105 GlbUpAxis::ZUp => [
106 -std::f64::consts::FRAC_1_SQRT_2,
107 0.0,
108 0.0,
109 std::f64::consts::FRAC_1_SQRT_2,
110 ],
111 }
112 }
113}
114
115/// One solid to write: its display mesh plus the colours it is shown in.
116#[derive(Clone, Debug, Default)]
117pub struct GlbSolid {
118 /// The scene name — becomes the glTF node and mesh name.
119 pub name: String,
120 /// Positions / normals / triangle indices, and the per-TRIANGLE face id the
121 /// `face_colors` keys match. An empty `face_ids` means "one group".
122 pub mesh: Mesh,
123 /// The body colour, 8-bit sRGB. `None` writes the primitive with no material.
124 pub color: Option<[u8; 3]>,
125 /// Per-face colour overrides, keyed by the face id in `mesh.face_ids`.
126 /// A face listed here wins over `color` for its own triangles.
127 pub face_colors: std::collections::BTreeMap<u32, [u8; 3]>,
128}
129
130/// What one written file contains — the counts a caller reports or asserts on.
131#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
132pub struct GlbReport {
133 /// Meshes written (solids that carried at least one triangle).
134 pub meshes: usize,
135 /// Primitives across every mesh — one per distinct colour within a solid.
136 pub primitives: usize,
137 /// Distinct materials, deduplicated across the whole file.
138 pub materials: usize,
139 /// Triangles written.
140 pub triangles: usize,
141 /// Total file length in bytes.
142 pub bytes: usize,
143}
144
145/// sRGB electro-optical transfer: one 8-bit sRGB channel -> linear 0..1.
146fn srgb_byte_to_linear(byte: u8) -> f64 {
147 let c = f64::from(byte) / 255.0;
148 if c <= 0.04045 {
149 c / 12.92
150 } else {
151 ((c + 0.055) / 1.055).powf(2.4)
152 }
153}
154
155/// A JSON string literal, with the escapes the grammar requires. Scene names
156/// carry `:` today and nothing stops a quote or a backslash tomorrow.
157fn json_string(value: &str) -> String {
158 let mut out = String::with_capacity(value.len() + 2);
159 out.push('"');
160 for ch in value.chars() {
161 match ch {
162 '"' => out.push_str("\\\""),
163 '\\' => out.push_str("\\\\"),
164 '\n' => out.push_str("\\n"),
165 '\r' => out.push_str("\\r"),
166 '\t' => out.push_str("\\t"),
167 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
168 c => out.push(c),
169 }
170 }
171 out.push('"');
172 out
173}
174
175/// A finite f64 as a JSON number. Rust's `Display` never emits an exponent and
176/// prints the shortest round-tripping form, so this is both valid JSON and
177/// exact; a non-finite value is refused rather than written as `NaN`.
178fn json_number(value: f64) -> Result<String, String> {
179 if !value.is_finite() {
180 return Err("write_glb: non-finite number".into());
181 }
182 Ok(format!("{value}"))
183}
184
185fn json_numbers(values: &[f64]) -> Result<String, String> {
186 let parts: Result<Vec<String>, String> = values.iter().map(|v| json_number(*v)).collect();
187 Ok(format!("[{}]", parts?.join(",")))
188}
189
190fn push_u32(bytes: &mut Vec<u8>, value: u32) {
191 bytes.extend_from_slice(&value.to_le_bytes());
192}
193
194/// Colour of one triangle: its face's override if it has one, else the body's.
195fn triangle_color(solid: &GlbSolid, triangle: usize) -> Option<[u8; 3]> {
196 solid
197 .mesh
198 .face_ids
199 .get(triangle)
200 .and_then(|face| solid.face_colors.get(face))
201 .copied()
202 .or(solid.color)
203}
204
205/// Write `solids` as one binary glTF 2.0 document.
206///
207/// `document_name` names the scene and its root node; `up` states the caller's
208/// world up axis and `unit_scale` the caller's unit in metres (`0.001` for
209/// millimetres) — both ride on the root node's transform, never on the vertices.
210///
211/// Solids with no triangles are SKIPPED (a glTF accessor must hold at least one
212/// element); a set in which nothing has a triangle is refused, as the OBJ and
213/// STL lanes refuse an empty scene.
214pub fn write_glb(
215 solids: &[GlbSolid],
216 document_name: &str,
217 up: GlbUpAxis,
218 unit_scale: f64,
219) -> Result<(Vec<u8>, GlbReport), String> {
220 let mut bin: Vec<u8> = Vec::new();
221 let mut buffer_views: Vec<String> = Vec::new();
222 let mut accessors: Vec<String> = Vec::new();
223 let mut meshes: Vec<String> = Vec::new();
224 let mut nodes: Vec<String> = Vec::new();
225 let mut palette: Vec<[u8; 3]> = Vec::new();
226 let mut report = GlbReport::default();
227
228 for solid in solids {
229 solid.mesh.validate()?;
230 let triangles = solid.mesh.indices.len() / 3;
231 if triangles == 0 {
232 continue;
233 }
234 let vertices = solid.mesh.positions.len() / 3;
235
236 // Narrow to f32 ONCE, here: the accessor's min/max must be the values
237 // the file actually stores, so they are taken after the narrowing.
238 let positions: Vec<f32> = solid.mesh.positions.iter().map(|v| *v as f32).collect();
239 let normals: Vec<f32> = solid.mesh.normals.iter().map(|v| *v as f32).collect();
240 if !positions.iter().all(|v| v.is_finite()) || !normals.iter().all(|v| v.is_finite()) {
241 return Err(format!(
242 "write_glb: solid '{}' has a non-finite position or normal",
243 solid.name
244 ));
245 }
246 let mut min = [f64::INFINITY; 3];
247 let mut max = [f64::NEG_INFINITY; 3];
248 for point in positions.chunks_exact(3) {
249 for axis in 0..3 {
250 let value = f64::from(point[axis]);
251 min[axis] = min[axis].min(value);
252 max[axis] = max[axis].max(value);
253 }
254 }
255
256 let position_view = buffer_views.len();
257 buffer_views.push(format!(
258 r#"{{"buffer":0,"byteOffset":{},"byteLength":{},"target":{TARGET_ARRAY_BUFFER}}}"#,
259 bin.len(),
260 positions.len() * 4
261 ));
262 for value in &positions {
263 bin.extend_from_slice(&value.to_le_bytes());
264 }
265 let position_accessor = accessors.len();
266 accessors.push(format!(
267 r#"{{"bufferView":{position_view},"componentType":{COMPONENT_FLOAT},"count":{vertices},"type":"VEC3","min":{},"max":{}}}"#,
268 json_numbers(&min)?,
269 json_numbers(&max)?
270 ));
271
272 let normal_view = buffer_views.len();
273 buffer_views.push(format!(
274 r#"{{"buffer":0,"byteOffset":{},"byteLength":{},"target":{TARGET_ARRAY_BUFFER}}}"#,
275 bin.len(),
276 normals.len() * 4
277 ));
278 for value in &normals {
279 bin.extend_from_slice(&value.to_le_bytes());
280 }
281 let normal_accessor = accessors.len();
282 accessors.push(format!(
283 r#"{{"bufferView":{normal_view},"componentType":{COMPONENT_FLOAT},"count":{vertices},"type":"VEC3"}}"#
284 ));
285
286 // Group triangles by the colour they are shown in, in FIRST-APPEARANCE
287 // order so the primitive order follows the mesh rather than the palette.
288 let mut groups: Vec<(Option<[u8; 3]>, Vec<u32>)> = Vec::new();
289 for triangle in 0..triangles {
290 let color = triangle_color(solid, triangle);
291 let slot = match groups.iter().position(|(key, _)| *key == color) {
292 Some(slot) => slot,
293 None => {
294 groups.push((color, Vec::new()));
295 groups.len() - 1
296 }
297 };
298 groups[slot]
299 .1
300 .extend_from_slice(&solid.mesh.indices[triangle * 3..triangle * 3 + 3]);
301 }
302
303 let mut primitives: Vec<String> = Vec::new();
304 for (color, indices) in &groups {
305 let index_view = buffer_views.len();
306 buffer_views.push(format!(
307 r#"{{"buffer":0,"byteOffset":{},"byteLength":{},"target":{TARGET_ELEMENT_ARRAY_BUFFER}}}"#,
308 bin.len(),
309 indices.len() * 4
310 ));
311 for index in indices {
312 push_u32(&mut bin, *index);
313 }
314 let index_accessor = accessors.len();
315 accessors.push(format!(
316 r#"{{"bufferView":{index_view},"componentType":{COMPONENT_UNSIGNED_INT},"count":{},"type":"SCALAR"}}"#,
317 indices.len()
318 ));
319 let material = color.map(|rgb| match palette.iter().position(|c| *c == rgb) {
320 Some(slot) => slot,
321 None => {
322 palette.push(rgb);
323 palette.len() - 1
324 }
325 });
326 let material_key = match material {
327 Some(slot) => format!(r#","material":{slot}"#),
328 None => String::new(),
329 };
330 primitives.push(format!(
331 r#"{{"attributes":{{"POSITION":{position_accessor},"NORMAL":{normal_accessor}}},"indices":{index_accessor}{material_key},"mode":4}}"#
332 ));
333 }
334 report.primitives += primitives.len();
335 report.triangles += triangles;
336
337 let mesh_index = meshes.len();
338 meshes.push(format!(
339 r#"{{"name":{},"primitives":[{}]}}"#,
340 json_string(&solid.name),
341 primitives.join(",")
342 ));
343 // Node 0 is the root; the solids are its children, numbered from 1 in
344 // the order they were written.
345 nodes.push(format!(
346 r#"{{"name":{},"mesh":{mesh_index}}}"#,
347 json_string(&solid.name)
348 ));
349 }
350
351 if meshes.is_empty() {
352 return Err("nothing to export: the scene has no triangles".into());
353 }
354 report.meshes = meshes.len();
355 report.materials = palette.len();
356
357 let materials: Vec<String> = palette
358 .iter()
359 .map(|rgb| {
360 let linear = rgb.map(srgb_byte_to_linear);
361 Ok(format!(
362 r#"{{"name":{},"pbrMetallicRoughness":{{"baseColorFactor":[{},{},{},1],"metallicFactor":0,"roughnessFactor":0.5}}}}"#,
363 json_string(&format!("color_{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])),
364 json_number(linear[0])?,
365 json_number(linear[1])?,
366 json_number(linear[2])?
367 ))
368 })
369 .collect::<Result<Vec<String>, String>>()?;
370
371 // The root: the axis rotation and the unit scale, in front of every solid.
372 let children: Vec<String> = (1..=nodes.len()).map(|index| index.to_string()).collect();
373 let root = format!(
374 r#"{{"name":{},"rotation":{},"scale":[{s},{s},{s}],"children":[{}]}}"#,
375 json_string(document_name),
376 json_numbers(&up.node_rotation())?,
377 children.join(","),
378 s = json_number(unit_scale)?
379 );
380 nodes.insert(0, root);
381
382 let mut json = String::new();
383 json.push_str(r#"{"asset":{"version":"2.0","generator":"BREP glTF 2.0 writer"}"#);
384 json.push_str(&format!(
385 r#","scene":0,"scenes":[{{"name":{},"nodes":[0]}}]"#,
386 json_string(document_name)
387 ));
388 json.push_str(&format!(r#","nodes":[{}]"#, nodes.join(",")));
389 json.push_str(&format!(r#","meshes":[{}]"#, meshes.join(",")));
390 if !materials.is_empty() {
391 json.push_str(&format!(r#","materials":[{}]"#, materials.join(",")));
392 }
393 json.push_str(&format!(r#","accessors":[{}]"#, accessors.join(",")));
394 json.push_str(&format!(r#","bufferViews":[{}]"#, buffer_views.join(",")));
395 json.push_str(&format!(r#","buffers":[{{"byteLength":{}}}]"#, bin.len()));
396 json.push('}');
397
398 // Both chunks pad to 4 bytes: the JSON with SPACES, the binary with ZEROS.
399 let mut json_bytes = json.into_bytes();
400 while json_bytes.len() % 4 != 0 {
401 json_bytes.push(b' ');
402 }
403 while bin.len() % 4 != 0 {
404 bin.push(0);
405 }
406
407 let total = 12 + 8 + json_bytes.len() + 8 + bin.len();
408 let mut out = Vec::with_capacity(total);
409 push_u32(&mut out, GLB_MAGIC);
410 push_u32(&mut out, 2);
411 push_u32(&mut out, total as u32);
412 push_u32(&mut out, json_bytes.len() as u32);
413 push_u32(&mut out, CHUNK_JSON);
414 out.extend_from_slice(&json_bytes);
415 push_u32(&mut out, bin.len() as u32);
416 push_u32(&mut out, CHUNK_BIN);
417 out.extend_from_slice(&bin);
418 report.bytes = out.len();
419 Ok((out, report))
420}
421