ifc-geometry 0.10.0

IFC semantic views lowered into the format-neutral geometry DAG.
Documentation
//! Net lowering of hosts whose IFC4 Reference View openings carry no Body
//! (#351).
//!
//! `reference_view_openings.ifc` (generated by
//! `tools/gen_lowering_fixtures.py`) holds four walls whose tessellated Body
//! already has the hole cut, as a Reference View export writes it. IFC4 ADD2
//! TC1 `IfcOpeningElement`: a `'Reference'` representation "is not
//! subtracted, it is provided in addition to the hole in the Body shape
//! representation of the voided element". Taking that at its word is an
//! explicit request; without it the host stays refused.

#![cfg(feature = "lowering")]

use ifc_geometry::lower::{
    lower_product_net, lower_product_net_with, AppliedReason, LoweringSession, NetLowering,
    NetOptions, ReferenceOnlyOpenings,
};
use ifc_geometry::{units, GeometryError, GeometryResult};
use ifc_model::{Codec, EntityId, Model, Value};
use std::path::PathBuf;

fn fixture() -> Model {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../test/fixtures/synthetic-lowering/reference_view_openings.ifc");
    ifc_step::StepCodec
        .read_path(&path)
        .expect("fixture parses")
}

/// The one entity of `type_name` whose `Name` (slot 2) is `name`.
fn named(model: &Model, type_name: &str, name: &str) -> EntityId {
    let found: Vec<EntityId> = model
        .of_type(type_name)
        .filter(|(_, entity)| matches!(entity.attributes.get(2), Some(Value::Text(text)) if text.as_ref() == name))
        .map(|(id, _)| id)
        .collect();
    assert_eq!(found.len(), 1, "exactly one {type_name} named {name}");
    found[0]
}

/// The `IfcRelVoidsElement` naming `opening`.
fn relation_of(model: &Model, opening: EntityId) -> EntityId {
    model
        .of_type("IFCRELVOIDSELEMENT")
        .find(|(_, entity)| entity.attributes.get(5) == Some(&Value::Ref(opening)))
        .map(|(id, _)| id)
        .expect("the opening is related")
}

fn net(model: &Model, wall: &str, options: NetOptions) -> GeometryResult<NetLowering> {
    let scale = units::resolve(model);
    let mut session = LoweringSession::new(model, &scale);
    let host = named(model, "IFCWALL", wall);
    Ok(lower_product_net_with(&mut session, host, options)?.expect("the wall has a Body"))
}

fn take_as_applied() -> NetOptions {
    NetOptions::default().with_reference_only_openings(ReferenceOnlyOpenings::TakeAsApplied)
}

fn refused_opening(error: GeometryError) -> (EntityId, EntityId) {
    match error {
        GeometryError::OpeningNotSubtracted { host, opening, .. } => (host, opening),
        other => panic!("expected OpeningNotSubtracted, got {other:?}"),
    }
}

#[test]
fn reference_view_openings_without_the_opt_in_refuse_the_host_as_before() {
    let model = fixture();
    let wall = named(&model, "IFCWALL", "W1");
    let opening = named(&model, "IFCOPENINGELEMENT", "O1-reference-only");
    let scale = units::resolve(&model);
    let mut session = LoweringSession::new(&model, &scale);
    let error = lower_product_net(&mut session, wall).expect_err("no opt-in, no net body");
    assert_eq!(refused_opening(error), (wall, opening));
    // The default options are exactly `lower_product_net`.
    let error = net(&model, "W1", NetOptions::default()).expect_err("default options refuse");
    assert_eq!(refused_opening(error), (wall, opening));
}

#[test]
fn reference_view_openings_a_reference_only_opening_is_taken_as_applied() {
    let model = fixture();
    let opening = named(&model, "IFCOPENINGELEMENT", "O1-reference-only");
    let lowered = net(&model, "W1", take_as_applied()).expect("the host lowers");

    assert!(lowered.subtractions.is_empty(), "nothing is subtracted");
    assert_eq!(
        lowered.root, lowered.gross,
        "the gross Body is the net Body"
    );
    assert_eq!(lowered.taken_as_applied.len(), 1);
    let applied = lowered.taken_as_applied[0];
    assert_eq!(applied.opening, opening);
    assert_eq!(applied.relation, relation_of(&model, opening));
    assert_eq!(applied.reason, AppliedReason::ReferenceRepresentationOnly);
}

#[test]
fn reference_view_openings_an_opening_without_a_representation_is_still_refused() {
    let model = fixture();
    let wall = named(&model, "IFCWALL", "W2");
    let opening = named(&model, "IFCOPENINGELEMENT", "O2-no-representation");
    let error = net(&model, "W2", take_as_applied())
        .expect_err("an opening that states nothing is not already applied");
    assert_eq!(refused_opening(error), (wall, opening));
}

#[test]
fn reference_view_openings_a_body_opening_beside_one_is_still_subtracted() {
    let model = fixture();
    let reference = named(&model, "IFCOPENINGELEMENT", "O3a-reference-only");
    let body = named(&model, "IFCOPENINGELEMENT", "O3b-body");
    let lowered = net(&model, "W3", take_as_applied()).expect("the host lowers");

    assert_eq!(lowered.openings(), vec![body], "the Body opening is cut");
    assert_eq!(
        lowered
            .taken_as_applied
            .iter()
            .map(|applied| applied.opening)
            .collect::<Vec<_>>(),
        vec![reference]
    );
    assert_ne!(
        lowered.root, lowered.gross,
        "the Body opening changes the result"
    );
    assert_eq!(lowered.root, lowered.subtractions[0].result);
}

#[test]
fn reference_view_openings_reference_beside_another_representation_is_refused() {
    let model = fixture();
    let wall = named(&model, "IFCWALL", "W4");
    let opening = named(&model, "IFCOPENINGELEMENT", "O4-reference-and-box");
    let error = net(&model, "W4", take_as_applied())
        .expect_err("only a Reference-only opening is taken as applied");
    assert_eq!(refused_opening(error), (wall, opening));
}