ifc_lite_geometry/router/transforms/
mod.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//! Placement and transformation: axis placement parsing, coordinate transforms, RTC offset.
6
7mod grid;
8mod grid_frame;
9mod linear;
10pub(crate) mod mapped;
11mod mesh_world;
12pub(crate) use mesh_world::LARGE_MAPPED_ORIGIN_M;
13pub(crate) mod operator;
14
15mod parsers;
16mod walk;
17
18#[cfg(test)]
19#[path = "placement_depth_tests.rs"]
20mod placement_depth_tests;
21
22use super::GeometryRouter;
23use crate::{Mesh, Result, SubMeshCollection};
24use ifc_lite_core::{DecodedEntity, EntityDecoder};
25use nalgebra::Matrix4;
26
27static LOCAL_FRAME_OVERRIDE: std::sync::atomic::AtomicI8 = std::sync::atomic::AtomicI8::new(-1);
28
29/// Test/harness-only: force the target/env fallback on/off, or `None` for the
30/// target default. A router constructed with an explicit frame policy does not
31/// consult this process-global hook. Mirrors `rect_fast::param_set_enabled_override`. The
32/// mesh-output determinism manifest uses it to run native and wasm with the
33/// SAME flag state (wasm defaults ON, native defaults OFF below), so the two
34/// targets' outputs are comparable byte-for-byte.
35pub fn local_frame_set_enabled_override(v: Option<bool>) {
36    LOCAL_FRAME_OVERRIDE.store(
37        match v {
38            None => -1,
39            Some(false) => 0,
40            Some(true) => 1,
41        },
42        std::sync::atomic::Ordering::Relaxed,
43    );
44}
45
46/// Whether per-element local-frame vertex precision is enabled.
47///
48/// When ON, `transform_mesh_world` stores positions relative to a per-element
49/// f64 `origin` (so f32 coords stay element-small and never collapse to
50/// degenerate fans at building/georef scale), and the void CSG runs in that same
51/// local frame. Consumers reconstruct world = `MeshData.origin` + position.
52/// Default is ON for wasm (the precision-critical viewer path, whose renderer
53/// consumes `origin`) and OFF for native, where `IFC_LITE_LOCAL_FRAME=1` opts
54/// in. Env/cfg default read once and cached. An explicit per-router policy takes
55/// precedence; otherwise [`local_frame_set_enabled_override`] selects this fallback.
56pub(crate) fn local_frame_enabled() -> bool {
57    match LOCAL_FRAME_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed) {
58        0 => return false,
59        1 => return true,
60        _ => {}
61    }
62    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
63    *ENABLED.get_or_init(|| {
64        // The viewer (wasm) is the precision-critical target: building-scale f32
65        // vertex storage collapses near-edges into fans, fixed by storing each
66        // element relative to its AABB-centre origin (the renderer reconstructs
67        // world = origin + position). Default ON for wasm. Native stays opt-in
68        // (env) so server output + the cross-arch determinism snapshots remain
69        // absolute-coord byte-identical; native consumers reconstruct from
70        // MeshData.origin when they want the local frame.
71        if cfg!(target_arch = "wasm32") {
72            true
73        } else {
74            std::env::var("IFC_LITE_LOCAL_FRAME").is_ok()
75        }
76    })
77}
78
79/// GPU-instancing capture is ALWAYS ON (no flag). The pipeline attaches
80/// [`crate::mesh::InstanceMeta`] (rep-identity + per-occurrence world transform)
81/// to every instanceable mesh so the collator can group occurrences into unique
82/// templates + per-instance transforms. This adds only metadata + an O(verts)
83/// content hash — the flat geometry output (positions/normals/indices) is
84/// unchanged, so determinism snapshots (which hash geometry, not `instance_meta`)
85/// stay byte-identical, and the instancing renderer path is data-driven, not
86/// toggled. (The old env flag never fired in wasm — `std::env` is empty there —
87/// which is exactly the browser path that needs it.)
88#[inline]
89pub(crate) fn instancing_enabled() -> bool {
90    true
91}
92
93/// Flatten a nalgebra `Matrix4<f64>` into a **column-major** `[f64; 16]` for the
94/// [`EntityDecoder`] placement-transform memo. `Matrix4::as_slice` is already
95/// column-major length 16, and [`Matrix4::from_column_slice`] reconstructs it
96/// bit-for-bit (an f64 round-trip is exact), so the memo is byte-identical to
97/// recomputing the transform. Distinct from [`mat4_to_row_major`], which is the
98/// row-major GPU-instancing convention.
99#[inline]
100fn mat4_to_col_array(m: &Matrix4<f64>) -> [f64; 16] {
101    *m.as_slice()
102        .first_chunk::<16>()
103        .expect("Matrix4<f64> as_slice is exactly 16 elements")
104}
105
106/// Flatten a column-major nalgebra `Matrix4<f64>` into a row-major `[f64; 16]`
107/// (the [`crate::mesh::InstanceMeta`] convention; matches a GPU mat4 fed row-by-row).
108pub(crate) fn mat4_to_row_major(m: &Matrix4<f64>) -> [f64; 16] {
109    [
110        m[(0, 0)], m[(0, 1)], m[(0, 2)], m[(0, 3)],
111        m[(1, 0)], m[(1, 1)], m[(1, 2)], m[(1, 3)],
112        m[(2, 0)], m[(2, 1)], m[(2, 2)], m[(2, 3)],
113        m[(3, 0)], m[(3, 1)], m[(3, 2)], m[(3, 3)],
114    ]
115}
116
117impl GeometryRouter {
118    pub(super) fn local_frame_enabled(&self) -> bool {
119        self.local_frame_enabled.unwrap_or_else(local_frame_enabled)
120    }
121
122    /// Apply local placement transformation to mesh
123    ///
124    /// Welds the source vertices FIRST (#4103). This is the last point at which
125    /// the vertices are still in the object frame, and the weld keys on raw f32
126    /// bits, so welding after the bake makes the merge depend on where the
127    /// element sits and leaves two occurrences of one representation with
128    /// different buffers. See `crate::mesh_weld`'s module doc.
129    pub(super) fn apply_placement(
130        &self,
131        element: &DecodedEntity,
132        decoder: &mut EntityDecoder,
133        mesh: &mut Mesh,
134    ) -> Result<()> {
135        crate::mesh_weld::weld_mesh(mesh);
136        let placement_attr = match element.get(5) {
137            Some(attr) if !attr.is_null() => attr,
138            _ => {
139                // No ObjectPlacement: the world frame IS the object frame, so
140                // run the ordinary world step with an identity placement. This
141                // both records the #1474 frame capture AND applies the model
142                // RTC shift / local-frame relativization exactly like every
143                // placed mesh — consumers (renderer, demesher session)
144                // reconstruct `true_world = origin + position + rtc` uniformly
145                // and must not special-case placement-less geometry.
146                self.transform_mesh_world(mesh, &Matrix4::identity());
147                return Ok(());
148            }
149        };
150
151        let placement = match decoder.resolve_ref(placement_attr)? {
152            Some(p) => p,
153            None => {
154                self.transform_mesh_world(mesh, &Matrix4::identity());
155                return Ok(());
156            }
157        };
158
159        let mut transform = self.get_placement_transform(&placement, decoder)?;
160        self.scale_transform(&mut transform);
161        // Instancing: record the full (scaled) world placement on the mesh's
162        // instance metadata BEFORE it is baked + RTC-folded by transform_mesh_world.
163        // Only fires when processing already marked this mesh instanceable (so the
164        // metadata exists); a no-op otherwise, keeping the flat path untouched.
165        if let Some(im) = mesh.instance_meta.as_mut() {
166            im.transform = mat4_to_row_major(&transform);
167        }
168        self.transform_mesh_world(mesh, &transform);
169        Ok(())
170    }
171
172    /// Apply the element's `ObjectPlacement` (scaled to metres) to every sub-mesh.
173    /// Placement is a rigid per-instance transform, kept OUT of the dedup cache so
174    /// instances of one shared geometry land at their own positions.
175    /// (Moved here from `processing.rs` — placement logic lives with the other
176    /// placement appliers, and `processing.rs` sits at its ratchet budget.)
177    ///
178    /// Welds each sub-mesh's source vertices first, for the reason
179    /// [`Self::apply_placement`] gives. Every per-style channel (plain, layered,
180    /// textured) converges here, so one loop covers them all, and the sub-mesh
181    /// weld carries `SubMesh::uvs` through the same remap.
182    pub(super) fn apply_submesh_placement(
183        &self,
184        sub_meshes: &mut SubMeshCollection,
185        element: &DecodedEntity,
186        decoder: &mut EntityDecoder,
187    ) -> Result<()> {
188        for sub in &mut sub_meshes.sub_meshes {
189            crate::mesh_weld::weld_sub_mesh(sub);
190        }
191        // ObjectPlacement translation is in file units (e.g. mm) but geometry is
192        // scaled to metres, so the transform MUST be scaled to match.
193        if let Some(placement_attr) = element.get(5) {
194            if !placement_attr.is_null() {
195                if let Some(placement) = decoder.resolve_ref(placement_attr)? {
196                    let mut transform = self.get_placement_transform(&placement, decoder)?;
197                    self.scale_transform(&mut transform);
198                    // Instancing: record the per-element world placement on EACH sub-mesh's
199                    // instance metadata BEFORE it is baked into the vertices, mirroring
200                    // `apply_placement` for the single-mesh path. Without this, the sub-mesh
201                    // path (every multi-item element — all the Tekla steel: beams, plates,
202                    // assemblies) leaves `instance_meta.transform` at the IDENTITY placeholder,
203                    // so `collate_refs` computes rel_k = m_k · m_ref⁻¹ = identity for every
204                    // occurrence and they all stack on the first one. The flat path was
205                    // always correct (placement IS baked into the vertices below), so the
206                    // dedup made it look like repeated geometry was "missing".
207                    if instancing_enabled() {
208                        let row_major = mat4_to_row_major(&transform);
209                        for sub in &mut sub_meshes.sub_meshes {
210                            if let Some(im) = sub.mesh.instance_meta.as_mut() {
211                                im.transform = row_major;
212                            }
213                        }
214                    }
215                    for sub in &mut sub_meshes.sub_meshes {
216                        self.transform_mesh_world(&mut sub.mesh, &transform);
217                    }
218                    return Ok(());
219                }
220            }
221        }
222        // No resolvable ObjectPlacement: run the ordinary world step with an
223        // identity placement — records the #1474 frame capture AND applies
224        // the model RTC shift / relativization, mirroring the same branch in
225        // `apply_placement` (single-mesh path).
226        for sub in &mut sub_meshes.sub_meshes {
227            self.transform_mesh_world(&mut sub.mesh, &Matrix4::identity());
228        }
229        Ok(())
230    }
231
232    /// Get placement transform from element without applying it
233    pub(super) fn get_placement_transform_from_element(
234        &self,
235        element: &DecodedEntity,
236        decoder: &mut EntityDecoder,
237    ) -> Result<Matrix4<f64>> {
238        Ok(self.get_placement_transform_from_element_with_status(element, decoder)?.0)
239    }
240
241    /// Return the placement and whether the shared depth guard truncated its
242    /// parent chain. Exact geometry descriptions must not report the partial
243    /// transform as a complete world placement.
244    pub(super) fn get_placement_transform_from_element_with_status(
245        &self,
246        element: &DecodedEntity,
247        decoder: &mut EntityDecoder,
248    ) -> Result<(Matrix4<f64>, bool)> {
249        // Get ObjectPlacement (attribute 5)
250        let placement_attr = match element.get(5) {
251            Some(attr) if !attr.is_null() => attr,
252            _ => return Ok((Matrix4::identity(), false)), // No placement
253        };
254
255        let placement = match decoder.resolve_ref(placement_attr)? {
256            Some(p) => p,
257            None => return Ok((Matrix4::identity(), false)),
258        };
259
260        let walk = self.get_placement_transform_with_depth(&placement, decoder, 0)?;
261        Ok((walk.transform, walk.truncated))
262    }
263
264    /// Exact counterpart to `resolve_scaled_placement`: refuses a world
265    /// placement when the shared reference-walk guard truncated its chain.
266    pub fn resolve_scaled_placement_strict(
267        &self,
268        element: &DecodedEntity,
269        decoder: &mut EntityDecoder,
270    ) -> Result<[f64; 16]> {
271        if let Some(attr) = element.get(5).filter(|attr| !attr.is_null()) {
272            if decoder.resolve_ref(attr)?.is_none() {
273                return Err(crate::Error::geometry(format!(
274                    "ObjectPlacement for #{} cannot be resolved",
275                    element.id
276                )));
277            }
278        }
279        let (mut transform, truncated) =
280            self.get_placement_transform_from_element_with_status(element, decoder)?;
281        if truncated {
282            return Err(crate::Error::geometry(format!(
283                "placement chain for #{} exceeded maximum depth",
284                element.id
285            )));
286        }
287        self.scale_transform(&mut transform);
288        let mut result = [0.0; 16];
289        result.copy_from_slice(transform.as_slice());
290        Ok(result)
291    }
292
293}