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}