ifc_lite_export/collada.rs
1// SPDX-License-Identifier: MPL-2.0
2//! **COLLADA 1.4.1** (`.dae`) exporter — the model format Google Earth's KML
3//! `<Model>` actually loads (it does NOT accept glTF/GLB; a `.glb` in `<Model>`
4//! fails with "Unsupported element: Model"). Used to build the KMZ payload (#1427).
5//!
6//! Input is the viewer's already-produced **Y-up** `MeshData` (the from-meshes
7//! path), identical to the GLB exporter. Two Google-Earth-specific choices make
8//! the model render correctly:
9//!
10//! - **Orientation:** vertices are converted back to the IFC-native **Z-up** frame
11//! (`(x, y, z)_yup -> (x, -z, y)_zup`) and the document declares `<up_axis>Z_UP</up_axis>`,
12//! so the building stands upright. Horizontal grid-north alignment is carried by
13//! the KML `<Model><Orientation><heading>` (computed elsewhere), exactly as the
14//! GLB path did, so X/Y placement matches the (already-correct) GLB output.
15//! - **Brightness:** Google Earth has no ambient/IBL and a single hard sun, so a
16//! plain diffuse material renders near-black. Each material sets `<emission>` to
17//! its colour (the well-known "make Google Earth models glow" trick) so the model
18//! shows its true colour. Faces are flagged `double_sided` via the `GOOGLEEARTH`
19//! profile extra (IFC winding isn't reliably outward).
20
21use std::collections::HashMap;
22
23use crate::collada_document::write_dae;
24use crate::error::ExportError;
25
26/// Material dedup key: RGBA rounded to 2 decimals (matches the glTF exporter).
27fn color_key(c: [f32; 4]) -> (i32, i32, i32, i32) {
28 let r = |v: f32| (v * 100.0).round() as i32;
29 (r(c[0]), r(c[1]), r(c[2]), r(c[3]))
30}
31
32/// Convert a Y-up vector back to the IFC-native Z-up frame: `(x, y, z) -> (x, -z, y)`.
33#[inline]
34fn to_zup(x: f64, y: f64, z: f64) -> [f64; 3] {
35 [x, -z, y]
36}
37
38/// Vertex dedup key: position quantised to 0.1 mm + normal to ~1e-3 (the emitted
39/// precision), so vertices that serialise identically collapse to one. Quantises in
40/// f64→i64 so large world coordinates (e.g. a national-grid model not RTC-shifted,
41/// hundreds of km) can't overflow the key and merge unrelated vertices. The position
42/// is kept in f64 end-to-end (only normals, which are unit-scale, are f32) so a
43/// large-georef world coordinate is not quantised by an early f32 downcast.
44#[inline]
45fn vert_key(p: [f64; 3], n: [f32; 3]) -> [i64; 6] {
46 let qp = |v: f64| (v * 10_000.0).round() as i64;
47 let qn = |v: f32| (v as f64 * 1_000.0).round() as i64;
48 [qp(p[0]), qp(p[1]), qp(p[2]), qn(n[0]), qn(n[1]), qn(n[2])]
49}
50
51/// Build a Google-Earth-compatible COLLADA 1.4.1 `.dae` from already-produced
52/// (Y-up) meshes, flattened into parallel arrays exactly like
53/// `export_glb_from_meshes`. Per mesh `i`: `vertex_counts[i]` vertices +
54/// `index_counts[i]` indices taken in order from the concatenated
55/// `positions`/`normals`/`indices`; `colors` is RGBA per mesh, `origins` xyz per
56/// mesh (`world = origin + position`). Returns the `.dae` bytes (UTF-8 XML).
57///
58/// Fails closed when no triangle survives. An empty document is not merely
59/// useless, it is SCHEMA-INVALID: COLLADA 1.4.1 declares `<library_effects>`,
60/// `<library_materials>`, `<library_geometries>` and `<visual_scene>` each with
61/// `minOccurs="1"` children, and `write_dae` emits all four regardless. Two
62/// inputs reach it: an empty visible set, and a mesh whose only triangle is
63/// degenerate (the vertex dedup collapses two of its corners and the triangle
64/// is dropped AFTER its material was registered, so effects and materials are
65/// non-empty while geometries is empty). The GLB twin fails closed on the same
66/// condition (`gltf::from_meshes`, #1438/#1516). The count that decides is
67/// taken from the material buckets AFTER every drop rule has run, not from the
68/// caller's `index_counts`, which says only what was offered.
69#[allow(clippy::too_many_arguments)]
70#[allow(clippy::needless_range_loop)]
71pub fn try_export_collada_from_meshes(
72 positions: &[f32],
73 normals: &[f32],
74 indices: &[u32],
75 vertex_counts: &[u32],
76 index_counts: &[u32],
77 colors: &[f32],
78 origins: &[f64],
79) -> Result<Vec<u8>, ExportError> {
80 // Gate every float BEFORE any of it is read. `<float_array>` is `xs:float`,
81 // which has no lexical form matching Rust's `inf`/`-inf`, and the AABB
82 // re-centring below spreads a single non-finite vertex over every other
83 // vertex in the document. See `crate::mesh_input`.
84 let scrubbed = crate::mesh_input::scrub_nonfinite(positions, normals, colors, origins);
85 let (positions, normals, colors, origins) = (
86 &*scrubbed.positions,
87 &*scrubbed.normals,
88 &*scrubbed.colors,
89 &*scrubbed.origins,
90 );
91 // Concatenated Z-up vertex buffers (one shared POSITION + NORMAL source) and,
92 // per material, the triangle indices into that shared buffer. Positions are
93 // accumulated in f64 so `world = origin + position` and the subsequent AABB
94 // re-centering keep full precision at georef scale; the buffer is downcast to
95 // f32 only once, after centering, just before serialisation.
96 let mut pos: Vec<f64> = Vec::new();
97 let mut nrm: Vec<f32> = Vec::new();
98 let mut mat_colors: Vec<[f32; 4]> = Vec::new();
99 let mut mat_tris: Vec<Vec<u32>> = Vec::new();
100 let mut mat_map: HashMap<(i32, i32, i32, i32), usize> = HashMap::new();
101 // Global vertex dedup: quantised (position, normal) → index into pos/nrm. Merges
102 // identical vertices within and across meshes so a non-indexed IFC mesh (per-face
103 // vertices) doesn't blow past Google Earth's render limits.
104 let mut dedup: HashMap<[i64; 6], u32> = HashMap::new();
105
106 let n = vertex_counts.len();
107 let mut vbase = 0usize; // running vertex offset into the flat input
108 let mut ibase = 0usize; // running index offset into the flat input
109 for i in 0..n {
110 let vc = vertex_counts[i] as usize;
111 // A missing `index_counts` slot or a count running past its buffer is
112 // a caller bug, and the GLB twin refuses both; reading the slot as 0
113 // or stopping here instead shipped a document with that mesh (or
114 // every later one) missing as success.
115 let Some(&ic) = index_counts.get(i) else {
116 return Err(ExportError::MalformedMeshInput {
117 detail: format!(
118 "`index_counts` has {} entries but `vertex_counts` declares {n} meshes",
119 index_counts.len()
120 ),
121 });
122 };
123 let ic = ic as usize;
124 // `u64` like the GLB twin: on wasm32 an absurd u32 count times 3 wraps
125 // `usize` past this check and the slices below.
126 if (vbase as u64 + vc as u64) * 3 > positions.len() as u64
127 || ibase as u64 + ic as u64 > indices.len() as u64
128 {
129 return Err(ExportError::MalformedMeshInput {
130 detail: format!(
131 "mesh {i} declares {vc} vertices and {ic} indices but `positions` has {} \
132 floats and `indices` has {} entries at offsets {vbase}/{ibase}",
133 positions.len(),
134 indices.len()
135 ),
136 });
137 }
138 let pslice = &positions[vbase * 3..(vbase + vc) * 3];
139 let nslice: &[f32] = if normals.len() >= (vbase + vc) * 3 {
140 &normals[vbase * 3..(vbase + vc) * 3]
141 } else {
142 &[]
143 };
144 let islice = &indices[ibase..ibase + ic];
145 // The GLB twin refuses a partial triangle and an index outside its mesh
146 // through the same predicate; dropping them here shipped the document
147 // with those faces missing as success.
148 crate::mesh_input::check_index_block(i, islice, vertex_counts[i])?;
149 let color = [
150 colors.get(i * 4).copied().unwrap_or(0.8),
151 colors.get(i * 4 + 1).copied().unwrap_or(0.8),
152 colors.get(i * 4 + 2).copied().unwrap_or(0.8),
153 colors.get(i * 4 + 3).copied().unwrap_or(1.0),
154 ];
155 let origin = [
156 origins.get(i * 3).copied().unwrap_or(0.0),
157 origins.get(i * 3 + 1).copied().unwrap_or(0.0),
158 origins.get(i * 3 + 2).copied().unwrap_or(0.0),
159 ];
160
161 // Skip degenerate meshes (mirrors the glTF `view_ok` guard).
162 if islice.is_empty() || pslice.len() < 9 || !pslice.len().is_multiple_of(3) {
163 vbase += vc;
164 ibase += ic;
165 continue;
166 }
167
168 // Bake world = origin + position into Z-up, deduplicating by (position,
169 // normal). The world position is accumulated in f64 so a large-georef
170 // coordinate is not quantised by an f32 downcast before centering. `l2g`
171 // maps this mesh's local vertex index → the shared deduped index. A hard
172 // edge keeps distinct normals, so its vertices are NOT merged → flat shading
173 // is preserved; only redundant same-position-same-normal vertices collapse.
174 let has_normals = nslice.len() == pslice.len();
175 let mut l2g = vec![0u32; vc];
176 for vi in 0..vc {
177 let p = &pslice[vi * 3..vi * 3 + 3];
178 let zp = to_zup(
179 p[0] as f64 + origin[0],
180 p[1] as f64 + origin[1],
181 p[2] as f64 + origin[2],
182 );
183 // Normals are unit-scale direction vectors; f32 carries them losslessly.
184 let zn = if has_normals {
185 let nv = &nslice[vi * 3..vi * 3 + 3];
186 let z = to_zup(nv[0] as f64, nv[1] as f64, nv[2] as f64);
187 [z[0] as f32, z[1] as f32, z[2] as f32]
188 } else {
189 [0.0, 0.0, 1.0]
190 };
191 l2g[vi] = *dedup.entry(vert_key(zp, zn)).or_insert_with(|| {
192 let g = (pos.len() / 3) as u32;
193 pos.extend_from_slice(&zp);
194 nrm.extend_from_slice(&zn);
195 g
196 });
197 }
198
199 let mi = *mat_map.entry(color_key(color)).or_insert_with(|| {
200 mat_colors.push(color);
201 mat_tris.push(Vec::new());
202 mat_colors.len() - 1
203 });
204 // `check_index_block` established whole, in-range triangles. Remap each to
205 // its deduped global index, dropping any triangle that dedup collapsed to
206 // zero area (two corners merged).
207 for tri in islice.chunks_exact(3) {
208 let (a, b, c) = (l2g[tri[0] as usize], l2g[tri[1] as usize], l2g[tri[2] as usize]);
209 if a != b && b != c && a != c {
210 mat_tris[mi].extend_from_slice(&[a, b, c]);
211 }
212 }
213
214 vbase += vc;
215 ibase += ic;
216 }
217
218 // Read AFTER every drop rule above has run: a degenerate-only mesh registers
219 // its material and then loses its triangle, so the material count cannot
220 // see this.
221 if mat_tris.iter().all(Vec::is_empty) {
222 return Err(ExportError::NoRenderGeometry);
223 }
224
225 // Center the model on its horizontal (X,Y) AABB centre so the .dae origin
226 // coincides with the geometry centre. The KMZ <Model> pins the .dae origin to
227 // <Location>, and that lat/lon is computed for the geometry's AABB centre (the
228 // viewer's reproject adds the model centre to the MapConversion eastings/northings).
229 // Without this the model lands offset by however far its geometry sits from the
230 // local/survey origin — e.g. a CH1903+/LV95 model whose structure is 200 m from
231 // the project origin appeared ~250 m away in Google Earth (#1427). Z is left alone
232 // so clampToGround rests project-zero on the terrain (foundations below, frame above).
233 if pos.len() >= 3 {
234 let mut min_x = f64::INFINITY;
235 let mut max_x = f64::NEG_INFINITY;
236 let mut min_y = f64::INFINITY;
237 let mut max_y = f64::NEG_INFINITY;
238 for v in pos.chunks_exact(3) {
239 min_x = min_x.min(v[0]);
240 max_x = max_x.max(v[0]);
241 min_y = min_y.min(v[1]);
242 max_y = max_y.max(v[1]);
243 }
244 let cx = (min_x + max_x) * 0.5;
245 let cy = (min_y + max_y) * 0.5;
246 for v in pos.chunks_exact_mut(3) {
247 v[0] -= cx;
248 v[1] -= cy;
249 }
250 }
251
252 // Downcast the centered (small, origin-local) coordinates to f32 only now — the
253 // accumulation + AABB centering above ran in f64 to survive georef magnitudes.
254 let pos_f32: Vec<f32> = pos.iter().map(|&c| c as f32).collect();
255 Ok(write_dae(&pos_f32, &nrm, &mat_colors, &mat_tris))
256}
257
258#[cfg(test)]
259#[path = "collada_tests.rs"]
260mod tests;