ifc_lite_export/gltf/
from_meshes.rs

1// SPDX-License-Identifier: MPL-2.0
2//! The **from-meshes** GLB path: assemble a GLB from already-produced meshes (the
3//! viewer's `MeshData`, flattened) with no re-meshing, plus its fail-closed wrapper.
4//!
5//! Split out of `gltf.rs` to keep that module under its size ratchet; the logic is
6//! unchanged, so the emitted bytes are identical.
7
8use super::{build_gltf, pack_glb, Chunker, GltfStats, MeshView};
9use crate::error::ExportError;
10
11/// Fail-closed [`export_glb_from_meshes`]: validates the per-mesh
12/// `vertex_counts` / `index_counts` (and normals) against the flattened buffers, and
13/// the index VALUES against the vertex counts, BEFORE assembling. Returns
14/// [`ExportError::MalformedMeshInput`] when a declared vertex/index count runs past
15/// the end of `positions` / `indices`, an `index_counts` entry is missing, `normals`
16/// is empty or too short to cover every emitted vertex, or an index names a vertex
17/// its own mesh does not have — instead of silently dropping the un-backed meshes
18/// (which the infallible variant does — a valid GLB missing part of the model,
19/// reported as success) or copying the out-of-range index straight into the BIN
20/// chunk. Prefer this at any boundary where the counts and buffers come from
21/// separate sources (the wasm FFI).
22#[allow(clippy::too_many_arguments)]
23pub fn try_export_glb_from_meshes(
24    positions: &[f32],
25    normals: &[f32],
26    indices: &[u32],
27    vertex_counts: &[u32],
28    index_counts: &[u32],
29    colors: &[f32],
30    origins: &[f64],
31    express_ids: &[u32],
32    include_metadata: bool,
33    lit: bool,
34    emissive: bool,
35) -> Result<(Vec<u8>, GltfStats), ExportError> {
36    // Sum the declared counts and check they fit the flattened buffers. `u64` sums so a
37    // caller passing absurd counts can't overflow `usize` on wasm32 before the compare.
38    let n = vertex_counts.len();
39    let vsum: u64 = vertex_counts.iter().map(|&c| c as u64).sum();
40    let isum: u64 = index_counts.iter().take(n).map(|&c| c as u64).sum();
41    if vsum * 3 > positions.len() as u64 {
42        return Err(ExportError::MalformedMeshInput {
43            detail: format!(
44                "vertex_counts sum to {vsum} vertices ({} position floats) but `positions` has {}",
45                vsum * 3,
46                positions.len()
47            ),
48        });
49    }
50    // Parallel-array contract: each declared mesh needs its own `index_counts` entry.
51    // With fewer entries the assembler reads a missing count as 0 indices, so those
52    // meshes fail `view_ok` and vanish from a "successful" GLB.
53    if index_counts.len() < n {
54        return Err(ExportError::MalformedMeshInput {
55            detail: format!(
56                "`index_counts` has {} entries but `vertex_counts` declares {n} meshes",
57                index_counts.len()
58            ),
59        });
60    }
61    // Normals must cover EVERY emitted vertex. Empty (or short) normals make the
62    // under-covered meshes fall back to an empty normal slice, silently failing the
63    // `view_ok` `normals.len() == positions.len()` gate — with empty normals that drops
64    // the WHOLE model into a valid-but-empty GLB.
65    if (normals.len() as u64) < vsum * 3 {
66        return Err(ExportError::MalformedMeshInput {
67            detail: format!(
68                "`normals` has {} floats but the meshes need {} (vertex_counts sum to {vsum})",
69                normals.len(),
70                vsum * 3
71            ),
72        });
73    }
74    if isum > indices.len() as u64 {
75        return Err(ExportError::MalformedMeshInput {
76            detail: format!(
77                "index_counts sum to {isum} but `indices` has {}",
78                indices.len()
79            ),
80        });
81    }
82    // Every check above is about a COUNT running past a buffer. A block that is
83    // not whole triangles, or an index VALUE outside its mesh, passes all of
84    // them; the assembler would write the first as a TRIANGLES primitive whose
85    // count is not a multiple of 3 and copy the second into the BIN chunk. The
86    // COLLADA writer refuses both through the same predicate.
87    crate::mesh_input::check_index_blocks(indices, vertex_counts, index_counts)?;
88    // Every count is backed and every mesh's normals/indices are present, so the
89    // infallible assembler's malformed-input `break` is unreachable and no mesh is dropped.
90    let (glb, stats) = export_glb_from_meshes(
91        positions, normals, indices, vertex_counts, index_counts, colors, origins,
92        express_ids, include_metadata, lit, emissive,
93    );
94    // Zero visible meshes (empty input, or every declared mesh failing `view_ok` —
95    // e.g. a single-vertex/zero-index mesh) passes every count check above trivially
96    // and would otherwise return a "successful" GLB that glTF-Validator rejects:
97    // `accessors`/`bufferViews`/`meshes`/`nodes` are EMPTY_ENTITY (glTF schema
98    // `minItems: 1` when present) and `buffers[0].byteLength` is 0 (schema
99    // `minimum: 1`). `try_export_glb` (the from-bytes sibling, #1438/#1516) already
100    // fails closed here with `NoRenderGeometry`; this from-meshes entry point —
101    // reachable from the viewer's `exportGlbFromMeshes` — did not.
102    if stats.meshes == 0 {
103        return Err(ExportError::NoRenderGeometry);
104    }
105    Ok((glb, stats))
106}
107
108/// Assemble a GLB from already-produced meshes (the viewer's MeshData — **no re-meshing**).
109/// Per mesh `i`: `vertex_counts[i]` vertices + `index_counts[i]` indices, taken in order
110/// from the concatenated `positions`/`normals`/`indices`; `colors` is RGBA per mesh,
111/// `origins` is xyz per mesh, `express_ids` labels each mesh. Indices are per-mesh LOCAL.
112/// Callers pass exactly the meshes they want emitted (visibility filtering is theirs).
113///
114/// NOTE: on malformed input — a `vertex_counts`/`index_counts` entry that runs past the
115/// end of the flattened buffers — this stops at the first un-backed mesh and returns the
116/// valid prefix, so a caller bug ships a GLB missing the model's tail as "success". Use
117/// [`try_export_glb_from_meshes`] to turn that into [`ExportError::MalformedMeshInput`].
118#[allow(clippy::too_many_arguments)]
119// The index `i` walks several parallel count/offset arrays in lockstep; a
120// range loop is the clearest expression and avoids zipping ragged slices.
121#[allow(clippy::needless_range_loop)]
122pub fn export_glb_from_meshes(
123    positions: &[f32],
124    normals: &[f32],
125    indices: &[u32],
126    vertex_counts: &[u32],
127    index_counts: &[u32],
128    colors: &[f32],
129    origins: &[f64],
130    express_ids: &[u32],
131    include_metadata: bool,
132    lit: bool,
133    emissive: bool,
134) -> (Vec<u8>, GltfStats) {
135    // Gate every float BEFORE any of it is read: a non-finite coordinate has no
136    // representation in glTF JSON (`serde_json` writes `null`, which is
137    // schema-invalid) and, left alone, propagates through the scene-centre
138    // subtraction into vertices that were fine. See `crate::mesh_input`.
139    let scrubbed = crate::mesh_input::scrub_nonfinite(positions, normals, colors, origins);
140    let (positions, normals, colors, origins) = (
141        &*scrubbed.positions,
142        &*scrubbed.normals,
143        &*scrubbed.colors,
144        &*scrubbed.origins,
145    );
146    let n = vertex_counts.len();
147    // The viewer's `MeshData` arrives pre-welded from the mesh source
148    // (`ifc_lite_geometry::mesh_weld`, run in the object frame for shared
149    // geometry and at `element::build_mesh_data` for the rest), so this path no
150    // longer re-welds — it slices each mesh's block straight into a borrowing
151    // `MeshView`. Views borrow the caller's buffers, which outlive the call.
152    let mut views: Vec<MeshView> = Vec::with_capacity(n);
153    let mut vbase = 0usize; // running vertex offset
154    let mut ibase = 0usize; // running index offset
155    for i in 0..n {
156        let vc = vertex_counts[i] as usize;
157        let ic = index_counts.get(i).copied().unwrap_or(0) as usize;
158        if (vbase + vc) * 3 > positions.len() || ibase + ic > indices.len() {
159            break; // malformed counts — stop rather than panic
160        }
161        let pslice = &positions[vbase * 3..(vbase + vc) * 3];
162        let nslice: &[f32] = if normals.len() >= (vbase + vc) * 3 {
163            &normals[vbase * 3..(vbase + vc) * 3]
164        } else {
165            &[]
166        };
167        let islice = &indices[ibase..ibase + ic];
168        let color = [
169            colors.get(i * 4).copied().unwrap_or(0.8),
170            colors.get(i * 4 + 1).copied().unwrap_or(0.8),
171            colors.get(i * 4 + 2).copied().unwrap_or(0.8),
172            colors.get(i * 4 + 3).copied().unwrap_or(1.0),
173        ];
174        let origin = [
175            origins.get(i * 3).copied().unwrap_or(0.0),
176            origins.get(i * 3 + 1).copied().unwrap_or(0.0),
177            origins.get(i * 3 + 2).copied().unwrap_or(0.0),
178        ];
179        views.push(MeshView {
180            express_id: express_ids.get(i).copied().unwrap_or(0),
181            ifc_type: "",
182            global_id: None,
183            positions: pslice,
184            normals: nslice,
185            indices: islice,
186            color,
187            origin,
188            // The viewer's MeshData drops the instancing side-channel across the
189            // worker boundary (it is `#[serde(skip)]`), so this path is always flat.
190            instance: None,
191        });
192        vbase += vc;
193        ibase += ic;
194    }
195    // From-meshes geometry is already absolute Y-up and never instances (no
196    // side-channel), so there is no RTC frame to compensate. Quantization is a
197    // from-bytes feature; the viewer path stays f32.
198    let mut ch = Chunker::new(12, usize::MAX, None);
199    let (gltf, stats) =
200        build_gltf(
201            &views,
202            include_metadata,
203            None,
204            lit,
205            emissive,
206            // No `ProcessingResult` reaches this path, so there is no tag
207            // either. `RawIfc` is what the absent site placement and the zero
208            // RTC below already say: nothing was subtracted, nothing rotated.
209            ifc_lite_processing::MeshCoordinateSpace::RawIfc,
210            [0.0, 0.0, 0.0],
211            // No `ProcessingResult` reaches this path, so there is no site
212            // placement available to restore.
213            //
214            // Not the same as "there is nothing to restore". The viewer's own
215            // `MeshData` comes through the same pipeline and, for a translated
216            // site, *is* site-local, so a GLB exported from the viewer stays in
217            // that frame. Fixing that needs the API to carry `site_transform`,
218            // which is a change to its signature rather than to this line.
219            None,
220            false,
221            &mut ch,
222        );
223    let json = serde_json::to_vec(&gltf).expect("glTF JSON serializes");
224    (pack_glb(&json, &ch.pos, &ch.norm, &ch.idx), stats)
225}