#![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;
enum Answer {
Volume { m3: f64, rel: f64 },
GrossAndNet { gross: f64, net: f64 },
RefusedDegenerate,
SurfaceArea { m2: f64 },
}
const D_SHAPE: f64 = 2.0 + PI / 2.0;
const LINING: f64 = 0.123_074_125_994_061_02;
fn expected() -> Vec<(&'static str, &'static str, Answer)> {
vec![
(
"composite-curve-profile",
"#43 IfcCompositeCurve profile boundary",
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)",
Answer::Volume {
m3: 8.0,
rel: 1e-12,
},
),
(
"shell-based-surface-model",
"IfcShellBasedSurfaceModel open shell (axiolid/kernel#161)",
Answer::SurfaceArea { m2: 0.12 },
),
(
"face-set-collinear-heads",
"closed IfcPolygonalFaceSet with collinear notch and window heads (axiolid/kernel#170)",
Answer::Volume {
m3: LINING,
rel: 1e-9,
},
),
(
"surface-model-bowtie-cap",
"IfcFaceBasedSurfaceModel with a zero-area bowtie end cap (axiolid/kernel#171)",
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"))
}
#[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);
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:?}"),
}
}
}
}
}
#[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",
);
}
#[test]
fn the_fixture_covers_every_kind_and_nothing_unpinned() {
let model = model();
let pinned: BTreeSet<&str> = expected().into_iter().map(|(name, ..)| name).collect();
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"
);
}
#[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;
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}"
);
}