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;