ifc_lite_export/
kmz.rs

1// SPDX-License-Identifier: MPL-2.0
2//! **KMZ** (Google Earth) exporter — a ZIP archive of `doc.kml` + `model.glb` so
3//! Google Earth can place a georeferenced 3D model at its real-world lat/lon/altitude.
4//!
5//! Ports `apps/viewer/src/lib/geo/kmz-exporter.ts`. The GLB is produced upstream by
6//! the Rust GLB exporter; this module computes the KML placement (incl. the IFC
7//! grid-north → KML heading conversion) and packs the archive.
8//!
9//! The archive uses the ZIP **stored** (uncompressed) method, written by hand so the
10//! default/wasm build pulls in no zip/deflate dependency (keeping the wasm bundle
11//! lean). KMZ readers accept stored entries; the trade-off is a larger file than a
12//! deflated archive, acceptable for this infrequent georef export.
13
14// Keep this declaration before item-level attributes. The revert oracle maps
15// sibling Rust tests from this declaration, and a declaration at the foot of a
16// long module can otherwise inherit an unrelated intervening `#[cfg(...)]`.
17#[cfg(test)]
18#[path = "kmz_tests.rs"]
19mod tests;
20
21/// KML `<altitudeMode>` — how Google Earth interprets the model's `altitude`.
22#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
23pub enum AltitudeMode {
24    /// IGNORE `altitude`; rest the model origin on the terrain, following the
25    /// ground. The robust default for a building that should sit on the ground:
26    /// it can never float and is immune to a wrong / zero / double-counted
27    /// `IfcMapConversion.OrthogonalHeight` (#1427). KML's own default mode.
28    #[default]
29    ClampToGround,
30    /// `altitude` is metres ABOVE the terrain directly below the origin.
31    RelativeToGround,
32    /// `altitude` is metres above mean sea level, independent of terrain.
33    Absolute,
34}
35
36impl AltitudeMode {
37    fn as_kml(self) -> &'static str {
38        match self {
39            AltitudeMode::ClampToGround => "clampToGround",
40            AltitudeMode::RelativeToGround => "relativeToGround",
41            AltitudeMode::Absolute => "absolute",
42        }
43    }
44}
45
46/// Options for KMZ export.
47///
48/// Derives `Default` (matching peer `*Options` structs) so callers can spread
49/// `..Default::default()` and stay source-compatible as fields are added.
50#[derive(Default)]
51pub struct KmzOptions {
52    /// WGS84 latitude of the model origin (degrees).
53    pub latitude: f64,
54    /// WGS84 longitude of the model origin (degrees).
55    pub longitude: f64,
56    /// Orthogonal height / elevation in metres. Ignored by Google Earth when
57    /// `altitude_mode` is `ClampToGround` (the default).
58    pub altitude: f64,
59    /// How Google Earth places the model vertically. Defaults to `ClampToGround`
60    /// so the model rests on the terrain instead of floating at its MSL elevation
61    /// (the `relativeToGround` + OrthogonalHeight bug — #1427).
62    pub altitude_mode: AltitudeMode,
63    /// `IfcMapConversion` `XAxisAbscissa` (grid-north X component). When either axis
64    /// component is absent the heading is `0` (local north == true north).
65    pub x_axis_abscissa: Option<f64>,
66    /// `IfcMapConversion` `XAxisOrdinate` (grid-north Y component).
67    pub x_axis_ordinate: Option<f64>,
68    /// Placemark display name (defaults to `IFC Model`).
69    pub name: Option<String>,
70}
71
72/// Convert the IFC angle-to-grid-north (counter-clockwise from map east, via the
73/// `IfcMapConversion` X-axis abscissa/ordinate) into a KML `<Model><Orientation><heading>`,
74/// a clockwise **rotation** of a model whose local X-axis starts pointing east. Returns `0`
75/// when either component is absent. KML heading is not a compass bearing: at `heading = 0`
76/// the X-axis already points east (KML's baseline), and rotating clockwise by `heading` moves
77/// it to true bearing `90 + heading`, so reaching bearing `B` takes `B - 90` (mod 360).
78pub fn ifc_angle_to_kml_heading(x_abscissa: Option<f64>, x_ordinate: Option<f64>) -> f64 {
79    match (x_abscissa, x_ordinate) {
80        // A zero-length axis is degenerate (atan2(0,0) = 0 would otherwise map to a
81        // spurious rotation); treat it like a missing axis → no rotation.
82        (Some(x), Some(y)) if x == 0.0 && y == 0.0 => 0.0,
83        (Some(x), Some(y)) => {
84            let angle_from_east_ccw = y.atan2(x).to_degrees();
85            // bearing = 90 - angle (CCW-from-east → CW-from-north); heading = bearing - 90
86            // = -angle, normalized to [0, 360). `+ 0.0` folds a resulting -0.0 (e.g. when
87            // angle is exactly 0) back to +0.0 so it doesn't render as "<heading>-0</heading>".
88            (-angle_from_east_ccw).rem_euclid(360.0) + 0.0
89        }
90        _ => 0.0,
91    }
92}
93
94fn build_kml(opts: &KmzOptions, heading: f64, model_href: &str) -> String {
95    let name = crate::xml_text::escape(opts.name.as_deref().unwrap_or("IFC Model"));
96    format!(
97        r#"<?xml version="1.0" encoding="UTF-8"?>
98<kml xmlns="http://www.opengis.net/kml/2.2">
99  <Document>
100    <name>{name}</name>
101    <Placemark>
102      <name>{name}</name>
103      <Model id="model">
104        <altitudeMode>{altitude_mode}</altitudeMode>
105        <Location>
106          <longitude>{lon}</longitude>
107          <latitude>{lat}</latitude>
108          <altitude>{alt}</altitude>
109        </Location>
110        <Orientation>
111          <heading>{heading}</heading>
112          <tilt>0</tilt>
113          <roll>0</roll>
114        </Orientation>
115        <Scale>
116          <x>1</x>
117          <y>1</y>
118          <z>1</z>
119        </Scale>
120        <Link>
121          <href>{href}</href>
122        </Link>
123      </Model>
124    </Placemark>
125  </Document>
126</kml>"#,
127        name = name,
128        altitude_mode = opts.altitude_mode.as_kml(),
129        lon = opts.longitude,
130        lat = opts.latitude,
131        alt = opts.altitude,
132        heading = heading,
133        href = model_href,
134    )
135}
136
137/// Build a Google-Earth-ready KMZ (`doc.kml` + `model.dae`) directly from the
138/// viewer's already-produced (Y-up) meshes, the working path (#1427). The model
139/// is embedded as **COLLADA** (the only `<Model>` format Google Earth loads), with
140/// emission-lit, double-sided materials and `clampToGround` placement. Mesh arrays
141/// match [`crate::try_export_collada_from_meshes`] / `export_glb_from_meshes`.
142///
143/// Fails closed when the embedded COLLADA would carry no geometry: the archive
144/// this used to ship around a schema-invalid document was read as success by
145/// every caller. See [`crate::try_export_collada_from_meshes`] for the two
146/// inputs that reach that state.
147#[allow(clippy::too_many_arguments)]
148pub fn try_export_kmz_collada_from_meshes(
149    positions: &[f32],
150    normals: &[f32],
151    indices: &[u32],
152    vertex_counts: &[u32],
153    index_counts: &[u32],
154    colors: &[f32],
155    origins: &[f64],
156    opts: &KmzOptions,
157) -> Result<Vec<u8>, crate::error::ExportError> {
158    let dae = crate::try_export_collada_from_meshes(
159        positions,
160        normals,
161        indices,
162        vertex_counts,
163        index_counts,
164        colors,
165        origins,
166    )?;
167    try_pack_kmz(&dae, "model.dae", opts)
168}
169
170const ZIP_LOCAL_HEADER_LEN: u64 = 30;
171const ZIP_CENTRAL_HEADER_LEN: u64 = 46;
172const ZIP_END_RECORD_LEN: u64 = 22;
173
174fn zip32_error(detail: impl Into<String>) -> crate::error::ExportError {
175    crate::error::ExportError::Serialization {
176        stage: "KMZ ZIP32",
177        detail: detail.into(),
178    }
179}
180
181/// Validate the complete stored-ZIP layout using wide arithmetic before any
182/// header field is narrowed. Kept separate from allocation so boundary tests
183/// can exercise multi-gigabyte sizes without constructing multi-gigabyte
184/// buffers.
185fn validate_zip32_layout(entries: &[(&str, u64)]) -> Result<u64, crate::error::ExportError> {
186    if entries.len() > u16::MAX as usize {
187        return Err(zip32_error(format!(
188            "{} entries exceed ZIP32's {}-entry limit",
189            entries.len(),
190            u16::MAX
191        )));
192    }
193
194    let mut local_size = 0u64;
195    let mut central_size = 0u64;
196    for (name, data_size) in entries {
197        let name_size = u64::try_from(name.len()).map_err(|_| zip32_error("entry name length overflow"))?;
198        if name_size > u16::MAX as u64 {
199            return Err(zip32_error(format!("entry name is {name_size} bytes, over ZIP32's 65535-byte limit")));
200        }
201        if *data_size > u32::MAX as u64 {
202            return Err(zip32_error(format!(
203                "entry {name:?} is {data_size} bytes, over ZIP32's 4 GiB entry limit"
204            )));
205        }
206        if local_size > u32::MAX as u64 {
207            return Err(zip32_error(format!(
208                "entry {name:?} starts at offset {local_size}, over ZIP32's 32-bit offset limit"
209            )));
210        }
211        local_size = local_size
212            .checked_add(ZIP_LOCAL_HEADER_LEN)
213            .and_then(|size| size.checked_add(name_size))
214            .and_then(|size| size.checked_add(*data_size))
215            .ok_or_else(|| zip32_error("local-entry layout overflow"))?;
216        central_size = central_size
217            .checked_add(ZIP_CENTRAL_HEADER_LEN)
218            .and_then(|size| size.checked_add(name_size))
219            .ok_or_else(|| zip32_error("central-directory layout overflow"))?;
220    }
221
222    if local_size > u32::MAX as u64 {
223        return Err(zip32_error(format!(
224            "central directory starts at offset {local_size}, over ZIP32's 32-bit offset limit"
225        )));
226    }
227    if central_size > u32::MAX as u64 {
228        return Err(zip32_error(format!(
229            "central directory is {central_size} bytes, over ZIP32's 32-bit size limit"
230        )));
231    }
232    let total = local_size
233        .checked_add(central_size)
234        .and_then(|size| size.checked_add(ZIP_END_RECORD_LEN))
235        .ok_or_else(|| zip32_error("archive layout overflow"))?;
236    if total > u32::MAX as u64 {
237        return Err(zip32_error(format!(
238            "archive is {total} bytes, over this ZIP32 writer's 4 GiB limit"
239        )));
240    }
241    Ok(total)
242}
243
244fn try_pack_kmz(
245    model: &[u8],
246    href: &str,
247    opts: &KmzOptions,
248) -> Result<Vec<u8>, crate::error::ExportError> {
249    let heading = ifc_angle_to_kml_heading(opts.x_axis_abscissa, opts.x_axis_ordinate);
250    let kml = build_kml(opts, heading, href);
251    validate_zip32_layout(&[
252        ("doc.kml", kml.len() as u64),
253        (href, model.len() as u64),
254    ])?;
255
256    let mut zip = StoredZip::new();
257    zip.add("doc.kml", kml.as_bytes());
258    zip.add(href, model);
259    Ok(zip.finish())
260}
261
262// ── Minimal stored-ZIP writer ──────────────────────────────────────────────────
263
264/// A bare-bones ZIP writer that stores entries uncompressed (method 0). Enough for
265/// KMZ; avoids a zip/deflate crate dependency in the default/wasm build.
266struct StoredZip {
267    out: Vec<u8>,
268    central: Vec<u8>,
269    count: u16,
270}
271
272// Fixed DOS timestamp (1980-01-01 00:00:00) so archives are byte-deterministic
273// (and because wall-clock time is unavailable on wasm).
274const DOS_TIME: u16 = 0;
275const DOS_DATE: u16 = 0x0021;
276
277impl StoredZip {
278    fn new() -> Self {
279        StoredZip { out: Vec::new(), central: Vec::new(), count: 0 }
280    }
281
282    fn add(&mut self, name: &str, data: &[u8]) {
283        let crc = crc32(data);
284        let offset = self.out.len() as u32;
285        let name_bytes = name.as_bytes();
286        let size = data.len() as u32;
287
288        // Local file header.
289        self.out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
290        self.out.extend_from_slice(&20u16.to_le_bytes()); // version needed
291        self.out.extend_from_slice(&0u16.to_le_bytes()); // flags
292        self.out.extend_from_slice(&0u16.to_le_bytes()); // method = stored
293        self.out.extend_from_slice(&DOS_TIME.to_le_bytes());
294        self.out.extend_from_slice(&DOS_DATE.to_le_bytes());
295        self.out.extend_from_slice(&crc.to_le_bytes());
296        self.out.extend_from_slice(&size.to_le_bytes()); // compressed size
297        self.out.extend_from_slice(&size.to_le_bytes()); // uncompressed size
298        self.out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
299        self.out.extend_from_slice(&0u16.to_le_bytes()); // extra length
300        self.out.extend_from_slice(name_bytes);
301        self.out.extend_from_slice(data);
302
303        // Central directory record.
304        self.central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
305        self.central.extend_from_slice(&20u16.to_le_bytes()); // version made by
306        self.central.extend_from_slice(&20u16.to_le_bytes()); // version needed
307        self.central.extend_from_slice(&0u16.to_le_bytes()); // flags
308        self.central.extend_from_slice(&0u16.to_le_bytes()); // method
309        self.central.extend_from_slice(&DOS_TIME.to_le_bytes());
310        self.central.extend_from_slice(&DOS_DATE.to_le_bytes());
311        self.central.extend_from_slice(&crc.to_le_bytes());
312        self.central.extend_from_slice(&size.to_le_bytes());
313        self.central.extend_from_slice(&size.to_le_bytes());
314        self.central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
315        self.central.extend_from_slice(&0u16.to_le_bytes()); // extra length
316        self.central.extend_from_slice(&0u16.to_le_bytes()); // comment length
317        self.central.extend_from_slice(&0u16.to_le_bytes()); // disk number start
318        self.central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
319        self.central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
320        self.central.extend_from_slice(&offset.to_le_bytes());
321        self.central.extend_from_slice(name_bytes);
322
323        self.count += 1;
324    }
325
326    fn finish(mut self) -> Vec<u8> {
327        let cd_offset = self.out.len() as u32;
328        let cd_size = self.central.len() as u32;
329        self.out.extend_from_slice(&self.central);
330
331        // End of central directory record.
332        self.out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
333        self.out.extend_from_slice(&0u16.to_le_bytes()); // disk number
334        self.out.extend_from_slice(&0u16.to_le_bytes()); // cd start disk
335        self.out.extend_from_slice(&self.count.to_le_bytes()); // entries this disk
336        self.out.extend_from_slice(&self.count.to_le_bytes()); // total entries
337        self.out.extend_from_slice(&cd_size.to_le_bytes());
338        self.out.extend_from_slice(&cd_offset.to_le_bytes());
339        self.out.extend_from_slice(&0u16.to_le_bytes()); // comment length
340        self.out
341    }
342}
343
344/// CRC-32 (IEEE 802.3, polynomial 0xEDB88320) — the checksum ZIP entries require.
345fn crc32(data: &[u8]) -> u32 {
346    let mut crc = 0xFFFF_FFFFu32;
347    for &byte in data {
348        crc ^= byte as u32;
349        for _ in 0..8 {
350            let mask = (crc & 1).wrapping_neg();
351            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
352        }
353    }
354    !crc
355}