ifc-lite-geometry 9.3.0

Geometry processing and mesh generation for IFC models
Documentation
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

//! Placement and transformation: axis placement parsing, coordinate transforms, RTC offset.

mod grid;
mod linear;
pub(crate) mod mapped;
mod mesh_world;
pub(crate) mod operator;

/// `IfcCartesianPoint` at `attr_index` of `parent`, as a 3D point (Z defaults to
/// 0 for the 2D form). The router-free form of
/// `GeometryRouter::parse_cartesian_point`, shared with the 2D drawing extractor.
pub(crate) fn cartesian_point_at(
    parent: &DecodedEntity,
    decoder: &mut EntityDecoder,
    attr_index: usize,
) -> crate::Result<crate::Point3<f64>> {
    let attr = parent
        .get(attr_index)
        .ok_or_else(|| crate::Error::geometry("Missing cartesian point".to_string()))?;
    let entity = decoder
        .resolve_ref(attr)?
        .ok_or_else(|| crate::Error::geometry("Failed to resolve cartesian point".to_string()))?;
    if entity.ifc_type != IfcType::IfcCartesianPoint {
        return Err(crate::Error::geometry(format!(
            "Expected IfcCartesianPoint, got {}",
            entity.ifc_type
        )));
    }
    let coords = entity
        .get(0)
        .and_then(|a| a.as_list())
        .ok_or_else(|| crate::Error::geometry("Expected coordinate list".to_string()))?;
    Ok(crate::Point3::new(
        coords.first().and_then(|v| v.as_float()).unwrap_or(0.0),
        coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0),
        coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0),
    ))
}

mod parsers;
mod walk;

#[cfg(test)]
#[path = "placement_depth_tests.rs"]
mod placement_depth_tests;

use super::GeometryRouter;
use crate::{Mesh, Result, SubMeshCollection};
use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
use nalgebra::Matrix4;

static LOCAL_FRAME_OVERRIDE: std::sync::atomic::AtomicI8 = std::sync::atomic::AtomicI8::new(-1);

/// Test/harness-only: force [`local_frame_enabled`] on/off, or `None` for the
/// target default. Mirrors `rect_fast::param_set_enabled_override`. The
/// mesh-output determinism manifest uses it to run native and wasm with the
/// SAME flag state (wasm defaults ON, native defaults OFF below), so the two
/// targets' outputs are comparable byte-for-byte.
pub fn local_frame_set_enabled_override(v: Option<bool>) {
    LOCAL_FRAME_OVERRIDE.store(
        match v {
            None => -1,
            Some(false) => 0,
            Some(true) => 1,
        },
        std::sync::atomic::Ordering::Relaxed,
    );
}

/// Whether per-element local-frame vertex precision is enabled.
///
/// When ON, `transform_mesh_world` stores positions relative to a per-element
/// f64 `origin` (so f32 coords stay element-small and never collapse to
/// degenerate fans at building/georef scale), and the void CSG runs in that same
/// local frame. Consumers reconstruct world = `MeshData.origin` + position.
/// Default is ON for wasm (the precision-critical viewer path, whose renderer
/// consumes `origin`) and OFF for native, where `IFC_LITE_LOCAL_FRAME=1` opts
/// in. Env/cfg default read once and cached; the
/// [`local_frame_set_enabled_override`] hook takes precedence on every call.
pub(crate) fn local_frame_enabled() -> bool {
    match LOCAL_FRAME_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed) {
        0 => return false,
        1 => return true,
        _ => {}
    }
    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
    *ENABLED.get_or_init(|| {
        // The viewer (wasm) is the precision-critical target: building-scale f32
        // vertex storage collapses near-edges into fans, fixed by storing each
        // element relative to its AABB-centre origin (the renderer reconstructs
        // world = origin + position). Default ON for wasm. Native stays opt-in
        // (env) so server output + the cross-arch determinism snapshots remain
        // absolute-coord byte-identical; native consumers reconstruct from
        // MeshData.origin when they want the local frame.
        if cfg!(target_arch = "wasm32") {
            true
        } else {
            std::env::var("IFC_LITE_LOCAL_FRAME").is_ok()
        }
    })
}

/// GPU-instancing capture is ALWAYS ON (no flag). The pipeline attaches
/// [`crate::mesh::InstanceMeta`] (rep-identity + per-occurrence world transform)
/// to every instanceable mesh so the collator can group occurrences into unique
/// templates + per-instance transforms. This adds only metadata + an O(verts)
/// content hash — the flat geometry output (positions/normals/indices) is
/// unchanged, so determinism snapshots (which hash geometry, not `instance_meta`)
/// stay byte-identical, and the instancing renderer path is data-driven, not
/// toggled. (The old env flag never fired in wasm — `std::env` is empty there —
/// which is exactly the browser path that needs it.)
#[inline]
pub(crate) fn instancing_enabled() -> bool {
    true
}

/// Flatten a nalgebra `Matrix4<f64>` into a **column-major** `[f64; 16]` for the
/// [`EntityDecoder`] placement-transform memo. `Matrix4::as_slice` is already
/// column-major length 16, and [`Matrix4::from_column_slice`] reconstructs it
/// bit-for-bit (an f64 round-trip is exact), so the memo is byte-identical to
/// recomputing the transform. Distinct from [`mat4_to_row_major`], which is the
/// row-major GPU-instancing convention.
#[inline]
fn mat4_to_col_array(m: &Matrix4<f64>) -> [f64; 16] {
    *m.as_slice()
        .first_chunk::<16>()
        .expect("Matrix4<f64> as_slice is exactly 16 elements")
}

/// Flatten a column-major nalgebra `Matrix4<f64>` into a row-major `[f64; 16]`
/// (the [`crate::mesh::InstanceMeta`] convention; matches a GPU mat4 fed row-by-row).
pub(crate) fn mat4_to_row_major(m: &Matrix4<f64>) -> [f64; 16] {
    [
        m[(0, 0)], m[(0, 1)], m[(0, 2)], m[(0, 3)],
        m[(1, 0)], m[(1, 1)], m[(1, 2)], m[(1, 3)],
        m[(2, 0)], m[(2, 1)], m[(2, 2)], m[(2, 3)],
        m[(3, 0)], m[(3, 1)], m[(3, 2)], m[(3, 3)],
    ]
}

impl GeometryRouter {
    /// Apply local placement transformation to mesh
    pub(super) fn apply_placement(
        &self,
        element: &DecodedEntity,
        decoder: &mut EntityDecoder,
        mesh: &mut Mesh,
    ) -> Result<()> {
        let placement_attr = match element.get(5) {
            Some(attr) if !attr.is_null() => attr,
            _ => {
                // No ObjectPlacement: the world frame IS the object frame, so
                // run the ordinary world step with an identity placement. This
                // both records the #1474 frame capture AND applies the model
                // RTC shift / local-frame relativization exactly like every
                // placed mesh — consumers (renderer, demesher session)
                // reconstruct `true_world = origin + position + rtc` uniformly
                // and must not special-case placement-less geometry.
                self.transform_mesh_world(mesh, &Matrix4::identity());
                return Ok(());
            }
        };

        let placement = match decoder.resolve_ref(placement_attr)? {
            Some(p) => p,
            None => {
                self.transform_mesh_world(mesh, &Matrix4::identity());
                return Ok(());
            }
        };

        let mut transform = self.get_placement_transform(&placement, decoder)?;
        self.scale_transform(&mut transform);
        // Instancing: record the full (scaled) world placement on the mesh's
        // instance metadata BEFORE it is baked + RTC-folded by transform_mesh_world.
        // Only fires when processing already marked this mesh instanceable (so the
        // metadata exists); a no-op otherwise, keeping the flat path untouched.
        if let Some(im) = mesh.instance_meta.as_mut() {
            im.transform = mat4_to_row_major(&transform);
        }
        self.transform_mesh_world(mesh, &transform);
        Ok(())
    }

    /// Apply the element's `ObjectPlacement` (scaled to metres) to every sub-mesh.
    /// Placement is a rigid per-instance transform, kept OUT of the dedup cache so
    /// instances of one shared geometry land at their own positions.
    /// (Moved here from `processing.rs` — placement logic lives with the other
    /// placement appliers, and `processing.rs` sits at its ratchet budget.)
    pub(super) fn apply_submesh_placement(
        &self,
        sub_meshes: &mut SubMeshCollection,
        element: &DecodedEntity,
        decoder: &mut EntityDecoder,
    ) -> Result<()> {
        // ObjectPlacement translation is in file units (e.g. mm) but geometry is
        // scaled to metres, so the transform MUST be scaled to match.
        if let Some(placement_attr) = element.get(5) {
            if !placement_attr.is_null() {
                if let Some(placement) = decoder.resolve_ref(placement_attr)? {
                    let mut transform = self.get_placement_transform(&placement, decoder)?;
                    self.scale_transform(&mut transform);
                    // Instancing: record the per-element world placement on EACH sub-mesh's
                    // instance metadata BEFORE it is baked into the vertices, mirroring
                    // `apply_placement` for the single-mesh path. Without this, the sub-mesh
                    // path (every multi-item element — all the Tekla steel: beams, plates,
                    // assemblies) leaves `instance_meta.transform` at the IDENTITY placeholder,
                    // so `collate_refs` computes rel_k = m_k · m_ref⁻¹ = identity for every
                    // occurrence and they all stack on the first one. The flat path was
                    // always correct (placement IS baked into the vertices below), so the
                    // dedup made it look like repeated geometry was "missing".
                    if instancing_enabled() {
                        let row_major = mat4_to_row_major(&transform);
                        for sub in &mut sub_meshes.sub_meshes {
                            if let Some(im) = sub.mesh.instance_meta.as_mut() {
                                im.transform = row_major;
                            }
                        }
                    }
                    for sub in &mut sub_meshes.sub_meshes {
                        self.transform_mesh_world(&mut sub.mesh, &transform);
                    }
                    return Ok(());
                }
            }
        }
        // No resolvable ObjectPlacement: run the ordinary world step with an
        // identity placement — records the #1474 frame capture AND applies
        // the model RTC shift / relativization, mirroring the same branch in
        // `apply_placement` (single-mesh path).
        for sub in &mut sub_meshes.sub_meshes {
            self.transform_mesh_world(&mut sub.mesh, &Matrix4::identity());
        }
        Ok(())
    }

    /// Get placement transform from element without applying it
    pub(super) fn get_placement_transform_from_element(
        &self,
        element: &DecodedEntity,
        decoder: &mut EntityDecoder,
    ) -> Result<Matrix4<f64>> {
        // Get ObjectPlacement (attribute 5)
        let placement_attr = match element.get(5) {
            Some(attr) if !attr.is_null() => attr,
            _ => return Ok(Matrix4::identity()), // No placement
        };

        let placement = match decoder.resolve_ref(placement_attr)? {
            Some(p) => p,
            None => return Ok(Matrix4::identity()),
        };

        // Recursively get combined transform from placement hierarchy
        self.get_placement_transform(&placement, decoder)
    }

}