Skip to main content

ifc_lite_processing/
mesh_frame.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The frame serialized mesh vertices are expressed in: how it is chosen
6//! ([`MeshFrame::select`]) and how it is named on the wire
7//! ([`MeshCoordinateSpace`]).
8//!
9//! One selection, one home: the native pipeline (`processor/mod.rs`) and the
10//! browser pre-pass resolver (`stream_meta.rs`) both choose their frame
11//! through [`MeshFrame::select`], and the wire tag is spelled only by the
12//! `serde` attribute on [`MeshCoordinateSpace`].
13
14use ifc_lite_core::{EntityDecoder, RtcVerdict};
15use ifc_lite_geometry::GeometryRouter;
16use serde::{Deserialize, Serialize};
17
18/// Epsilon (metres) below which a placement translation is treated as identity.
19/// Avoids overriding a detected RTC anchor when `IfcSite` sits at the origin
20/// while the geometry itself carries large world coordinates.
21/// [`rotation_is_identity`] below uses the same epsilon on the rotation block.
22pub(crate) const PLACEMENT_IDENTITY_EPSILON: f64 = 1e-9;
23
24/// True when a column-major 4x4 matrix's 3x3 rotation block is (within
25/// [`PLACEMENT_IDENTITY_EPSILON`]) the identity — i.e. the placement it came
26/// from is a pure translation, contributing no rotation of its own.
27///
28/// A matrix shorter than 16 elements is treated conservatively as NOT
29/// identity (callers that gate a "safe to keep" decision on this should keep
30/// dropping rather than assume something about a shape they can't read).
31///
32/// Lives here, beside the epsilon and beside [`MeshFrame::rotate_into_frame`],
33/// because it is the condition under which a frame removes a rotation at all.
34/// Shared by `processor::site_local::apply_inverse_rotation_in_place` (skip
35/// the no-op rotation pass) and `element.rs`'s instancing/local-bounds guard
36/// (#4118: a pure translation site placement never rotates positions, so
37/// metadata captured before `convert_mesh_to_site_local` runs is never
38/// invalidated by it).
39#[inline]
40pub(crate) fn rotation_is_identity(column_major_matrix: &[f64]) -> bool {
41    if column_major_matrix.len() < 16 {
42        return false;
43    }
44    let r00 = column_major_matrix[0];
45    let r10 = column_major_matrix[1];
46    let r20 = column_major_matrix[2];
47    let r01 = column_major_matrix[4];
48    let r11 = column_major_matrix[5];
49    let r21 = column_major_matrix[6];
50    let r02 = column_major_matrix[8];
51    let r12 = column_major_matrix[9];
52    let r22 = column_major_matrix[10];
53
54    (r00 - 1.0).abs() < PLACEMENT_IDENTITY_EPSILON
55        && r10.abs() < PLACEMENT_IDENTITY_EPSILON
56        && r20.abs() < PLACEMENT_IDENTITY_EPSILON
57        && r01.abs() < PLACEMENT_IDENTITY_EPSILON
58        && (r11 - 1.0).abs() < PLACEMENT_IDENTITY_EPSILON
59        && r21.abs() < PLACEMENT_IDENTITY_EPSILON
60        && r02.abs() < PLACEMENT_IDENTITY_EPSILON
61        && r12.abs() < PLACEMENT_IDENTITY_EPSILON
62        && (r22 - 1.0).abs() < PLACEMENT_IDENTITY_EPSILON
63}
64
65#[inline]
66fn translation_is_nonidentity(t: (f64, f64, f64)) -> bool {
67    t.0.abs() > PLACEMENT_IDENTITY_EPSILON
68        || t.1.abs() > PLACEMENT_IDENTITY_EPSILON
69        || t.2.abs() > PLACEMENT_IDENTITY_EPSILON
70}
71
72/// The frame a pipeline meshes into: the translation it subtracts and, in the
73/// site tier, the rotation it removes.
74///
75/// Both pipelines build it with [`MeshFrame::select`]. The offset, the
76/// rotation, the needs-shift bit and the wire tag are read off the one value,
77/// so they cannot disagree with each other.
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub enum MeshFrame {
80    /// `IfcSite` has a non-identity translation: subtract it. Vertices land
81    /// relative to the site origin, small floats in a relatable frame. The
82    /// native pipeline also removes the site rotation
83    /// (`convert_mesh_to_site_local`) for this tier.
84    ///
85    /// Carries the whole site placement (column-major 4x4, metres), not just
86    /// the translation it subtracts, so the frame describes BOTH halves of
87    /// what the bake did: a baked point is `Rᵀ · (P − t)`. A consumer that
88    /// has to land in the same frame — the symbolic stream the server ships
89    /// beside these meshes (#4706) — reads the rotation through
90    /// [`MeshFrame::rotate_into_frame`] instead of fetching the site
91    /// placement itself and re-deriving the tier rule.
92    SiteLocal { placement: [f64; 16] },
93    /// `IfcSite` is identity or missing but the sampled geometry lives at
94    /// large world coordinates: subtract the detected anchor so f32 keeps its
95    /// precision. No rotation is removed.
96    ModelRtc { anchor: (f64, f64, f64) },
97    /// Neither anchor applies: subtract nothing.
98    RawIfc,
99}
100
101impl MeshFrame {
102    /// The three-tier selection.
103    ///
104    /// * `site_placement`: the `IfcSite` placement as a column-major 4x4 in
105    ///   metres (`GeometryRouter::resolve_scaled_placement`), `None` when the
106    ///   pipeline has no site tier (the browser pre-pass and the appearance
107    ///   authoring path mesh in world axes and pass `None` deliberately: see
108    ///   `stream_meta::resolve_stream_meta`). A matrix with fewer than 16
109    ///   elements is not a placement this can read, so it falls through to
110    ///   the detector rather than indexing past its end.
111    /// * `detected`: the RTC detector's verdict (see
112    ///   `GeometryRouter::detect_rtc_offset_for_file`). A `Large` verdict
113    ///   is honoured whatever its anchor's own magnitude: the placement-bounds
114    ///   fallback decides on the bbox corners and anchors on the centre, which
115    ///   can be inside 10 km while the coordinates are not. Only an anchor at
116    ///   the origin (nothing to subtract) falls through to `RawIfc`.
117    pub fn select(site_placement: Option<&[f64]>, detected: Option<RtcVerdict>) -> Self {
118        if let Some(matrix) = site_placement
119            .filter(|m| m.len() >= 16)
120            .filter(|m| translation_is_nonidentity((m[12], m[13], m[14])))
121        {
122            let mut placement = [0.0; 16];
123            placement.copy_from_slice(&matrix[..16]);
124            return Self::SiteLocal { placement };
125        }
126        match detected {
127            Some(RtcVerdict::Large { anchor }) if translation_is_nonidentity(anchor) => {
128                Self::ModelRtc { anchor }
129            }
130            _ => Self::RawIfc,
131        }
132    }
133
134    /// Frame for file-parsing consumers: grid/alignment overlays and the
135    /// browser symbolic stream (#4665). It uses the same file-scoped sample
136    /// window as the browser meshes, so their anchors agree (#4611).
137    ///
138    /// NOT for a consumer that ran the native pipeline over the same bytes:
139    /// there is a site tier there, and this has none, so the two frames
140    /// disagree on every translated `IfcSite`. Such a caller passes the frame
141    /// its meshes were baked in (`ProcessingResult::frame`, #4706).
142    ///
143    /// Also NOT guaranteed to match the STREAMING browser pre-pass, which
144    /// samples only the indexed head when it emits mid-scan. A model whose
145    /// head does not represent its tail can therefore differ; closing that
146    /// requires handing the emitted frame to the overlay APIs (#4611).
147    pub fn for_overlay(router: &GeometryRouter, content: &[u8], decoder: &mut EntityDecoder) -> Self {
148        Self::select(None, router.detect_rtc_offset_for_file(content, decoder))
149    }
150
151    /// The translation the router subtracts from every world vertex before
152    /// the f32 cast; `(0,0,0)` for [`MeshFrame::RawIfc`].
153    #[inline]
154    pub fn rtc_offset(self) -> (f64, f64, f64) {
155        match self {
156            Self::SiteLocal { placement } => (placement[12], placement[13], placement[14]),
157            Self::ModelRtc { anchor } => anchor,
158            Self::RawIfc => (0.0, 0.0, 0.0),
159        }
160    }
161
162    /// An IFC world DIRECTION expressed in this frame's own axes: `Rᵀ · v`.
163    ///
164    /// The other half of the frame, beside [`MeshFrame::rtc_offset`]. A world
165    /// POINT lands at `rotate_into_frame(p − rtc_offset)`, which is exactly
166    /// what `processor::site_local::convert_mesh_to_site_local` bakes into
167    /// the vertices for the [`MeshFrame::SiteLocal`] tier — same `Rᵀ`, and
168    /// applied under the same [`rotation_is_identity`] condition, so a
169    /// consumer that re-bases with this cannot disagree with the meshes.
170    ///
171    /// The identity for [`MeshFrame::ModelRtc`] and [`MeshFrame::RawIfc`]:
172    /// neither tier removes a rotation.
173    #[inline]
174    pub fn rotate_into_frame(self, v: [f64; 3]) -> [f64; 3] {
175        match self {
176            Self::SiteLocal { placement } if !rotation_is_identity(&placement) => [
177                placement[0] * v[0] + placement[1] * v[1] + placement[2] * v[2],
178                placement[4] * v[0] + placement[5] * v[1] + placement[6] * v[2],
179                placement[8] * v[0] + placement[9] * v[1] + placement[10] * v[2],
180            ],
181            _ => v,
182        }
183    }
184
185    /// True when the frame subtracts anything at all.
186    #[inline]
187    pub fn needs_shift(self) -> bool {
188        !matches!(self, Self::RawIfc)
189    }
190
191    /// The wire tag for this frame.
192    #[inline]
193    pub fn coordinate_space(self) -> MeshCoordinateSpace {
194        match self {
195            Self::SiteLocal { .. } => MeshCoordinateSpace::SiteLocal,
196            Self::ModelRtc { .. } => MeshCoordinateSpace::ModelRtc,
197            Self::RawIfc => MeshCoordinateSpace::RawIfc,
198        }
199    }
200}
201
202/// Which frame serialized mesh vertices are expressed in.
203///
204/// The string form is the wire contract (`ParseResponse::mesh_coordinate_space`,
205/// the server's stream `Complete` event, the FFI JSON, the Parquet metadata
206/// headers): `site_local`, `model_rtc`, `raw_ifc`, spelled by the `serde`
207/// attribute below.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum MeshCoordinateSpace {
211    /// Vertices are relative to the `IfcSite` placement: its translation was
212    /// subtracted and its rotation removed (small floats in a meaningful,
213    /// relatable frame, useful for coordination).
214    SiteLocal,
215    /// `IfcSite` is identity (or missing) but the geometry lives at large
216    /// world coordinates: a detected model-level anchor was subtracted so f32
217    /// keeps its precision. No rotation was removed.
218    ModelRtc,
219    /// Neither anchor applies: nothing was subtracted, vertices are in raw IFC
220    /// world space.
221    RawIfc,
222}
223
224// Regression tests for #4611 (one frame selection for native and browser).
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    const FAR: (f64, f64, f64) = (2_679_062.0, 1_247_992.0, 532.0);
230    const LARGE_FAR: RtcVerdict = RtcVerdict::Large { anchor: FAR };
231
232    /// A column-major 4x4 site placement: identity rotation, `t` translation.
233    fn site_at(t: (f64, f64, f64)) -> [f64; 16] {
234        let mut m = [0.0; 16];
235        m[0] = 1.0;
236        m[5] = 1.0;
237        m[10] = 1.0;
238        m[15] = 1.0;
239        m[12] = t.0;
240        m[13] = t.1;
241        m[14] = t.2;
242        m
243    }
244
245    /// The same, yawed `degrees` about Z.
246    fn site_at_yawed(t: (f64, f64, f64), degrees: f64) -> [f64; 16] {
247        let (s, c) = degrees.to_radians().sin_cos();
248        let mut m = site_at(t);
249        m[0] = c;
250        m[1] = s;
251        m[4] = -s;
252        m[5] = c;
253        m
254    }
255
256    /// The site tier wins whenever the site is translated at all, and it wins
257    /// over a detected anchor. Deleting the site arm of `select` sends the
258    /// first two cases to `ModelRtc`/`RawIfc`.
259    #[test]
260    fn a_translated_site_selects_site_local_over_everything() {
261        let placement = site_at((500.0, 0.0, 0.0));
262        let frame = MeshFrame::select(Some(&placement), Some(LARGE_FAR));
263        assert_eq!(frame, MeshFrame::SiteLocal { placement });
264        assert_eq!(frame.rtc_offset(), (500.0, 0.0, 0.0));
265        assert!(frame.needs_shift());
266        assert_eq!(frame.coordinate_space(), MeshCoordinateSpace::SiteLocal);
267        let tiny = site_at((0.0, 0.0, 1e-6));
268        assert_eq!(
269            MeshFrame::select(Some(&tiny), None),
270            MeshFrame::SiteLocal { placement: tiny }
271        );
272    }
273
274    /// An identity (or absent) site falls through to the detector's anchor.
275    #[test]
276    fn an_identity_site_falls_through_to_the_detected_anchor() {
277        let identity = site_at((0.0, 0.0, 0.0));
278        let sub_epsilon = site_at((1e-10, -1e-10, 0.0));
279        for site in [None, Some(&identity), Some(&sub_epsilon)] {
280            let frame = MeshFrame::select(site.map(|m| &m[..]), Some(LARGE_FAR));
281            assert_eq!(frame, MeshFrame::ModelRtc { anchor: FAR }, "site {site:?}");
282            assert_eq!(frame.rtc_offset(), FAR);
283            assert!(frame.needs_shift());
284            assert_eq!(frame.coordinate_space(), MeshCoordinateSpace::ModelRtc);
285        }
286    }
287
288    /// "Detector found nothing" and "detector found only small coordinates"
289    /// both mean raw IFC, with a zero offset and no shift. A frame that said
290    /// `needs_shift` with a zero offset cannot be built.
291    #[test]
292    fn no_site_and_no_anchor_is_raw_ifc_with_nothing_to_subtract() {
293        for detected in [None, Some(RtcVerdict::Small)] {
294            let frame = MeshFrame::select(None, detected);
295            assert_eq!(frame, MeshFrame::RawIfc, "detected {detected:?}");
296            assert_eq!(frame.rtc_offset(), (0.0, 0.0, 0.0));
297            assert!(!frame.needs_shift());
298            assert_eq!(frame.coordinate_space(), MeshCoordinateSpace::RawIfc);
299        }
300    }
301
302    /// #4643: a `Large` verdict whose anchor is inside 10 km (the bounds
303    /// fallback's bbox centre for a 2 to 15 km extent) is still subtracted;
304    /// judging the anchor's own magnitude cast 15 km coordinates straight to
305    /// f32. A `Large` verdict anchored at the origin has nothing to subtract.
306    /// Re-gating the anchor arm on `coord_is_large` fails the loop.
307    #[test]
308    fn a_large_verdict_with_a_sub_threshold_anchor_is_subtracted() {
309        for anchor in [(-5_000.0, 0.0, 0.0), (8_500.0, 0.0, 0.0), (0.0, 0.0, 10_000.0)] {
310            let frame = MeshFrame::select(None, Some(RtcVerdict::Large { anchor }));
311            assert_eq!(frame, MeshFrame::ModelRtc { anchor }, "{anchor:?}");
312            assert!(frame.needs_shift());
313        }
314        let at_origin = RtcVerdict::Large { anchor: (0.0, 0.0, 0.0) };
315        assert_eq!(MeshFrame::select(None, Some(at_origin)), MeshFrame::RawIfc);
316    }
317
318    /// Why `ifc_lite_ffi::normalize_to_site_local` could be deleted: it
319    /// subtracted the site translation from `raw_ifc` output whenever the site
320    /// sat more than 1 km from the origin, and that input cannot be produced.
321    /// Any site translation past the identity epsilon selects `SiteLocal`, so
322    /// `RawIfc` with a translated site is not a state the selector can emit.
323    /// Removing the `translation_is_nonidentity` filter on the site arm, or
324    /// the site arm itself, fails this.
325    #[test]
326    fn raw_ifc_is_never_selected_beside_a_translated_site() {
327        for translation in [
328            (1e-8, 0.0, 0.0),
329            (999.0, 0.0, 0.0),
330            (1_000.5, 0.0, 0.0),
331            (0.0, -1_500.0, 0.0),
332            FAR,
333        ] {
334            let placement = site_at(translation);
335            for detected in [None, Some(RtcVerdict::Small), Some(LARGE_FAR)] {
336                let frame = MeshFrame::select(Some(&placement), detected);
337                assert_ne!(frame, MeshFrame::RawIfc, "{translation:?} / {detected:?}");
338                assert_eq!(frame.coordinate_space(), MeshCoordinateSpace::SiteLocal);
339            }
340        }
341        assert_eq!(
342            MeshFrame::select(Some(&site_at((0.0, 0.0, 0.0))), None),
343            MeshFrame::RawIfc
344        );
345    }
346
347    /// The rotation half of the frame (#4706). `rotate_into_frame` must be
348    /// `Rᵀ` — the SAME inverse rotation `convert_mesh_to_site_local` applies
349    /// to the vertices — for the site tier, and the identity for the other
350    /// two, which remove no rotation. A site yawed 30 degrees maps its own
351    /// +X axis, `(cos30, sin30, 0)` in world, back onto `(1, 0, 0)`.
352    /// Transposing the matrix here (reading rows instead of columns) turns
353    /// the yaw the wrong way and fails on the sign of the second component.
354    #[test]
355    fn the_site_tier_undoes_its_own_yaw_and_the_others_rotate_nothing() {
356        let yawed = MeshFrame::select(Some(&site_at_yawed((500.0, 300.0, 0.0), 30.0)), None);
357        let (s, c) = 30.0f64.to_radians().sin_cos();
358        let back = yawed.rotate_into_frame([c, s, 0.0]);
359        for (i, want) in [1.0, 0.0, 0.0].iter().enumerate() {
360            assert!((back[i] - want).abs() < 1e-12, "axis {i}: {back:?}");
361        }
362        // A world point on the site origin lands ON the frame origin.
363        let offset = yawed.rtc_offset();
364        let at_origin = yawed.rotate_into_frame([
365            500.0 - offset.0,
366            300.0 - offset.1,
367            0.0 - offset.2,
368        ]);
369        assert_eq!(at_origin, [0.0, 0.0, 0.0]);
370
371        let v = [3.0, -7.0, 2.0];
372        let translated_only = MeshFrame::select(Some(&site_at((500.0, 300.0, 0.0))), None);
373        assert_eq!(translated_only.rotate_into_frame(v), v);
374        assert_eq!(
375            MeshFrame::select(None, Some(LARGE_FAR)).rotate_into_frame(v),
376            v
377        );
378        assert_eq!(MeshFrame::RawIfc.rotate_into_frame(v), v);
379    }
380
381    /// The wire contract. The three strings are what every consumer (the TS
382    /// server client, the FFI host, the Python binding) reads back, so a
383    /// renamed variant or a changed `rename_all` must fail here, not in a
384    /// downstream deserializer.
385    #[test]
386    fn wire_strings_are_the_documented_snake_case_tags() {
387        for (space, tag) in [
388            (MeshCoordinateSpace::SiteLocal, "\"site_local\""),
389            (MeshCoordinateSpace::ModelRtc, "\"model_rtc\""),
390            (MeshCoordinateSpace::RawIfc, "\"raw_ifc\""),
391        ] {
392            assert_eq!(serde_json::to_string(&space).unwrap(), tag);
393            assert_eq!(
394                serde_json::from_str::<MeshCoordinateSpace>(tag).unwrap(),
395                space
396            );
397        }
398        assert!(serde_json::from_str::<MeshCoordinateSpace>("\"SiteLocal\"").is_err());
399    }
400}