Skip to main content

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