#![cfg(feature = "lowering")]
use axiolid_model::{CurveRelation, GeometryNode};
use ifc_geometry::lower::dispatch::{IMPLEMENTED, PLANNED};
use ifc_geometry::lower::{lower_representation_item, LoweringSession};
use ifc_geometry::transform::Transform;
use ifc_geometry::units;
use ifc_geometry::GeometryError;
use ifc_model::Codec;
use ifc_step::StepCodec;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
mod lower_dispatch_corpus {
pub mod corpus;
pub mod probes;
}
use lower_dispatch_corpus::corpus::{collect_ifc, fixture_root, is_pinned_collapsed_loop};
use lower_dispatch_corpus::probes::irregular_network;
const DISPOSITIONS: &str = include_str!("../data/ifc4-representation-item-dispositions.tsv");
fn disposition_rows() -> Vec<[&'static str; 4]> {
DISPOSITIONS
.lines()
.skip(1)
.map(|line| {
let fields: Vec<_> = line.split('\t').collect();
assert_eq!(fields.len(), 4, "malformed disposition row: {line}");
[fields[0], fields[1], fields[2], fields[3]]
})
.collect()
}
#[test]
fn every_concrete_ifc4_representation_item_is_classified() {
let schema = ifc_schema::ifc4();
let expected: BTreeSet<_> = schema
.entity_names()
.filter(|name| {
schema.is_a(name, "IfcRepresentationItem")
&& !schema.entity(name).expect("known entity").abstract_
})
.map(str::to_ascii_uppercase)
.collect();
let rows = disposition_rows();
let classified: BTreeSet<_> = rows.iter().map(|row| row[0].to_owned()).collect();
assert_eq!(
classified.len(),
rows.len(),
"duplicate representation-item disposition"
);
for row in &rows {
assert!(
matches!(
row[1],
"nested-exact" | "planned-exact" | "typed-refusal" | "non-shape"
),
"unknown disposition for {}: {}",
row[0],
row[1]
);
assert!(!row[2].is_empty(), "{} has no owner", row[0]);
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let owner_path = if row[2] == "ifc-style" {
manifest.join("../ifc-style/src/lib.rs")
} else if let Some(module) = row[2].strip_prefix("lower::") {
manifest.join("src/lower").join(format!("{module}.rs"))
} else if let Some(module) = row[2].strip_prefix("resource::") {
manifest.join("src/resource").join(format!("{module}.rs"))
} else {
panic!("{} has unknown owner syntax: {}", row[0], row[2]);
};
assert!(
owner_path.is_file(),
"{} names missing owner module {}",
row[0],
owner_path.display()
);
assert!(!row[3].is_empty(), "{} has no rationale", row[0]);
if row[1] == "typed-refusal" && row[2] != "ifc-style" {
let body = std::fs::read_to_string(&owner_path).expect("owner module is readable");
assert!(
body.contains(row[0]),
"{} is a typed-refusal owned by {} but that module never names it, \
so the refusal cannot be the documented one",
row[0],
row[2]
);
}
}
let in_ifc4 = |name: &str| schema.entity(name).is_some();
let implemented: BTreeSet<_> = IMPLEMENTED
.iter()
.filter(|name| in_ifc4(name))
.map(|name| (*name).to_owned())
.collect();
assert!(
implemented.is_disjoint(&classified),
"root-exact and non-root classifications overlap"
);
let actual: BTreeSet<_> = implemented.union(&classified).cloned().collect();
let missing: Vec<_> = expected.difference(&actual).cloned().collect();
let extra: Vec<_> = actual.difference(&expected).cloned().collect();
assert!(
missing.is_empty() && extra.is_empty(),
"representation-item disposition drift: missing={missing:?}; extra={extra:?}"
);
let planned: BTreeSet<_> = PLANNED
.iter()
.map(|(name, _)| *name)
.filter(|name| in_ifc4(name))
.collect();
let classified_planned: BTreeSet<_> = rows
.iter()
.filter(|row| row[1] == "planned-exact")
.map(|row| row[0])
.collect();
assert_eq!(
planned, classified_planned,
"runtime typed-refusal inventory drift"
);
}
#[test]
fn a_committed_standalone_composite_curve_on_surface_lowers_exactly() {
let path = fixture_root().join("synthetic-surfaces/synthetic_conic_offset_bounded.ifc");
let model = StepCodec.read_path(&path).expect("fixture parses");
let ids = model.ids_of_type("IFCCOMPOSITECURVEONSURFACE");
assert_eq!(ids.len(), 1, "fixture has one standalone curve-on-surface");
let scale = units::resolve(&model);
let mut session = LoweringSession::new(&model, &scale);
let root = lower_representation_item(&mut session, ids[0], Transform::identity())
.expect("curve-on-surface lowers through the total dispatcher");
let lowered = session.finish(root).expect("session finishes");
let GeometryNode::CurveRelation(CurveRelation::Composite { segments }) =
lowered.graph.get(root).expect("root exists")
else {
panic!("curve-on-surface must remain an exact composite relation");
};
assert_eq!(segments.len(), 1, "authored segment order is preserved");
assert!(
matches!(
lowered.graph.get(segments[0].curve),
Some(GeometryNode::CurveRelation(
CurveRelation::ParameterCurve { .. }
))
),
"the segment stays a surface-parameter p-curve"
);
}
#[test]
fn every_corpus_representation_item_lowers_or_reports_a_typed_reason() {
let mut files = Vec::new();
collect_ifc(&fixture_root(), &mut files);
assert!(files.len() >= 19, "expected the committed corpus");
let mut lowered = 0usize;
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
for path in &files {
let Ok(model) = StepCodec.read_path(path) else {
continue;
};
let scale = units::resolve(&model);
for type_name in IMPLEMENTED.iter().chain(PLANNED.iter().map(|(n, _)| n)) {
for id in model.ids_of_type(type_name) {
let mut session = LoweringSession::new(&model, &scale);
match lower_representation_item(&mut session, *id, Transform::identity()) {
Ok(_) => lowered += 1,
Err(error) => {
let named = error.entity().expect("every failure names an entity");
assert!(
model.get(named).is_some(),
"{path:?}: reported entity {named} is not in the model"
);
let cyclic_fixture = path
.file_name()
.is_some_and(|name| name.to_string_lossy().contains("cycle"));
if is_pinned_collapsed_loop(path, &model, &error) {
continue;
}
if cyclic_fixture && !error.is_unsupported() {
let text = error.to_string();
assert!(
text.contains("cyclic") || text.contains("has no MappingTarget"),
"{path:?} {id}: expected a structural report, got: {error}"
);
continue;
}
assert!(
error.is_unsupported(),
"{path:?} {id}: unexpected hard failure: {error}"
);
*unsupported.entry((*type_name).to_string()).or_default() += 1;
}
}
}
}
}
println!("dispatched: {lowered} lowered, unsupported by family: {unsupported:?}");
assert!(
lowered > 0,
"the corpus must exercise at least one implemented family"
);
}
#[test]
fn planned_families_report_their_documented_reason() {
let mut files = Vec::new();
collect_ifc(&fixture_root(), &mut files);
let mut observed = BTreeSet::new();
for path in &files {
let Ok(model) = StepCodec.read_path(path) else {
continue;
};
let scale = units::resolve(&model);
for (type_name, detail) in PLANNED {
for id in model.ids_of_type(type_name) {
let mut session = LoweringSession::new(&model, &scale);
let error = lower_representation_item(&mut session, *id, Transform::identity())
.expect_err("a planned family must not silently succeed");
assert!(
error.to_string().contains(detail),
"{type_name} must report {detail:?}, got: {error}"
);
observed.insert(*type_name);
}
}
}
let planned: BTreeSet<_> = PLANNED.iter().map(|(type_name, _)| *type_name).collect();
assert_eq!(
observed, planned,
"every planned typed refusal needs committed corpus discrimination"
);
for (type_name, detail) in PLANNED {
assert!(
!IMPLEMENTED.contains(type_name),
"{type_name} is listed as both planned and implemented"
);
assert!(
!detail.trim().is_empty(),
"{type_name} must state why it is not lowered yet"
);
}
}
#[test]
fn a_nested_failure_names_the_innermost_unlowerable_entity() {
let mut model = ifc_model::Model::new();
let inner = ifc_model::EntityId(1);
let outer = ifc_model::EntityId(2);
model.insert(
inner,
ifc_model::Entity::new("IFCNOSUCHREPRESENTATIONITEM", vec![]),
);
model.insert(
outer,
ifc_model::Entity::new(
"IFCBOOLEANRESULT",
vec![
ifc_model::Value::Enum("DIFFERENCE".into()),
ifc_model::Value::Ref(inner),
ifc_model::Value::Ref(inner),
],
),
);
let scale = units::resolve(&model);
let mut session = LoweringSession::new(&model, &scale);
let error = lower_representation_item(&mut session, outer, Transform::identity())
.expect_err("the spine operand is not lowered yet");
assert_eq!(
error.entity(),
Some(inner),
"the report must name the innermost gap, not the boolean that wraps it"
);
assert!(error.is_unsupported(), "this is a gap, not corruption");
assert!(
error
.to_string()
.contains("representation item family is not lowered yet"),
"the report must state the documented reason, got: {error}"
);
}
#[test]
fn every_implemented_family_has_committed_corpus_evidence() {
let mut files = Vec::new();
collect_ifc(&fixture_root(), &mut files);
let mut counts = BTreeMap::<&str, usize>::new();
for path in &files {
let Ok(model) = StepCodec.read_path(path) else {
continue;
};
for family in IMPLEMENTED {
*counts.entry(family).or_default() += model.ids_of_type(family).len();
}
}
let missing: Vec<_> = IMPLEMENTED
.iter()
.copied()
.filter(|family| counts.get(family).copied().unwrap_or_default() == 0)
.collect();
assert!(
missing.is_empty(),
"IMPLEMENTED families without committed corpus instances: {missing:?}"
);
}
fn typed_refusals() -> Vec<String> {
disposition_rows()
.iter()
.filter(|row| row[1] == "typed-refusal")
.map(|row| row[0].to_owned())
.collect()
}
#[test]
fn implemented_families_lower_and_lowering_families_are_claimed() {
let mut files = Vec::new();
collect_ifc(&fixture_root(), &mut files);
let mut successes = BTreeMap::<&str, usize>::new();
for path in &files {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.contains("cycle") || name.contains("invalid") || name.contains("malformed") {
continue;
}
let Ok(model) = StepCodec.read_path(path) else {
continue;
};
let scale = units::resolve(&model);
for family in IMPLEMENTED {
for id in model.ids_of_type(family) {
let mut session = LoweringSession::new(&model, &scale);
match lower_representation_item(&mut session, *id, Transform::identity()) {
Ok(_) => *successes.entry(family).or_default() += 1,
Err(GeometryError::Unsupported { type_name, .. })
if PLANNED
.iter()
.any(|(planned, _)| planned.eq_ignore_ascii_case(&type_name))
|| typed_refusals()
.iter()
.any(|refused| refused.eq_ignore_ascii_case(&type_name)) =>
{
}
Err(error) if is_pinned_collapsed_loop(path, &model, &error) => {}
Err(error) => panic!(
"{family} is listed IMPLEMENTED but {id:?} in {} failed: {error}",
path.display()
),
}
}
}
}
let without_success: Vec<_> = IMPLEMENTED
.iter()
.copied()
.filter(|family| successes.get(family).copied().unwrap_or_default() == 0)
.collect();
assert!(
without_success.is_empty(),
"IMPLEMENTED families without a successful committed lowering: {without_success:?}"
);
}
#[test]
fn every_family_that_lowers_is_named_in_the_inventory() {
let mut files = Vec::new();
collect_ifc(&fixture_root(), &mut files);
let mut unclaimed: BTreeMap<String, usize> = BTreeMap::new();
for path in &files {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.contains("cycle") || name.contains("invalid") || name.contains("malformed") {
continue;
}
let Ok(model) = StepCodec.read_path(path) else {
continue;
};
let scale = units::resolve(&model);
for (id, entity) in model.iter() {
let type_name = entity.type_name.to_ascii_uppercase();
let claimed = IMPLEMENTED
.iter()
.any(|n| n.eq_ignore_ascii_case(&type_name))
|| PLANNED
.iter()
.any(|(n, _)| n.eq_ignore_ascii_case(&type_name));
if claimed {
continue;
}
let mut session = LoweringSession::new(&model, &scale);
if lower_representation_item(&mut session, id, Transform::identity()).is_ok() {
*unclaimed.entry(type_name).or_default() += 1;
}
}
}
assert!(
unclaimed.is_empty(),
"these families lower but are named in neither IMPLEMENTED nor PLANNED: {unclaimed:?}"
);
}
#[test]
fn every_partial_family_declares_both_admitted_and_refused_variants() {
use ifc_geometry::lower::dispatch::{Support, PARTIAL};
let implemented: BTreeSet<_> = IMPLEMENTED.iter().copied().collect();
let planned: BTreeSet<_> = PLANNED.iter().map(|(name, _)| *name).collect();
let mut families: BTreeSet<&str> = BTreeSet::new();
for variant in PARTIAL {
families.insert(variant.family);
assert!(
implemented.contains(variant.family),
"{} is in PARTIAL but not IMPLEMENTED; a partial family must be \
one whose base support exists",
variant.family
);
assert!(
!planned.contains(variant.family),
"{} is in both PARTIAL and PLANNED; a family cannot be partially \
supported and entirely unimplemented at once",
variant.family
);
assert!(
!variant.variant.is_empty(),
"{}: variant condition must be stated",
variant.family
);
assert!(
!variant.rationale.is_empty(),
"{} / {}: rationale must be stated",
variant.family,
variant.variant
);
}
for family in &families {
let admitted = PARTIAL
.iter()
.filter(|v| v.family == *family)
.any(|v| v.support == Support::Admitted);
let refused = PARTIAL
.iter()
.filter(|v| v.family == *family)
.any(|v| v.support == Support::Refused);
assert!(
admitted,
"{family} declares no admitted variant; it is not partially \
supported and should be in PLANNED"
);
assert!(
refused,
"{family} declares no refused variant; it is fully supported and \
should be in IMPLEMENTED alone"
);
}
let mut seen = BTreeSet::new();
for variant in PARTIAL {
assert!(
seen.insert((variant.family, variant.variant)),
"duplicate PARTIAL row for {} / {}",
variant.family,
variant.variant
);
}
}
#[test]
fn declared_variant_support_matches_runtime_behaviour() {
use ifc_geometry::lower::dispatch::{Support, PARTIAL};
use ifc_model::{Entity, EntityId, Model, Value};
fn ent(type_name: &str, values: Vec<Value>) -> Entity {
Entity::new(type_name, values)
}
fn rf(id: u64) -> Value {
Value::Ref(EntityId(id))
}
fn num(v: f64) -> Value {
Value::Real(v)
}
fn pt(coords: Vec<f64>) -> Entity {
ent(
"IFCCARTESIANPOINT",
vec![Value::List(coords.into_iter().map(num).collect())],
)
}
fn plane_model() -> Model {
let mut model = Model::new();
model.insert(EntityId(1), pt(vec![0.0, 0.0, 0.0]));
model.insert(
EntityId(2),
ent("IFCAXIS2PLACEMENT3D", vec![rf(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), ent("IFCPLANE", vec![rf(2)]));
model
}
let probes: Vec<(&str, &str, Model)> = vec![
{
let mut m = plane_model();
m.insert(EntityId(4), pt(vec![2.0, 3.0]));
m.insert(
EntityId(5),
ent("IFCAXIS2PLACEMENT2D", vec![rf(4), Value::Null]),
);
m.insert(EntityId(6), ent("IFCCIRCLE", vec![rf(5), num(1.5)]));
m.insert(EntityId(9), ent("IFCPCURVE", vec![rf(3), rf(6)]));
(
"IFCPCURVE",
"reference curve is an IfcLine, IfcCircle or IfcEllipse \
positioned by an IfcAxis2Placement2D",
m,
)
},
{
let mut m = plane_model();
m.insert(EntityId(6), ent("IFCCIRCLE", vec![rf(2), num(1.5)]));
m.insert(EntityId(9), ent("IFCPCURVE", vec![rf(3), rf(6)]));
(
"IFCPCURVE",
"reference conic positioned by an IfcAxis2Placement3D",
m,
)
},
{
let mut m = plane_model();
m.insert(EntityId(5), pt(vec![0.0, 0.0]));
m.insert(
EntityId(6),
ent("IFCAXIS2PLACEMENT2D", vec![rf(5), Value::Null]),
);
m.insert(EntityId(7), ent("IFCCIRCLE", vec![rf(6), Value::Real(2.0)]));
let param = |v: f64| Value::Typed {
type_name: "IFCPARAMETERVALUE".into(),
value: Box::new(Value::Real(v)),
};
m.insert(
EntityId(8),
ent(
"IFCTRIMMEDCURVE",
vec![
rf(7),
Value::List(vec![param(0.25)]),
Value::List(vec![param(0.75)]),
Value::Bool(true),
Value::Enum("PARAMETER".into()),
],
),
);
m.insert(EntityId(9), ent("IFCPCURVE", vec![rf(3), rf(8)]));
(
"IFCPCURVE",
"reference curve is a trimmed or composite curve",
m,
)
},
{
let mut m = plane_model();
m.insert(EntityId(6), ent("IFCBSPLINECURVE", vec![Value::Integer(3)]));
m.insert(EntityId(9), ent("IFCPCURVE", vec![rf(3), rf(6)]));
(
"IFCPCURVE",
"reference curve is a convention-only IfcBSplineCurve",
m,
)
},
{
let mut m = plane_model();
m.insert(EntityId(6), pt(vec![1.5, 2.5]));
m.insert(EntityId(7), pt(vec![3.5, 4.5]));
m.insert(
EntityId(8),
ent(
"IFCBSPLINECURVEWITHKNOTS",
vec![
Value::Integer(1),
Value::List(vec![rf(6), rf(7)]),
Value::Enum("UNSPECIFIED".into()),
Value::Bool(false),
Value::Bool(false),
Value::List(vec![Value::Integer(2), Value::Integer(2)]),
Value::List(vec![num(0.0), num(1.0)]),
Value::Enum("UNSPECIFIED".into()),
],
),
);
m.insert(EntityId(9), ent("IFCPCURVE", vec![rf(3), rf(8)]));
(
"IFCPCURVE",
"reference curve is an explicit-knot IfcBSplineCurveWithKnots \
or IfcRationalBSplineCurveWithKnots",
m,
)
},
{
let mut m = plane_model();
m.insert(EntityId(4), pt(vec![0.0, 0.0]));
m.insert(EntityId(5), pt(vec![1.0, 2.0]));
m.insert(
EntityId(6),
ent("IFCPOLYLINE", vec![Value::List(vec![rf(4), rf(5)])]),
);
m.insert(EntityId(9), ent("IFCPCURVE", vec![rf(3), rf(6)]));
("IFCPCURVE", "reference curve is an IfcPolyline", m)
},
irregular_network(&[1, 0], "every Flags value is a breakline code, 0 to 7"),
irregular_network(&[0, -2], "a Flags value is -1 (hole) or -2 (void)"),
irregular_network(&[8, 0], "a Flags value is outside -2 to 7"),
];
for (family, variant, model) in probes {
let declared = PARTIAL
.iter()
.find(|v| v.family == family && v.variant == variant)
.unwrap_or_else(|| {
panic!(
"no PARTIAL row for {family} / {variant:?}; the catalog and \
its runtime probes have drifted apart"
)
});
let scale = units::resolve(&model);
let mut session = LoweringSession::new(&model, &scale);
let outcome = lower_representation_item(&mut session, EntityId(9), Transform::identity());
match declared.support {
Support::Admitted => assert!(
outcome.is_ok(),
"{family} / {variant:?} is declared Admitted but did not lower: {:?}",
outcome.err()
),
Support::Refused => {
let error = outcome.err().unwrap_or_else(|| {
panic!("{family} / {variant:?} is declared Refused but lowered successfully")
});
assert!(
error.is_unsupported(),
"{family} / {variant:?} must be a typed gap, not corruption: {error}"
);
}
other => panic!("{family} / {variant:?}: support {other:?} has no corpus check"),
}
}
}