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}