ifc-geometry 0.10.0

IFC semantic views lowered into the format-neutral geometry DAG.
Documentation
//! Product-meshing coverage over one committed public fixture (#47).
//!
//! Issue #47 compiled every product in six real building models and sorted
//! each failure into six kinds. The real models cannot be committed, so
//! `test/fixtures/synthetic-coverage/meshing_coverage.ifc` restates each kind
//! as ONE small product (generated by `tools/gen_coverage_fixtures.py`). This
//! test compiles every product in that file through `compile_product_mesh`
//! and pins the answer it gets today.
//!
//! Four kinds were fixed in this repo and two in the Axiolid reference
//! compiler (axiolid/kernel#160, #161, `axiolid-mesh-compile` 0.3.1). Solids
//! pin an exact volume. The surface model pins its area and a typed refusal
//! of any volume: it is a surface, and must never acquire one.
//!
//! The volumes were cross-checked against IfcOpenShell 0.8.5 on the same
//! file: identical for the five solids that are exact in IfcOpenShell too, and
//! the composite-curve D within the chord budget both sides tessellate with.
#![cfg(feature = "compile-reference-backend")]

use std::collections::BTreeSet;
use std::f64::consts::PI;
use std::path::PathBuf;

use axiolid_contracts::ExecutionOptions;
use axiolid_core::Tolerance;
use axiolid_mesh::TriMesh;
use axiolid_mesh_compile_contract::MeshClosure;
use axiolid_mesh_compile_contract::MeshCompiler;
use ifc_geometry::compile::{
    compile_product_mesh, compile_product_mesh_net, compile_product_mesh_reported, default_backend,
    CompiledMesh,
};
use ifc_geometry::lower::{
    geometric_products, lower_product_representation, DegenerateFacePolicy, LoweringSession,
};
use ifc_geometry::{units, GeometryError, RepresentationPurpose};
use ifc_model::{Codec, EntityId, Model};
use ifc_step::StepCodec;

/// What compiling a product must return today.
enum Answer {
    /// Triangles enclosing this gross volume (m3), within `rel` of it.
    Volume { m3: f64, rel: f64 },
    /// Gross volume, and the net volume with every voiding opening removed.
    GrossAndNet { gross: f64, net: f64 },
    /// Lowering refuses with `Degenerate`, naming the collapsed loop.
    RefusedDegenerate,
    /// A surface model: triangles with this area (m2), reported as
    /// `MeshClosure::Surface`, and no volume.
    SurfaceArea { m2: f64 },
}

/// Exact volumes, derived in `tools/gen_coverage_fixtures.py`.
const D_SHAPE: f64 = 2.0 + PI / 2.0;
/// The real lining's divergence volume over its authored rings, from the
/// exact coordinates (export noise included), independent of Axiolid.
const LINING: f64 = 0.123_074_125_994_061_02;

/// One row per #47 failure kind, keyed by the product's `Name`.
fn expected() -> Vec<(&'static str, &'static str, Answer)> {
    vec![
        (
            "composite-curve-profile",
            "#43 IfcCompositeCurve profile boundary",
            // The arc is exact in lowering and chorded by the compiler;
            // its budget undershoots the half disc by ~2e-4 relative
            // (axiolid/kernel#165), so 1e-3 still catches a wrong arc.
            Answer::Volume {
                m3: D_SHAPE,
                rel: 1e-3,
            },
        ),
        (
            "wall-with-opening",
            "#44 IfcRelVoidsElement openings subtracted",
            Answer::GrossAndNet {
                gross: 3.6,
                net: 3.0,
            },
        ),
        (
            "bounded-half-space-clip",
            "#45 IfcPolygonalBoundedHalfSpace clip",
            Answer::Volume {
                m3: 47.875,
                rel: 1e-9,
            },
        ),
        (
            "collapsed-poly-loop",
            "#46 degenerate IfcPolyLoop (default policy)",
            Answer::RefusedDegenerate,
        ),
        (
            "polygonal-face-set-quads",
            "IfcPolygonalFaceSet faces with more than 3 corners (axiolid/kernel#160)",
            // A closed 2 m cube of quads.
            Answer::Volume {
                m3: 8.0,
                rel: 1e-12,
            },
        ),
        (
            "shell-based-surface-model",
            "IfcShellBasedSurfaceModel open shell (axiolid/kernel#161)",
            // Two triangles covering a 0.4 m x 0.3 m rectangle.
            Answer::SurfaceArea { m2: 0.12 },
        ),
        (
            "face-set-collinear-heads",
            "closed IfcPolygonalFaceSet with collinear notch and window heads (axiolid/kernel#170)",
            // A real ArchiCAD wall lining, 10 mm thick, at its exact
            // coordinates. On axiolid-mesh-compile 0.3.2 the mesh cracked
            // where the notch and window heads meet (4 open edges) and this
            // row came back a Surface.
            Answer::Volume {
                m3: LINING,
                rel: 1e-9,
            },
        ),
        (
            "surface-model-bowtie-cap",
            "IfcFaceBasedSurfaceModel with a zero-area bowtie end cap (axiolid/kernel#171)",
            // Four 0.1 x 1 m side walls; the bowtie cap covers nothing.
            // Before kernel#171 the whole product was refused.
            Answer::SurfaceArea { m2: 0.4 },
        ),
    ]
}

fn model() -> Model {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../test/fixtures/synthetic-coverage/meshing_coverage.ifc");
    StepCodec
        .read_path(&path)
        .unwrap_or_else(|e| panic!("{} must parse: {e:?}", path.display()))
}

fn product_named(model: &Model, name: &str) -> EntityId {
    let found: Vec<EntityId> = geometric_products(model)
        .into_iter()
        .filter(|&id| name_of(model, id).as_deref() == Some(name))
        .collect();
    assert_eq!(
        found.len(),
        1,
        "exactly one product named {name}: {found:?}"
    );
    found[0]
}

fn name_of(model: &Model, id: EntityId) -> Option<String> {
    model
        .get(id)?
        .attributes
        .get(2)?
        .as_text()
        .map(str::to_string)
}

fn signed_volume(mesh: &TriMesh) -> f64 {
    assert!(!mesh.indices.is_empty(), "compiled mesh is empty");
    let base = mesh.positions[0];
    mesh.indices
        .chunks_exact(3)
        .map(|t| {
            let [a, b, c] = [t[0], t[1], t[2]].map(|i| mesh.positions[i as usize] - base);
            a.dot(b.cross(c)) / 6.0
        })
        .sum()
}

fn area(mesh: &TriMesh) -> f64 {
    mesh.indices
        .chunks_exact(3)
        .map(|t| {
            let [a, b, c] = [t[0], t[1], t[2]].map(|i| mesh.positions[i as usize]);
            (b - a).cross(c - a).length() / 2.0
        })
        .sum()
}

fn reported(model: &Model, id: EntityId) -> CompiledMesh {
    compile_product_mesh_reported(model, id, Tolerance::MILLIMETRE)
        .unwrap_or_else(|e| panic!("{id}: refused: {e}"))
        .expect("every coverage product has a Body")
}

fn assert_close(actual: f64, expected: f64, rel: f64, what: &str) {
    assert!(
        (actual - expected).abs() <= rel * expected.abs(),
        "{what}: volume {actual} m3, expected {expected} m3 (rel {rel})"
    );
}

fn gross(model: &Model, id: EntityId) -> Result<TriMesh, GeometryError> {
    compile_product_mesh(model, id, Tolerance::MILLIMETRE)
        .map(|mesh| mesh.expect("every coverage product has a Body"))
}

/// Every #47 failure kind compiles to the answer pinned for it.
#[test]
fn each_failure_kind_compiles_to_its_pinned_answer() {
    let model = model();
    for (name, kind, answer) in expected() {
        let id = product_named(&model, name);
        let what = format!("{name} ({kind})");
        match answer {
            Answer::Volume { m3, rel } => {
                let mesh = gross(&model, id).unwrap_or_else(|e| panic!("{what}: refused: {e}"));
                assert_close(signed_volume(&mesh), m3, rel, &what);
                // The reported path agrees, and says it is a solid.
                let compiled = reported(&model, id);
                assert_eq!(compiled.closure, MeshClosure::Solid, "{what}");
                let solid = compiled.solid_mesh(id).expect("a solid has a volume");
                assert_close(signed_volume(solid), m3, rel, &what);
            }
            Answer::GrossAndNet { gross: g, net: n } => {
                let mesh = gross(&model, id).unwrap_or_else(|e| panic!("{what}: refused: {e}"));
                assert_close(signed_volume(&mesh), g, 1e-9, &format!("{what}, gross"));
                let net = compile_product_mesh_net(&model, id, Tolerance::MILLIMETRE)
                    .unwrap_or_else(|e| panic!("{what}: net refused: {e}"))
                    .expect("the host has a Body");
                assert_close(signed_volume(&net.mesh), n, 1e-9, &format!("{what}, net"));
                assert_eq!(net.openings.len(), 1, "{what}: one opening subtracted");
            }
            Answer::RefusedDegenerate => {
                let error = gross(&model, id).expect_err("the default policy refuses");
                assert!(
                    matches!(error, GeometryError::Degenerate { .. }),
                    "{what}: {error}"
                );
                let loop_id = error.entity().expect("names the loop");
                assert_eq!(
                    model.get(loop_id).map(|e| e.type_name.to_ascii_uppercase()),
                    Some("IFCPOLYLOOP".to_string()),
                    "{what}: the refusal names the collapsed loop"
                );
            }
            Answer::SurfaceArea { m2 } => {
                let compiled = reported(&model, id);
                assert_eq!(compiled.closure, MeshClosure::Surface, "{what}");
                assert_close(area(&compiled.mesh), m2, 1e-12, &what);
                match compiled.solid_mesh(id) {
                    Err(GeometryError::NotASolid { entity, closure }) => {
                        assert_eq!(entity, id, "{what}: the refusal names the product");
                        assert_eq!(closure, MeshClosure::Surface, "{what}");
                    }
                    other => panic!("{what}: a surface must refuse a volume: {other:?}"),
                }
            }
        }
    }
}

/// The #46 row compiles once the caller opts into `DropAndReport`.
///
/// The default refusal is pinned above; this is the other half of the policy,
/// on the same product: the box's 24 m3 with the sliver face reported.
#[test]
fn the_collapsed_loop_product_compiles_under_drop_and_report() {
    let model = model();
    let id = product_named(&model, "collapsed-poly-loop");
    let scale = units::resolve(&model);
    let mut session =
        LoweringSession::new(&model, &scale).with_face_policy(DegenerateFacePolicy::DropAndReport);
    let root = lower_product_representation(&mut session, id, RepresentationPurpose::Body)
        .expect("lowers under DropAndReport")
        .expect("has a Body");
    let lowered = session.finish(root).expect("finishes");
    let dropped: Vec<EntityId> = lowered.provenance.dropped_faces().collect();
    assert_eq!(dropped.len(), 1, "exactly the sliver face: {dropped:?}");
    assert_eq!(
        model
            .get(dropped[0])
            .map(|e| e.type_name.to_ascii_uppercase()),
        Some("IFCFACE".to_string())
    );
    let mesh = default_backend()
        .compile_mesh(
            &lowered.graph,
            lowered.root,
            &ExecutionOptions::new(Tolerance::MILLIMETRE),
        )
        .expect("the box compiles");
    assert_close(
        signed_volume(&mesh),
        24.0,
        1e-9,
        "collapsed-poly-loop, dropped",
    );
}

/// The fixture carries exactly the pinned products, so coverage cannot
/// silently shrink: a product dropped from the generator, or a new one added
/// without a pinned answer, fails here.
#[test]
fn the_fixture_covers_every_kind_and_nothing_unpinned() {
    let model = model();
    let pinned: BTreeSet<&str> = expected().into_iter().map(|(name, ..)| name).collect();
    // The opening is a product too, but it is the #44 row's tool, not a kind.
    let present: BTreeSet<String> = geometric_products(&model)
        .into_iter()
        .filter_map(|id| name_of(&model, id))
        .filter(|name| name != "door-opening")
        .collect();
    let present: BTreeSet<&str> = present.iter().map(String::as_str).collect();
    assert_eq!(present, pinned, "fixture products and pinned rows differ");
    assert_eq!(
        pinned.len(),
        8,
        "one row per #47 failure kind, plus kernel#170 and #171"
    );
}

/// An explicit chord budget tightens the composite-curve row (axiolid/kernel#165).
///
/// The D's half disc (radius 1 m) is chorded by the kernel. At
/// `Tolerance::MILLIMETRE` the default budget is the linear tolerance; a
/// budget a hundred times finer must bring the volume a hundred times
/// closer, within float noise of the analytic value. The oracle is the
/// closed form 2 + pi/2 m3, not a number read back from the compiler.
#[test]
fn a_chord_budget_brings_the_composite_curve_row_to_its_exact_volume() {
    let model = model();
    let id = product_named(&model, "composite-curve-profile");
    let scale = units::resolve(&model);
    let mut session = LoweringSession::new(&model, &scale);
    let root = lower_product_representation(&mut session, id, RepresentationPurpose::Body)
        .expect("lowers")
        .expect("has a Body");
    let lowered = session.finish(root).expect("finishes");
    let volume_at = |options: &ExecutionOptions| {
        let mesh = default_backend()
            .compile_mesh(&lowered.graph, lowered.root, options)
            .expect("compiles");
        signed_volume(&mesh)
    };
    let default = ExecutionOptions::new(Tolerance::MILLIMETRE);
    let fine = ExecutionOptions::new(Tolerance::MILLIMETRE)
        .with_chord_error(1.0e-5)
        .expect("a positive finite budget");
    let coarse_error = (volume_at(&default) - D_SHAPE).abs() / D_SHAPE;
    let fine_error = (volume_at(&fine) - D_SHAPE).abs() / D_SHAPE;
    // Chords under-cover a convex arc by about their sagitta times two
    // thirds of the arc length, so the relative error scales with the
    // budget: 1e-5 m on a 1 m radius half disc bounds it near 1e-5.
    assert!(
        fine_error < 1.0e-5,
        "a 10 um budget must meet 1e-5 relative: {fine_error:e}"
    );
    assert!(
        fine_error * 50.0 < coarse_error,
        "the budget must matter: default {coarse_error:e}, 10 um {fine_error:e}"
    );
}