#![allow(missing_docs)]
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use axioval::ifc::IFC4_TYPE_SYSTEM;
use serde_json::{Value, json};
const FIXTURES: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../fixtures/schema-v0.1.0"
);
fn ifc(wall_2_global_id: &str, wall_2_has_reference: bool) -> String {
let related = if wall_2_has_reference {
"(#1,#2)"
} else {
"(#1)"
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCWALL('0000000000000000000001',$,$,$,$,$,$,$,$);\n\
#2=IFCWALLSTANDARDCASE('{wall_2_global_id}',$,$,$,$,$,$,$,$);\n\
#3=IFCSLAB('0000000000000000000003',$,$,$,$,$,$,$,$);\n\
#4=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('W-1'),$);\n\
#5=IFCPROPERTYSET('0000000000000000000005',$,'Pset_WallCommon',$,(#4));\n\
#6=IFCRELDEFINESBYPROPERTIES('0000000000000000000006',$,$,$,{related},#5);\n\
ENDSEC;\nEND-ISO-10303-21;\n"
)
}
struct Case {
dir: PathBuf,
}
impl Case {
fn new(name: &str) -> Self {
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self { dir }
}
fn path(&self, name: &str) -> PathBuf {
self.dir.join(name)
}
fn write(&self, name: &str, contents: &str) -> PathBuf {
let path = self.path(name);
std::fs::write(&path, contents).unwrap();
path
}
fn definitions(&self, bound: bool) -> PathBuf {
let text = std::fs::read_to_string(format!("{FIXTURES}/definitions.json")).unwrap();
let mut definitions: Value = serde_json::from_str(&text).unwrap();
if bound {
for (section, id, name) in [
("objectTypes", "axioval:example.ifc.wall", "IfcWall"),
("properties", "axioval:example.ifc.reference", "Reference"),
(
"propertySets",
"axioval:example.ifc.pset-wall-common",
"Pset_WallCommon",
),
] {
definitions[section][id]["externalNames"]
.as_array_mut()
.unwrap()
.push(json!({"typeSystem": IFC4_TYPE_SYSTEM, "name": name}));
}
}
self.write("definitions.json", &definitions.to_string())
}
fn check(&self, model: &str, bound: bool, extra: &[&str]) -> Output {
let model = self.write("model.ifc", model);
let definitions = self.definitions(bound);
Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(format!("{FIXTURES}/ruleset.json"))
.args(extra)
.env("SOURCE_DATE_EPOCH", "1790416800")
.output()
.unwrap()
}
}
fn json(output: &Output) -> Value {
serde_json::from_slice(&output.stdout).unwrap()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
#[test]
fn a_finding_exits_3_and_is_written_as_json_and_bcf() {
let case = Case::new("finding");
let bcf = case.path("issues.bcfzip");
let output = case.check(
&ifc("0000000000000000000002", false),
true,
&["--bcf", bcf.to_str().unwrap()],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["object_id"]["local_id"], "#2");
assert_eq!(findings[0]["object_id"]["source"]["document"], "model.ifc");
assert!(result["integrity"].as_array().unwrap().is_empty());
let archive = openbim_bcf::read_path(&bcf).unwrap();
assert!(
archive.diagnostics().is_empty(),
"{:?}",
archive.diagnostics()
);
let topic = &archive.topics().next().unwrap().topic;
assert_eq!(topic.creation_date.as_deref(), Some("2026-09-26T10:00:00Z"));
assert_eq!(topic.creation_author.as_deref(), Some("axioval"));
assert_eq!(topic.labels, ["wall-reference-required", "example"]);
assert!(stderr(&output).contains("1 finding(s), 0 not evaluated"));
}
#[test]
fn a_complete_clean_check_exits_0() {
let case = Case::new("clean");
let output = case.check(&ifc("0000000000000000000002", true), true, &[]);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
let result = json(&output);
assert!(result["report"]["findings"].as_array().unwrap().is_empty());
assert!(
result["report"]["not_evaluated"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn an_incomplete_check_never_exits_0() {
let case = Case::new("incomplete");
let output = case.check(&ifc("0000000000000000000002", false), false, &[]);
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
let result = json(&output);
assert!(result["report"]["findings"].as_array().unwrap().is_empty());
assert!(
!result["report"]["not_evaluated"]
.as_array()
.unwrap()
.is_empty()
);
}
const EMPTY_MODEL: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCPRESENTATIONLAYERWITHSTYLE('Foo',$,(#1),$,.T.,.F.,.F.,());\n\
ENDSEC;\nEND-ISO-10303-21;\n";
impl Case {
fn wall_count_packages(&self) -> (PathBuf, PathBuf) {
let definitions = self.definitions(true);
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(definitions).unwrap()).unwrap();
definitions["definitions"]["axioval:example.object-count"] = json!({
"id": "axioval:example.object-count",
"name": {"default": "Object count", "translations": {}},
"description": {"default": "The model contains the selection.", "translations": {}},
"capability": "axioval:capability.object-count",
"parameters": registry_signature("axioval:capability.object-count"),
"citations": [],
"tags": [],
});
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("a-wall-exists");
rule["definitionId"] = json!("axioval:example.object-count");
rule["parameters"] = json!({});
(
self.write("definitions.json", &definitions.to_string()),
self.write("ruleset.json", &ruleset.to_string()),
)
}
}
#[test]
fn a_model_without_objects_is_checked_and_an_existence_rule_fails_on_it() {
let case = Case::new("empty-model");
let output = case.check(EMPTY_MODEL, true, &[]);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
let result = json(&output);
assert!(result["report"]["findings"].as_array().unwrap().is_empty());
assert!(
result["report"]["not_evaluated"]
.as_array()
.unwrap()
.is_empty()
);
let model = case.write("model.ifc", EMPTY_MODEL);
let (definitions, ruleset) = case.wall_count_packages();
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["rule_id"], "a-wall-exists", "{result:#}");
assert_eq!(
findings[0]["message"],
"no object matches the selection in source `ifc-step:model.ifc`; required at least 1"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn a_type_object_is_checked_against_its_own_property_sets() {
let case = Case::new("type-objects");
let model = case.write(
"model.ifc",
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('WT-1'),$);\n\
#2=IFCPROPERTYSET('0000000000000000000002',$,'Pset_WallCommon',$,(#1));\n\
#10=IFCWALLTYPE('0000000000000000000010',$,'A',$,$,(#2),$,$,$,.STANDARD.);\n\
#11=IFCWALLTYPE('0000000000000000000011',$,'B',$,$,$,$,$,$,.STANDARD.);\n\
#20=IFCWALL('0000000000000000000020',$,$,$,$,$,$,$,$);\n\
#21=IFCRELDEFINESBYTYPE('0000000000000000000021',$,$,$,(#20),#10);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
);
let text = std::fs::read_to_string(case.definitions(true)).unwrap();
let mut definitions: Value = serde_json::from_str(&text).unwrap();
definitions["objectTypes"]["axioval:example.ifc.wall"]["externalNames"]
.as_array_mut()
.unwrap()
.last_mut()
.unwrap()["name"] = json!("IfcWallType");
let definitions = case.write("definitions.json", &definitions.to_string());
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(format!("{FIXTURES}/ruleset.json"))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["object_id"]["local_id"], "#11", "{result:#}");
assert!(
result["report"]["not_evaluated"]
.as_array()
.unwrap()
.is_empty(),
"{result:#}"
);
}
fn materials_check(case: &Case, materials: bool, extra: &str) -> (Output, PathBuf) {
let model = case.write(
"model.ifc",
&format!("ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCWALL('0000000000000000000001',$,$,$,$,$,$,$,$);\n\
#2=IFCWALL('0000000000000000000002',$,$,$,$,$,$,$,$);\n\
#3=IFCRELCONNECTSPATHELEMENTS('0000000000000000000003',$,$,$,$,#1,#2,(),(),.ATSTART.,.ATEND.);\n\
#5=IFCMATERIAL('Concrete',$,$);\n\
#6=IFCMATERIAL('Steel',$,$);\n\
{extra}ENDSEC;\nEND-ISO-10303-21;\n"
),
);
let text = std::fs::read_to_string(case.definitions(true)).unwrap();
let mut definitions: Value = serde_json::from_str(&text).unwrap();
if materials {
definitions["objectTypes"]["axioval:example.ifc.wall"]["externalNames"]
.as_array_mut()
.unwrap()
.last_mut()
.unwrap()["name"] = json!("IFCMATERIAL");
}
let definitions = case.write("definitions.json", &definitions.to_string());
let bcf = case.path("issues.bcfzip");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(format!("{FIXTURES}/ruleset.json"))
.arg("--bcf")
.arg(&bcf)
.env("SOURCE_DATE_EPOCH", "1790416800")
.output()
.unwrap();
(output, bcf)
}
#[test]
fn a_rule_naming_a_resource_class_checks_its_resource_objects() {
let case = Case::new("resources");
let (output, bcf) = materials_check(&case, true, "");
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
let findings = result["report"]["findings"].as_array().unwrap();
let subjects: Vec<&str> = findings
.iter()
.map(|finding| finding["object_id"]["local_id"].as_str().unwrap())
.collect();
assert_eq!(subjects, ["#5", "#6"], "{result:#}");
let carried: Vec<&str> = result["report"]["resources"]
.as_array()
.unwrap()
.iter()
.map(|resource| resource["kind"].as_str().unwrap())
.collect();
assert_eq!(carried, ["IFCMATERIAL", "IFCMATERIAL"], "{result:#}");
assert_eq!(
result["objects"]["ifc-step:model.ifc/#5"]["kind"], "IFCMATERIAL",
"{result:#}"
);
let archive = openbim_bcf::read_path(&bcf).unwrap();
assert_eq!(archive.topics().count(), 2);
assert!(archive.topics().all(|topic| topic.viewpoints.is_empty()));
}
#[test]
fn a_material_carrying_the_property_in_its_own_set_meets_the_rule() {
let case = Case::new("material-properties");
let (output, _) = materials_check(
&case,
true,
"#7=IFCPROPERTYSINGLEVALUE('Reference',$,IFCLABEL('C30/37'),$);\n\
#8=IFCMATERIALPROPERTIES('Pset_WallCommon',$,(#7),#5);\n",
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
let (findings, open) = subjects_of(&result);
assert_eq!(findings, ["#6"], "{result:#}");
assert!(open.is_empty(), "{result:#}");
}
#[test]
fn a_wall_rule_on_a_model_with_resources_never_sees_them() {
let case = Case::new("resources-walls");
let (output, _) = materials_check(&case, false, "");
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
let subjects: Vec<&str> = result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| finding["object_id"]["local_id"].as_str().unwrap())
.collect();
assert_eq!(subjects, ["#1", "#2"], "{result:#}");
assert!(result["report"].get("resources").is_none(), "{result:#}");
}
fn walls_with_references(values: &[&str]) -> String {
let mut data = String::new();
for (index, value) in values.iter().enumerate() {
let wall = 10 * index + 1;
writeln!(
data,
"#{wall}=IFCWALL('{wall:022}',$,$,$,$,$,$,$,$);\n\
#{}=IFCPROPERTYSINGLEVALUE('Reference',$,{value},$);\n\
#{}=IFCPROPERTYSET('{:022}',$,'Pset_WallCommon',$,(#{}));\n\
#{}=IFCRELDEFINESBYPROPERTIES('{:022}',$,$,$,(#{wall}),#{});",
wall + 1,
wall + 2,
wall + 2,
wall + 1,
wall + 3,
wall + 3,
wall + 2,
)
.unwrap();
}
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n{data}ENDSEC;\nEND-ISO-10303-21;\n"
)
}
fn subjects_of(result: &Value) -> (Vec<String>, Vec<String>) {
let subjects = |outcomes: &str| {
result["report"][outcomes]
.as_array()
.unwrap()
.iter()
.map(|outcome| {
outcome["object_id"]["local_id"]
.as_str()
.unwrap_or("(no object)")
.to_owned()
})
.collect()
};
(subjects("findings"), subjects("not_evaluated"))
}
#[test]
fn a_date_stating_a_time_zone_is_not_the_unzoned_date() {
let case = Case::new("zoned-dates");
let (output, result) = case.geometry_rule(
&walls_with_references(&["IFCDATE('2022-01-01+00:00')", "IFCDATE('2022-01-01')"]),
&[],
"axioval:capability.property-value",
®istry_signature("axioval:capability.property-value"),
entity("wall"),
json!({"property": {"type": "propertyReference",
"property": "axioval:example.ifc.reference",
"propertySet": "axioval:example.ifc.pset-wall-common"},
"values": {"type": "stringList", "value": ["2022-01-01"]}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let (findings, open) = subjects_of(&result);
assert_eq!(findings, ["#1"], "{result:#}");
assert!(open.is_empty(), "{result:#}");
}
#[test]
fn a_measure_of_unknown_unit_is_judged_by_its_declared_type_alone() {
let case = Case::new("unreadable-value");
let (output, result) = case.geometry_rule(
&walls_with_references(&["IFCMASSMEASURE(2.)", "IFCTIMEMEASURE(2.)"]),
&[],
"axioval:capability.property-value",
®istry_signature("axioval:capability.property-value"),
entity("wall"),
json!({"property": {"type": "propertyReference",
"property": "axioval:example.ifc.reference",
"propertySet": "axioval:example.ifc.pset-wall-common"},
"data_type": {"type": "string", "value": "IFCTIMEMEASURE"},
"values": {"type": "stringList", "value": ["2"]},
"si_units": {"type": "boolean", "value": true}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let (findings, open) = subjects_of(&result);
assert_eq!(findings, ["#1"], "{result:#}");
assert_eq!(open, ["#11"], "{result:#}");
assert!(
result["report"]["findings"][0]["message"]
.as_str()
.unwrap()
.contains("is IFCMASSMEASURE, not IFCTIMEMEASURE"),
"{result:#}"
);
}
#[test]
fn a_logical_unknown_holds_no_value() {
let case = Case::new("logical-unknown");
let (output, result) = case.geometry_rule(
&walls_with_references(&["IFCLOGICAL(.U.)", "IFCLOGICAL(.T.)", "IFCLOGICAL(.F.)"]),
&[],
"axioval:capability.property-data-type",
®istry_signature("axioval:capability.property-data-type"),
entity("wall"),
json!({"property": {"type": "propertyReference",
"property": "axioval:example.ifc.reference",
"propertySet": "axioval:example.ifc.pset-wall-common"},
"data_type": {"type": "string", "value": "IFCLOGICAL"}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let (findings, open) = subjects_of(&result);
assert_eq!(findings, ["#1"], "{result:#}");
assert!(open.is_empty(), "{result:#}");
}
#[test]
fn a_complex_property_is_present_but_of_no_data_type() {
let ifc = walls_with_references(&["IFCLABEL('inner')", "IFCLABEL('outer')"]).replace(
"#2=IFCPROPERTYSINGLEVALUE('Reference',$,IFCLABEL('inner'),$);",
"#2=IFCCOMPLEXPROPERTY('Reference',$,'group',(#5));\n\
#5=IFCPROPERTYSINGLEVALUE('Inner',$,IFCLABEL('inner'),$);",
);
let property = json!({"type": "propertyReference",
"property": "axioval:example.ifc.reference",
"propertySet": "axioval:example.ifc.pset-wall-common"});
let (output, result) = Case::new("complex-property").geometry_rule(
&ifc,
&[],
"axioval:capability.property-data-type",
®istry_signature("axioval:capability.property-data-type"),
entity("wall"),
json!({"property": property, "data_type": {"type": "string", "value": "IFCLABEL"}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let (findings, open) = subjects_of(&result);
assert_eq!(findings, ["#1"], "{result:#}");
assert!(open.is_empty(), "{result:#}");
assert!(
result["report"]["findings"][0]["message"]
.as_str()
.unwrap()
.contains("is a complex property, not IFCLABEL"),
"{result:#}"
);
let (output, result) = Case::new("complex-property-required").geometry_rule(
&ifc,
&[],
"axioval:capability.property-required",
®istry_signature("axioval:capability.property-required"),
entity("wall"),
json!({"property": property}),
);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
let (findings, open) = subjects_of(&result);
assert!(findings.is_empty() && open.is_empty(), "{result:#}");
}
#[test]
fn integrity_issues_and_unselectable_objects_are_reported() {
let case = Case::new("integrity");
let bcf = case.path("issues.bcfzip");
let report = case.path("result.json");
let output = case.check(
&ifc("bad", false),
true,
&[
"--bcf",
bcf.to_str().unwrap(),
"--report",
report.to_str().unwrap(),
],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert!(
output.stdout.is_empty(),
"--report moves the JSON off stdout"
);
let result: Value = serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
let integrity = result["integrity"].as_array().unwrap();
assert_eq!(integrity.len(), 1, "{result:#}");
assert_eq!(integrity[0]["code"], axioval::ifc::INVALID_GLOBAL_ID);
assert_eq!(integrity[0]["severity"], "warning");
let stderr = stderr(&output);
assert!(stderr.contains(axioval::ifc::INVALID_GLOBAL_ID), "{stderr}");
assert!(
stderr.contains("model.ifc/#2 has no valid unique GlobalId"),
"{stderr}"
);
}
#[test]
fn source_date_epoch_makes_bcf_output_reproducible() {
let case = Case::new("reproducible");
let bytes = |name: &str| {
let bcf = case.path(name);
let output = case.check(
&ifc("0000000000000000000002", false),
true,
&["--bcf", bcf.to_str().unwrap()],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
std::fs::read(bcf).unwrap()
};
assert_eq!(bytes("a.bcfzip"), bytes("b.bcfzip"));
}
#[test]
fn unusable_input_exits_1_naming_the_file() {
let case = Case::new("errors");
let output = case.check("not a STEP file", true, &[]);
assert_eq!(output.status.code(), Some(1));
assert!(stderr(&output).contains("model.ifc"), "{}", stderr(&output));
let bcf = case.path("issues.bcfzip");
let output = case.check(
&ifc("0000000000000000000002", false),
true,
&["--bcf", bcf.to_str().unwrap(), "--bcf-date", "yesterday"],
);
assert_eq!(output.status.code(), Some(1));
assert!(
stderr(&output).contains("BCF writer refused"),
"{}",
stderr(&output)
);
assert!(!bcf.exists(), "nothing is written when the writer refuses");
assert!(output.stdout.is_empty(), "nor is the JSON report");
}
#[test]
fn bcf_options_without_bcf_output_are_usage_errors() {
let case = Case::new("usage");
let output = case.check(
&ifc("0000000000000000000002", false),
true,
&["--bcf-author", "x"],
);
assert_eq!(output.status.code(), Some(2), "{}", stderr(&output));
}
fn report(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("report")
.args(args)
.output()
.unwrap()
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn many_walls(n: usize) -> String {
let mut walls = String::new();
for i in 1..=n {
let _ = writeln!(walls, "#{i}=IFCWALL('{i:0>22}',$,$,$,$,$,$,$,$);");
}
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n{walls}ENDSEC;\nEND-ISO-10303-21;\n"
)
}
#[test]
fn a_summary_stays_small_and_names_the_next_command() {
let case = Case::new("summary");
let saved = case.path("result.json");
let output = case.check(
&many_walls(200),
true,
&["--summary", "--report", saved.to_str().unwrap()],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let summary = stdout(&output);
assert!(
summary.starts_with("status: findings · 200 finding(s)"),
"{summary}"
);
assert!(summary.contains("wall-reference-required"), "{summary}");
assert!(
summary.contains("e.g. #1 IFCWALL 0000000000000000000001;"),
"{summary}"
);
assert!(summary.contains("+197 more"), "{summary}");
let rule_step = format!(
"axioval report {} --rule wall-reference-required",
saved.display()
);
assert!(summary.contains(&rule_step), "{summary}");
assert!(summary.len() < 1_000, "{} bytes:\n{summary}", summary.len());
assert!(
std::fs::metadata(&saved).unwrap().len() > 20 * summary.len() as u64,
"the full result is on disk, not on stdout"
);
assert!(
!stderr(&output).contains("finding(s)"),
"{}",
stderr(&output)
);
}
fn ten_walls_two_without_reference() -> String {
let mut data = String::new();
for i in 1..=10 {
let _ = writeln!(data, "#{i}=IFCWALL('{i:0>22}',$,$,$,$,$,$,$,$);");
}
for i in 1..=8 {
let (value, set, rel) = (100 + 3 * i, 101 + 3 * i, 102 + 3 * i);
let _ = writeln!(
data,
"#{value}=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('W{i}'),$);\n\
#{set}=IFCPROPERTYSET('{set:0>22}',$,'Pset_WallCommon',$,(#{value}));\n\
#{rel}=IFCRELDEFINESBYPROPERTIES('{rel:0>22}',$,$,$,(#{i}),#{set});"
);
}
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n{data}ENDSEC;\nEND-ISO-10303-21;\n"
)
}
#[test]
fn rule_status_counts_the_checked_and_failed_objects_of_each_rule() {
let case = Case::new("rule-status");
let saved = case.path("result.json");
let saved = saved.to_str().unwrap();
let output = case.check(
&ten_walls_two_without_reference(),
true,
&["--rule-status", "--summary", "--report", saved],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result: Value = serde_json::from_str(&std::fs::read_to_string(saved).unwrap()).unwrap();
assert_eq!(
result["report"]["rules"],
json!([{"rule_id": "wall-reference-required", "checked": 10, "failed": 2,
"not_evaluated": 0, "status": "failed"}]),
"{result:#}"
);
let summary = stdout(&output);
assert!(summary.contains("rules: 1 failed"), "{summary}");
assert!(
summary.contains(
"failed 10 checked · 2 failed · 0 not evaluated wall-reference-required"
),
"{summary}"
);
let output = case.check(&many_walls(0), true, &["--rule-status", "--report", saved]);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
let result: Value = serde_json::from_str(&std::fs::read_to_string(saved).unwrap()).unwrap();
assert_eq!(
result["report"]["rules"][0]["status"], "nothing_selected",
"{result:#}"
);
assert_eq!(result["report"]["rules"][0]["checked"], 0, "{result:#}");
case.check(&many_walls(0), true, &["--report", saved]);
let plain = std::fs::read_to_string(saved).unwrap();
assert!(!plain.contains("\"rules\""), "{plain}");
}
#[test]
fn a_gated_rule_checks_only_the_walls_its_parent_failed_or_is_skipped() {
let case = Case::new("rule-gates");
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let parent = ruleset["root"]["rules"][0].clone();
let mut failed = parent.clone();
failed["id"] = json!("a-recheck-failed-walls");
failed["gate"] = json!({"rule": "wall-reference-required", "condition": "failedObjects"});
let mut if_passed = parent.clone();
if_passed["id"] = json!("b-only-if-passed");
if_passed["gate"] = json!({"rule": "wall-reference-required", "condition": "allIfPassed"});
ruleset["root"]["rules"] = json!([parent, failed, if_passed]);
let ruleset = case.write("gated.json", &ruleset.to_string());
let model = case.write("model.ifc", &ten_walls_two_without_reference());
let definitions = case.definitions(true);
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.arg("--rule-status")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
let rules = &result["report"]["rules"];
assert_eq!(
rules[0],
json!({"rule_id": "a-recheck-failed-walls", "checked": 2, "failed": 2,
"not_evaluated": 0, "status": "failed"}),
"{result:#}"
);
assert_eq!(rules[1]["status"], "skipped", "{result:#}");
}
#[test]
fn walls_a_derived_classification_leaves_unclassified_are_reported() {
let case = Case::new("classifications");
let definitions = case.definitions(true);
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(definitions).unwrap()).unwrap();
definitions["definitions"]["axioval:example.unclassified"] = json!({
"id": "axioval:example.unclassified",
"name": {"default": "Unclassified objects", "translations": {}},
"capability": "axioval:capability.unclassified-object",
"parameters": registry_signature("axioval:capability.unclassified-object"),
"citations": [],
"tags": [],
});
let definitions = case.write("definitions.json", &definitions.to_string());
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("walls-classified");
rule["definitionId"] = json!("axioval:example.unclassified");
rule["parameters"] = json!({"classification": {"type": "string", "value": "wall-kind"}});
ruleset["classifications"] = json!({"wall-kind": {
"id": "wall-kind",
"name": {"default": "Wall kind", "translations": {}},
"rows": [{
"selector": {"kind": "property",
"propertySet": "axioval:example.ifc.pset-wall-common",
"property": "axioval:example.ifc.reference", "operator": "exists"},
"class": "referenced",
}],
}});
let ruleset = case.write("classified.json", &ruleset.to_string());
let model = case.write("model.ifc", &ten_walls_two_without_reference());
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 2, "{result:#}");
assert!(
findings.iter().all(|finding| finding["message"]
.as_str()
.unwrap()
.contains("unclassified")),
"{result:#}"
);
}
#[test]
fn a_summary_without_a_saved_result_says_how_to_get_one() {
let case = Case::new("summary-unsaved");
let output = case.check(&many_walls(2), true, &["--summary"]);
assert_eq!(output.status.code(), Some(3));
assert!(
stdout(&output).contains("rerun with --report <file>"),
"{}",
stdout(&output)
);
}
#[test]
fn a_saved_result_can_be_summarized_and_paged_without_rerunning() {
let case = Case::new("drill");
let saved = case.path("result.json");
let output = case.check(&many_walls(5), true, &["--report", saved.to_str().unwrap()]);
assert_eq!(output.status.code(), Some(3));
let saved = saved.to_str().unwrap();
let result: Value = serde_json::from_str(&std::fs::read_to_string(saved).unwrap()).unwrap();
assert_eq!(
result["objects"]["ifc-step:model.ifc/#2"],
json!({"kind": "IFCWALL", "global_id": "0000000000000000000002"})
);
let summary = report(&[saved, "--json"]);
assert_eq!(summary.status.code(), Some(0), "{}", stderr(&summary));
let summary: Value = serde_json::from_slice(&summary.stdout).unwrap();
assert_eq!(summary["status"], "findings");
assert_eq!(summary["groups"][0]["count"], 5);
let first = report(&[saved, "--rule", "wall-reference-required", "--limit", "2"]);
let text = stdout(&first);
assert!(text.contains("showing 1–2 of 5"), "{text}");
let next = text
.lines()
.find_map(|line| line.strip_prefix("next: axioval report "))
.unwrap_or_else(|| panic!("no next-page hint in:\n{text}"));
let args: Vec<&str> = next.split(' ').collect();
let second = stdout(&report(&args));
assert!(second.contains("showing 3–4 of 5"), "{second}");
assert!(second.contains("#3 IFCWALL"), "{second}");
let by_global_id = report(&[saved, "--object", "0000000000000000000004", "--json"]);
let listing: Value = serde_json::from_slice(&by_global_id.stdout).unwrap();
assert_eq!(listing["total"], 1);
assert_eq!(
listing["entries"][0]["object"],
"#4 IFCWALL 0000000000000000000004"
);
assert!(listing["entries"][0].get("evidence").is_none());
let with_evidence = report(&[saved, "--object", "#4", "--evidence", "--json"]);
let listing: Value = serde_json::from_slice(&with_evidence.stdout).unwrap();
assert!(
listing["entries"][0]["evidence"][0]
.as_str()
.unwrap()
.contains("sha256:")
);
let none = stdout(&report(&[saved, "--code", "identity.invalid-global-id"]));
assert_eq!(none, "no matching entries\n");
}
#[test]
fn reading_a_missing_result_exits_1() {
let output = report(&["/nonexistent/result.json"]);
assert_eq!(output.status.code(), Some(1));
assert!(stderr(&output).contains("/nonexistent/result.json"));
}
#[test]
fn every_hint_runs_unchanged_in_a_shell() {
let case = Case::new("shell");
let saved = case.path("result.json");
let output = case.check(
&many_walls(3),
true,
&["--summary", "--report", saved.to_str().unwrap()],
);
let summary = stdout(&output);
let hint = summary
.lines()
.map(str::trim)
.find(|line| line.contains("--object"))
.unwrap_or_else(|| panic!("no --object hint in:\n{summary}"));
assert!(hint.contains("'#1'"), "{hint}");
let command = hint.replacen("axioval", env!("CARGO_BIN_EXE_axioval"), 1);
let ran = Command::new("sh").arg("-c").arg(&command).output().unwrap();
assert_eq!(ran.status.code(), Some(0), "{}", stderr(&ran));
let listing = stdout(&ran);
assert!(listing.contains("#1 IFCWALL"), "{listing}");
assert!(listing.contains("evidence: "), "{listing}");
assert!(listing.contains("showing 1–1 of 1"), "{listing}");
}
fn crossing_walls() -> String {
let wall = |first: u32, x: f64, y: f64, length: f64, width: f64, global: &str| {
let [p, pos, profile, solid, shape, product, wall] =
[0, 1, 2, 3, 4, 5, 6].map(|offset| first + offset);
format!(
"#{p}=IFCCARTESIANPOINT(({x},{y}));\n\
#{pos}=IFCAXIS2PLACEMENT2D(#{p},$);\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{pos},{length},{width});\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,3.);\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{product}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{wall}=IFCWALL('{global}',$,$,$,$,#3,#{product},$,$);\n"
)
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
{}{}\
#30=IFCBUILDINGELEMENTPROXY('0000000000000000000030',$,$,$,$,#3,$,$,$);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
wall(10, 2.0, 0.0, 4.0, 0.2, "0000000000000000000016"),
wall(20, 2.0, 0.0, 0.2, 4.0, "0000000000000000000026"),
)
}
impl Case {
fn clash_packages(&self) -> (PathBuf, PathBuf) {
let definitions = self.definitions(true);
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(definitions).unwrap()).unwrap();
definitions["definitions"]["axioval:example.clash"] = json!({
"id": "axioval:example.clash",
"name": {"default": "Clash", "translations": {}},
"description": {"default": "Bodies must not interpenetrate.", "translations": {}},
"capability": "axioval:capability.clash",
"parameters": registry_signature("axioval:capability.clash"),
"citations": [],
"tags": [],
});
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("walls-clash");
rule["definitionId"] = json!("axioval:example.clash");
rule["parameters"] = json!({
"counterparts": {"type": "selector", "value": {
"kind": "entityType", "objectType": "axioval:example.ifc.wall",
"includeSubtypes": true}},
"penetration_tolerance_metres": {"type": "number", "value": 0.01},
});
(
self.write("definitions.json", &definitions.to_string()),
self.write("ruleset.json", &ruleset.to_string()),
)
}
fn clash_check(&self, extra: &[&str]) -> Output {
let model = self.write("model.ifc", &crossing_walls());
let (definitions, ruleset) = self.clash_packages();
Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(extra)
.output()
.unwrap()
}
}
#[test]
fn with_geometry_a_clash_between_real_ifc_bodies_is_found() {
let case = Case::new("geometry-clash");
let saved = case.path("result.json");
let output = case.clash_check(&["--geometry", "--report", saved.to_str().unwrap()]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result: Value = serde_json::from_str(&std::fs::read_to_string(&saved).unwrap()).unwrap();
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
let message = findings[0]["message"].as_str().unwrap();
assert!(message.contains("penetration 0.1000 m"), "{message}");
let geometry = &result["geometry"];
assert_eq!(geometry["exact"], 2, "{geometry:#}");
assert_eq!(geometry["tessellated"], 0);
assert_eq!(geometry["unmeasured"][0]["object"]["local_id"], "#30");
assert_eq!(
geometry["unmeasured"][0]["reason"],
"no body representation"
);
assert!(
stderr(&output).contains("#30 was not meshed: no body representation"),
"{}",
stderr(&output)
);
let summary = stdout(&report(&[saved.to_str().unwrap()]));
assert!(
summary.contains("geometry: 2 exact · 0 tessellated"),
"{summary}"
);
assert!(summary.contains("--section geometry"), "{summary}");
let listing = stdout(&report(&[saved.to_str().unwrap(), "--section", "geometry"]));
assert!(
listing.contains("#30 IFCBUILDINGELEMENTPROXY 0000000000000000000030"),
"{listing}"
);
}
fn crossing_walls_with(extra: &str) -> String {
crossing_walls().replace("ENDSEC;\nEND-ISO", &format!("{extra}ENDSEC;\nEND-ISO"))
}
impl Case {
fn wall_clash(&self, model: &str, parameters: &Value) -> (Output, Value) {
let mut bound = json!({
"counterparts": {"type": "selector", "value": entity("wall")},
"penetration_tolerance_metres": {"type": "number", "value": 0.01},
});
for (name, value) in parameters.as_object().unwrap() {
bound[name] = value.clone();
}
self.geometry_rule(
model,
&[],
"axioval:capability.clash",
®istry_signature("axioval:capability.clash"),
entity("wall"),
bound,
)
}
}
#[test]
fn with_geometry_a_measured_extent_selects_walls() {
let case = Case::new("measured-extent");
let (output, result) = case.geometry_rule(
&crossing_walls(),
&[],
"axioval:capability.property-exists",
®istry_signature("axioval:capability.property-exists"),
json!({"kind": "allOf", "operands": [
entity("wall"),
{"kind": "property", "propertySet": "axioval:measured", "property": "extent_x",
"operator": "greaterThan", "value": {"type": "quantity", "value": 1, "unit": "m"}},
]}),
json!({"property": {"type": "propertyReference",
"property": "axioval:example.ifc.reference",
"propertySet": "axioval:example.ifc.pset-wall-common"}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["object_id"]["local_id"], "#16", "{result:#}");
}
#[test]
fn a_storey_is_selected_by_its_height_to_the_next_storey() {
let case = Case::new("level-height");
let model = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCCARTESIANPOINT((0.,0.,3.));\n\
#5=IFCAXIS2PLACEMENT3D(#4,$,$);\n\
#6=IFCLOCALPLACEMENT($,#5);\n\
#7=IFCCARTESIANPOINT((0.,0.,7.));\n\
#8=IFCAXIS2PLACEMENT3D(#7,$,$);\n\
#9=IFCLOCALPLACEMENT($,#8);\n\
#20=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#21=IFCUNITASSIGNMENT((#20));\n\
#22=IFCPROJECT('0000000000000000000022',$,'P',$,$,$,$,$,#21);\n\
#10=IFCBUILDING('0000000000000000000010',$,'B',$,$,#3,$,$,.ELEMENT.,$,$,$);\n\
#11=IFCBUILDINGSTOREY('0000000000000000000011',$,'0',$,$,#3,$,$,.ELEMENT.,0.);\n\
#12=IFCBUILDINGSTOREY('0000000000000000000012',$,'1',$,$,#6,$,$,.ELEMENT.,3.);\n\
#13=IFCBUILDINGSTOREY('0000000000000000000013',$,'2',$,$,#9,$,$,.ELEMENT.,7.);\n\
#14=IFCRELAGGREGATES('0000000000000000000014',$,$,$,#10,(#11,#12,#13));\n\
ENDSEC;\nEND-ISO-10303-21;\n";
let (output, result) = case.geometry_rule(
model,
&[("storey", "IfcBuildingStorey")],
"axioval:capability.property-exists",
®istry_signature("axioval:capability.property-exists"),
json!({"kind": "allOf", "operands": [
entity("storey"),
{"kind": "property", "propertySet": "axioval:measured", "property": "level_height",
"operator": "greaterThan", "value": {"type": "quantity", "value": 3.5, "unit": "m"}},
]}),
json!({"property": {"type": "propertyReference",
"property": "axioval:example.ifc.reference",
"propertySet": "axioval:example.ifc.pset-wall-common"}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["object_id"]["local_id"], "#12", "{result:#}");
}
#[test]
fn with_geometry_a_severity_override_raises_a_clash_involving_a_wall() {
let case = Case::new("clash-severity-override");
let clash = |extra: &Value| {
case.write("model.ifc", &crossing_walls());
case.geometry_rule_with(
&["model.ifc"],
&[],
(
"axioval:capability.clash",
®istry_signature("axioval:capability.clash"),
),
entity("wall"),
json!({
"counterparts": {"type": "selector", "value": entity("wall")},
"penetration_tolerance_metres": {"type": "number", "value": 0.01},
}),
extra,
&[],
)
};
let severities = |result: &Value| -> Vec<String> {
result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| finding["severity"].as_str().unwrap().to_owned())
.collect()
};
let (output, result) = clash(&json!({"severity": "warning"}));
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(severities(&result), ["warning"], "{result:#}");
let (output, result) = clash(&json!({
"severity": "warning",
"severityOverrides": [{"selector": entity("wall"), "severity": "error"}],
}));
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(severities(&result), ["error"], "{result:#}");
}
fn a_duct_through_a_wall_on_a_storey() -> String {
let wall = "IFCWALL('GID',$,$,$,$,PL,REP,$,$)";
let duct = "IFCDUCTSEGMENT('GID',$,$,$,$,PL,REP,$,$)";
let space = "IFCSPACE('GID',$,'101',$,$,PL,REP,$,.ELEMENT.,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}\
#100=IFCBUILDINGSTOREY('0000000000000000000100',$,'Level 1',$,$,$,$,$,.ELEMENT.,0.);\n\
#101=IFCRELAGGREGATES('0000000000000000000101',$,$,$,#100,(#39));\n\
#102=IFCRELCONTAINEDINSPATIALSTRUCTURE('0000000000000000000102',$,$,$,(#19,#29),#100);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [5.0, 5.0, 0.0], [0.2, 8.0, 3.0], wall),
placed_box(20, [5.0, 5.0, 1.0], [4.0, 0.3, 0.3], duct),
placed_box(30, [5.0, 5.0, 0.0], [10.0, 10.0, 3.0], space),
)
}
impl Case {
fn located_clash(&self, args: &[&str]) -> (Output, Value) {
self.write("model.ifc", &a_duct_through_a_wall_on_a_storey());
self.geometry_rule_with(
&["model.ifc"],
&[("duct", "IfcDuctSegment")],
(
"axioval:capability.clash",
®istry_signature("axioval:capability.clash"),
),
entity("duct"),
json!({
"counterparts": {"type": "selector", "value": entity("wall")},
"penetration_tolerance_metres": {"type": "number", "value": 0.01},
}),
&json!({}),
args,
)
}
}
#[test]
fn with_geometry_a_duct_wall_clash_is_located_by_storey_and_derived_space() {
let case = Case::new("clash-located");
let (output, result) = case.located_clash(&["--locate", "geometry"]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
let location = &findings[0]["location"];
assert_eq!(
location["storeys"][0]["id"]["local_id"], "#100",
"{result:#}"
);
assert_eq!(location["storeys"][0]["name"], "Level 1", "{result:#}");
assert_eq!(location["spaces"][0]["id"]["local_id"], "#39", "{result:#}");
assert_eq!(location["spaces"][0]["name"], "101", "{result:#}");
assert!(location.get("unresolved").is_none(), "{result:#}");
let saved = case.path("result.json");
let saved = saved.to_str().unwrap();
let listed = report(&[saved, "--location", "Level 1"]);
assert_eq!(listed.status.code(), Some(0), "{}", stderr(&listed));
let text = stdout(&listed);
assert!(text.contains("[finding] error under-test"), "{text}");
assert!(
text.contains("location: storey Level 1 (#100); space 101 (#39)"),
"{text}"
);
assert!(text.contains("showing 1–1 of 1"), "{text}");
let elsewhere = stdout(&report(&[saved, "--location", "Level 2"]));
assert!(elsewhere.contains("no matching entries"), "{elsewhere}");
let bcf = case.path("located.bcfzip");
let (output, _) = case.located_clash(&[
"--locate",
"geometry",
"--bcf",
bcf.to_str().unwrap(),
"--bcf-date",
"2026-09-27T00:00:00Z",
]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let archive = openbim_bcf::read_slice(&std::fs::read(&bcf).unwrap()).unwrap();
let topic = &archive.topics().next().unwrap().topic;
assert_eq!(
topic.labels,
["under-test", "example", "Storey: Level 1", "Space: 101"],
"{topic:?}"
);
}
#[test]
fn without_locating_the_result_is_byte_identical() {
let case = Case::new("clash-unlocated");
let (output, _) = case.located_clash(&[]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let plain = std::fs::read(case.path("result.json")).unwrap();
let (output, _) = case.located_clash(&["--locate", "none"]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let none = std::fs::read(case.path("result.json")).unwrap();
assert_eq!(plain, none);
assert!(!String::from_utf8(plain).unwrap().contains("location"));
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.current_dir(case.path("."))
.args([
"check",
"--model",
"model.ifc",
"--definitions",
"definitions.json",
])
.args(["--ruleset", "ruleset.json", "--locate", "geometry"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(
stderr(&output).contains("--locate geometry"),
"{}",
stderr(&output)
);
}
#[test]
fn with_geometry_clash_axis_tolerances_are_read_from_the_ifc_bodies() {
let case = Case::new("clash-axis-tolerances");
let (output, result) = case.wall_clash(
&crossing_walls(),
&json!({"horizontal_tolerance_metres": {"type": "number", "value": 0.15},
"vertical_tolerance_metres": {"type": "number", "value": 2.5}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = sorted_findings(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert!(
findings[0]
.1
.contains("reaching 0.2000 m in plan and 3.0000 m vertically"),
"{findings:?}"
);
let (output, result) = case.wall_clash(
&crossing_walls(),
&json!({"vertical_tolerance_metres": {"type": "number", "value": 3.5}}),
);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert!(finding_ids(&result).is_empty(), "{result:#}");
}
#[test]
fn with_geometry_clash_exclusions_read_ifc_systems_and_layers() {
let case = Case::new("clash-exclusions");
let same_system = crossing_walls_with(
"#40=IFCSYSTEM('0000000000000000000040',$,'Structure',$,$);\n\
#41=IFCRELASSIGNSTOGROUP('0000000000000000000041',$,$,$,(#16,#26),$,#40);\n",
);
let by_system = json!({"exclude_paths": {"type": "stringList",
"value": ["IfcRelAssignsToGroup:backward"]}});
let (output, result) = case.wall_clash(&same_system, &by_system);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert!(finding_ids(&result).is_empty(), "{result:#}");
let two_systems = crossing_walls_with(
"#40=IFCSYSTEM('0000000000000000000040',$,'Structure',$,$);\n\
#41=IFCRELASSIGNSTOGROUP('0000000000000000000041',$,$,$,(#16),$,#40);\n\
#42=IFCSYSTEM('0000000000000000000042',$,'Partitions',$,$);\n\
#43=IFCRELASSIGNSTOGROUP('0000000000000000000043',$,$,$,(#26),$,#42);\n",
);
let (output, result) = case.wall_clash(&two_systems, &by_system);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_ids(&result), vec!["#16"], "{result:#}");
let by_layer = json!({"exclude_same_layer": {"type": "boolean", "value": true}});
let one_layer =
crossing_walls_with("#40=IFCPRESENTATIONLAYERASSIGNMENT('A-WALL',$,(#14,#24),$);\n");
let (output, result) = case.wall_clash(&one_layer, &by_layer);
assert_eq!(
output.status.code(),
Some(0),
"{}{result:#}",
stderr(&output)
);
assert!(finding_ids(&result).is_empty(), "{result:#}");
let two_layers = crossing_walls_with(
"#40=IFCPRESENTATIONLAYERASSIGNMENT('A-WALL',$,(#14),$);\n\
#41=IFCPRESENTATIONLAYERASSIGNMENT('S-WALL',$,(#24),$);\n",
);
let (output, result) = case.wall_clash(&two_layers, &by_layer);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_ids(&result), vec!["#16"], "{result:#}");
}
#[test]
fn with_geometry_clashes_of_one_type_pair_are_one_issue() {
let case = Case::new("clash-groups");
let model = walls_file(&[
(10, 2.0, 0.0, 4.0, 0.2, "0000000000000000000016"),
(20, 1.0, 0.0, 0.2, 2.0, "0000000000000000000026"),
(30, 2.0, 0.0, 0.2, 2.0, "0000000000000000000036"),
(40, 3.0, 0.0, 0.2, 2.0, "0000000000000000000046"),
]);
let (output, result) = case.wall_clash(
&model,
&json!({"group_by": {"type": "string", "value": "type_pair"}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["object_id"]["local_id"], "#16");
let related: Vec<&str> = findings[0]["related"]
.as_array()
.unwrap()
.iter()
.map(|object| object["local_id"].as_str().unwrap())
.collect();
assert_eq!(related, vec!["#26", "#36", "#46"]);
assert_eq!(findings[0]["evidence"].as_array().unwrap().len(), 3);
let message = findings[0]["message"].as_str().unwrap();
assert!(
message.starts_with("3 clashes of IFCWALL with IFCWALL: "),
"{message}"
);
}
#[test]
fn with_geometry_clash_severities_grade_the_intersection() {
let case = Case::new("clash-severities");
let graded = |above: f64| {
let (output, result) = case.wall_clash(
&crossing_walls(),
&json!({
"severity_by_class": {"type": "table", "value": [
{"class": {"type": "string", "value": "intersection"},
"severity": {"type": "string", "value": "info"}}]},
"grade_by": {"type": "string", "value": "smallest_extent"},
"severity_grades": {"type": "table", "value": [
{"above": {"type": "number", "value": above},
"severity": {"type": "string", "value": "error"}}]},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = result["report"]["findings"].as_array().unwrap().clone();
assert_eq!(findings.len(), 1, "{result:#}");
(
findings[0]["severity"].as_str().unwrap().to_owned(),
findings[0]["message"].as_str().unwrap().to_owned(),
)
};
let (severity, message) = graded(0.1);
assert_eq!(severity, "error", "{message}");
assert!(
message.ends_with(", graded error by its smallest extent of 0.2000 m"),
"{message}"
);
let (severity, message) = graded(0.3);
assert_eq!(severity, "info", "{message}");
}
fn slab_in_a_turned_wall() -> String {
let body = |first: u32, centre: [f64; 2], size: [f64; 2], depth: f64| {
let [p, pos, profile, solid, shape, definition] =
[0, 1, 2, 3, 4, 5].map(|offset| first + offset);
format!(
"#{p}=IFCCARTESIANPOINT(({:?},{:?}));\n\
#{pos}=IFCAXIS2PLACEMENT2D(#{p},$);\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{pos},{:?},{:?});\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,{depth:?});\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{definition}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n",
centre[0], centre[1], size[0], size[1]
)
};
let (sin, cos) = (std::f64::consts::PI / 6.0).sin_cos();
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCDIRECTION(({cos:?},{sin:?},0.));\n\
#7=IFCAXIS2PLACEMENT3D(#1,#4,#6);\n\
#8=IFCLOCALPLACEMENT($,#7);\n\
#9=IFCCARTESIANPOINT((0.,0.,1.));\n\
#11=IFCAXIS2PLACEMENT3D(#9,#4,#6);\n\
#12=IFCLOCALPLACEMENT($,#11);\n\
#13=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#14=IFCUNITASSIGNMENT((#13));\n\
#15=IFCPROJECT('0000000000000000000015',$,'P',$,$,$,$,(#5),#14);\n\
{}#26=IFCWALL('0000000000000000000026',$,$,$,$,#8,#25,$,$);\n\
{}#36=IFCSLAB('0000000000000000000036',$,$,$,$,#12,#35,$,$);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
body(20, [0.0, 0.0], [4.0, 0.2], 3.0),
body(30, [0.0, 1.59], [3.0, 3.0], 0.2),
)
}
#[test]
fn with_geometry_clash_tolerance_cases_measure_along_the_walls_axes() {
let case = Case::new("clash-tolerance-cases");
let check = |cases: Value| {
let mut parameters = json!({
"counterparts": {"type": "selector", "value": entity("wall")},
"penetration_tolerance_metres": {"type": "number", "value": 0.0},
});
if !cases.is_null() {
parameters["tolerance_cases"] = json!({"type": "table", "value": cases});
}
case.geometry_rule(
&slab_in_a_turned_wall(),
&[("slab", "IfcSlab")],
"axioval:capability.clash",
®istry_signature("axioval:capability.clash"),
entity("slab"),
parameters,
)
};
let orthogonal = |tolerance: f64| {
json!([{
"case": {"type": "string", "value": "horizontal_orthogonal"},
"first_selector": {"type": "selector", "value": entity("slab")},
"second_selector": {"type": "selector", "value": entity("wall")},
"tolerance_metres": {"type": "number", "value": tolerance},
}])
};
let (output, result) = check(orthogonal(0.02));
assert_eq!(
output.status.code(),
Some(0),
"{}{result:#}",
stderr(&output)
);
let (output, result) = check(Value::Null);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_ids(&result), vec!["#36"], "{result:#}");
let (output, _) = check(orthogonal(0.005));
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
}
fn walls_in_two_files(case: &Case, systems: [&str; 2]) {
for (index, (file, (length, width))) in ["one.ifc", "two.ifc"]
.into_iter()
.zip([(4.0, 0.2), (0.2, 4.0)])
.enumerate()
{
let extra = format!(
"#40=IFCSYSTEM('00000000000000000000{index}0',$,'{}',$,$);\n\
#41=IFCRELASSIGNSTOGROUP('00000000000000000000{index}1',$,$,$,(#16),$,#40);\n\
#42=IFCPRESENTATIONLAYERASSIGNMENT('A-WALL',$,(#14),$);\n",
systems[index]
);
let model = walls_file(&[(
10,
2.0,
0.0,
length,
width,
&format!("0000000000000000000{index}16"),
)])
.replace("ENDSEC;\nEND-ISO", &format!("{extra}ENDSEC;\nEND-ISO"));
case.write(file, &model);
}
}
#[test]
fn with_geometry_clash_exclusions_match_systems_by_name_across_files() {
let case = Case::new("clash-federated-exclusions");
let clash = |parameters: Value| {
let mut bound = json!({
"counterparts": {"type": "selector", "value": entity("wall")},
"penetration_tolerance_metres": {"type": "number", "value": 0.01},
});
for (name, value) in parameters.as_object().unwrap() {
bound[name] = value.clone();
}
case.geometry_rule_over(
&["one.ifc", "two.ifc"],
&[],
"axioval:capability.clash",
®istry_signature("axioval:capability.clash"),
entity("wall"),
bound,
)
};
let by_name = json!({
"exclude_paths": {"type": "stringList", "value": ["IfcRelAssignsToGroup:backward"]},
"exclude_target_property": {"type": "propertyReference",
"property": "axioval:example.ifc.name",
"propertySet": "axioval:attributes"},
});
walls_in_two_files(&case, ["SUP-01", "SUP-01"]);
let (output, result) = clash(by_name.clone());
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
walls_in_two_files(&case, ["SUP-01", "RET-01"]);
let (output, result) = clash(by_name);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_ids(&result), vec!["#16"], "{result:#}");
let (output, result) = clash(json!({"exclude_same_layer": {"type": "boolean", "value": true}}));
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_ids(&result), vec!["#16"], "{result:#}");
}
#[test]
fn without_geometry_geometric_rules_are_not_evaluated_and_say_why() {
let case = Case::new("geometry-off");
let saved = case.path("result.json");
let output = case.clash_check(&["--summary", "--report", saved.to_str().unwrap()]);
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
let summary = stdout(&output);
assert!(summary.contains("missing-service"), "{summary}");
assert!(summary.contains("with --geometry"), "{summary}");
let result: Value = serde_json::from_str(&std::fs::read_to_string(&saved).unwrap()).unwrap();
assert!(result.get("geometry").is_none());
}
fn envelope_model() -> String {
let body = |first: u32, x: f64, y: f64, length: f64, width: f64, product: &str| {
let [p, pos, profile, solid, shape, definition, object] =
[0, 1, 2, 3, 4, 5, 6].map(|offset| first + offset);
format!(
"#{p}=IFCCARTESIANPOINT(({x},{y}));\n\
#{pos}=IFCAXIS2PLACEMENT2D(#{p},$);\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{pos},{length},{width});\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,3.);\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{definition}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{object}={product};\n",
product = product.replace("REP", &format!("#{definition}")),
)
};
let wall = |first: u32, x: f64, y: f64, length: f64, width: f64| {
let id = first + 6;
body(
first,
x,
y,
length,
width,
&format!("IFCWALL('00000000000000000000{id}',$,$,$,$,#3,REP,$,$)"),
)
};
let external = |value: &str, first: u32, wall: u32| {
let [single, set, rel] = [0, 1, 2].map(|offset| first + offset);
format!(
"#{single}=IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN({value}),$);\n\
#{set}=IFCPROPERTYSET('00000000000000000000{set}',$,'Pset_WallCommon',$,(#{single}));\n\
#{rel}=IFCRELDEFINESBYPROPERTIES('00000000000000000000{rel}',$,$,$,(#{wall}),#{set});\n"
)
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
{}{}{}{}{}{}{}{}\
#90=IFCZONE('0000000000000000000090',$,'Envelope',$,$,$);\n\
#91=IFCRELASSIGNSTOGROUP('0000000000000000000091',$,$,$,(#16),$,#90);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
body(
10,
5.0,
5.0,
10.0,
10.0,
"IFCSPACE('0000000000000000000016',$,$,$,$,#3,REP,$,.ELEMENT.,$,$)"
),
wall(20, 5.0, 0.15, 10.0, 0.3),
wall(30, 5.0, 5.0, 9.4, 0.2),
wall(40, 5.0, 9.85, 10.0, 0.3),
wall(50, 0.15, 5.0, 0.3, 9.4),
external(".F.", 60, 26),
external(".T.", 70, 36),
external(".T.", 80, 46),
)
}
impl Case {
fn envelope_check(&self, parameters: Value) -> (Output, Value) {
self.geometry_rule(
&envelope_model(),
&[("space", "IfcSpace"), ("zone", "IfcZone")],
"axioval:capability.external-wall-validation",
®istry_signature("axioval:capability.external-wall-validation"),
entity("wall"),
parameters,
)
}
}
fn sorted_findings(result: &Value) -> Vec<(String, String)> {
let mut findings: Vec<(String, String)> = result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| {
(
finding["object_id"]["local_id"]
.as_str()
.unwrap()
.to_owned(),
finding["message"].as_str().unwrap().to_owned(),
)
})
.collect();
findings.sort();
findings
}
#[test]
fn with_geometry_an_envelope_rule_selects_its_bounding_spaces() {
let case = Case::new("geometry-envelope");
let (output, result) = case.envelope_check(json!({
"derivations": {"type": "stringList", "value": ["all-spaces", "gross-area-groups"]},
"bounding_selector": {"type": "selector", "value": entity("space")},
"gross_area_group_selector": {"type": "selector", "value": entity("zone")},
"gross_area_group_path": {"type": "stringList",
"value": ["IfcRelAssignsToGroup:forward"]},
}));
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let mut expected: Vec<(String, String)> = ["all-spaces", "gross-area-groups"]
.iter()
.flat_map(|derivation| {
[
(
"#26".to_owned(),
format!("on the {derivation} envelope but not declared external"),
),
(
"#36".to_owned(),
format!("declared external but not on the {derivation} envelope"),
),
]
})
.collect();
expected.sort();
assert_eq!(sorted_findings(&result), expected, "{result:#}");
let not_evaluated = result["report"]["not_evaluated"].as_array().unwrap();
assert_eq!(not_evaluated.len(), 2, "{result:#}");
for outcome in not_evaluated {
assert_eq!(outcome["object_id"]["local_id"], "#56", "{result:#}");
}
}
#[test]
fn with_geometry_an_envelope_rule_without_bounding_spaces_is_not_evaluated() {
let case = Case::new("geometry-envelope-unbounded");
let (output, result) = case.envelope_check(json!({
"derivations": {"type": "stringList", "value": ["all-spaces"]},
"bounding_selector": {"type": "selector", "value": entity("zone")},
}));
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
assert!(finding_ids(&result).is_empty(), "{result:#}");
let (output, result) = case.envelope_check(json!({
"derivations": {"type": "stringList", "value": ["gross-area-groups"]},
"bounding_selector": {"type": "selector", "value": entity("space")},
}));
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
assert!(
result
.to_string()
.contains("needs `gross_area_group_selector`"),
"{result:#}"
);
}
#[test]
fn the_envelope_zone_flag_is_rejected() {
let case = Case::new("geometry-envelope-flag");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.args(["check", "--model", "m.ifc", "--definitions", "d.json"])
.args([
"--ruleset",
"r.json",
"--geometry",
"--envelope-zone",
"Envelope",
])
.current_dir(case.path(""))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2), "{}", stderr(&output));
}
fn landings() -> String {
let body = |first: u32, x: f64, y: f64, length: f64, width: f64, depth: f64, product: &str| {
let [p, pos, profile, solid, shape, definition, object] =
[0, 1, 2, 3, 4, 5, 6].map(|offset| first + offset);
format!(
"#{p}=IFCCARTESIANPOINT(({x},{y}));\n\
#{pos}=IFCAXIS2PLACEMENT2D(#{p},$);\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{pos},{length},{width});\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,{depth});\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{definition}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{object}={};\n",
product.replace("REP", &format!("#{definition}")),
)
};
let slab = |first: u32, x: f64| {
let id = first + 6;
body(
first,
x,
2.0,
4.0,
4.0,
0.2,
&format!("IFCSLAB('00000000000000000000{id}',$,$,$,$,#3,REP,$,.LANDING.)"),
)
};
let wall = |first: u32, x: f64, y: f64, length: f64, width: f64| {
let id = first + 6;
body(
first,
x,
y,
length,
width,
3.0,
&format!("IFCWALL('00000000000000000000{id}',$,$,$,$,#3,REP,$,$)"),
)
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
{}{}{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
slab(10, 2.0),
slab(20, 22.0),
wall(30, 2.0, 0.0, 4.2, 0.2),
wall(40, 2.0, 4.0, 4.2, 0.2),
wall(50, 0.0, 2.0, 0.2, 4.2),
wall(60, 4.0, 2.0, 0.2, 4.2),
)
}
#[test]
fn with_geometry_an_unguarded_landing_edge_is_found() {
let case = Case::new("geometry-guard");
let definitions = case.definitions(true);
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(definitions).unwrap()).unwrap();
definitions["objectTypes"]["axioval:example.ifc.slab"] = json!({
"id": "axioval:example.ifc.slab",
"name": {"default": "Slab", "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "IfcSlab"}],
"citations": [],
});
let numbers = [
("minimum_barrier_height_metres", 1.0),
("maximum_barrier_gap_metres", 0.1),
("maximum_platform_gap_metres", 0.1),
("maximum_landing_gap_metres", 0.3),
("maximum_fall_height_metres", 0.5),
("minimum_landing_width_metres", 1.0),
("climbable_barrier_distance_metres", 0.3),
("maximum_climbable_height_metres", 0.6),
("minimum_climbable_side_length_metres", 0.1),
];
let declare = |id: &str, kind: &str| {
json!({"id": id, "name": {"default": id, "translations": {}}, "kind": kind,
"required": true, "allowedValues": [], "citations": []})
};
let mut parameters: serde_json::Map<String, Value> = numbers
.iter()
.map(|(id, _)| ((*id).to_owned(), declare(id, "number")))
.collect();
parameters.insert(
"measure_barrier_from_curb".into(),
declare("measure_barrier_from_curb", "boolean"),
);
for role in ["barrier_selector", "landing_selector", "climbable_selector"] {
let mut optional = declare(role, "selector");
optional["required"] = json!(false);
parameters.insert(role.into(), optional);
}
definitions["definitions"]["axioval:example.guard"] = json!({
"id": "axioval:example.guard",
"name": {"default": "Fall protection", "translations": {}},
"description": {"default": "Walking surface edges are guarded.", "translations": {}},
"capability": "axioval:capability.horizontal-guard",
"parameters": parameters,
"citations": [],
"tags": [],
});
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("landings-guarded");
rule["definitionId"] = json!("axioval:example.guard");
let mut values: serde_json::Map<String, Value> = numbers
.iter()
.map(|(id, value)| ((*id).to_owned(), json!({"type": "number", "value": value})))
.collect();
values.insert(
"measure_barrier_from_curb".into(),
json!({"type": "boolean", "value": false}),
);
rule["parameters"] = values.into();
rule["applicability"]["groups"]["walls"]["selector"]["objectType"] =
json!("axioval:example.ifc.slab");
let model = case.write("model.ifc", &landings());
let definitions = case.write("definitions.json", &definitions.to_string());
let ruleset = case.write("ruleset.json", &ruleset.to_string());
let saved = case.path("result.json");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result: Value = serde_json::from_str(&std::fs::read_to_string(&saved).unwrap()).unwrap();
let findings = result["report"]["findings"].as_array().unwrap();
assert!(!findings.is_empty(), "{result:#}");
assert!(
findings
.iter()
.all(|finding| finding["object_id"]["local_id"] == "#26"),
"only the bare slab is unguarded: {result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn body(first: u32, x: f64, length: f64, depth: f64, product: &str) -> String {
let [p, pos, profile, solid, shape, definition, object] =
[0, 1, 2, 3, 4, 5, 6].map(|offset| first + offset);
format!(
"#{p}=IFCCARTESIANPOINT(({x},2.));\n\
#{pos}=IFCAXIS2PLACEMENT2D(#{p},$);\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{pos},{length},4.);\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,{depth});\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{definition}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{object}={};\n",
product.replace("REP", &format!("#{definition}")),
)
}
fn spaces_in_a_zone(bodiless_member: bool) -> String {
let space = |first: u32, x: f64| {
body(
first,
x,
4.0,
3.0,
&format!(
"IFCSPACE('00000000000000000000{:02}',$,$,$,$,#3,REP,$,.ELEMENT.,$,$)",
first + 6
),
)
};
let members = if bodiless_member {
"(#16,#26,#50)"
} else {
"(#16,#26)"
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
{}{}{}\
#50=IFCSPACE('0000000000000000000050',$,$,$,$,#3,$,$,.ELEMENT.,$,$);\n\
#90=IFCZONE('0000000000000000000090',$,'Compartment',$,$,$);\n\
#91=IFCRELASSIGNSTOGROUP('0000000000000000000091',$,$,$,{members},$,#90);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
space(10, 2.0),
space(20, 5.0),
space(40, 22.0),
)
}
fn spaces_on_a_slab() -> String {
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
body(
10,
2.0,
4.0,
3.0,
"IFCSPACE('0000000000000000000016',$,$,$,$,#3,REP,$,.ELEMENT.,$,$)"
),
body(
20,
5.0,
4.0,
3.0,
"IFCSPACE('0000000000000000000026',$,$,$,$,#3,REP,$,.ELEMENT.,$,$)"
),
body(
30,
2.0,
4.0,
0.2,
"IFCSLAB('0000000000000000000036',$,$,$,$,#3,REP,$,.FLOOR.)"
),
)
}
fn plan_coverage(name: &str, model: &str, candidate: (&str, &str)) -> (Output, Value) {
let case = Case::new(name);
let definitions = case.definitions(true);
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(definitions).unwrap()).unwrap();
for (id, name) in [("space", "IfcSpace"), candidate] {
definitions["objectTypes"][format!("axioval:example.ifc.{id}")] = json!({
"id": format!("axioval:example.ifc.{id}"),
"name": {"default": name, "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": name}],
"citations": [],
});
}
let declare = |id: &str, kind: &str, required: bool| {
json!({"id": id, "name": {"default": id, "translations": {}}, "kind": kind,
"required": required, "allowedValues": [], "citations": []})
};
definitions["definitions"]["axioval:example.coverage"] = json!({
"id": "axioval:example.coverage",
"name": {"default": "Coverage", "translations": {}},
"description": {"default": "Spaces lie within a candidate.", "translations": {}},
"capability": "axioval:capability.plan-coverage",
"parameters": {
"candidate_selector": declare("candidate_selector", "selector", true),
"minimum_ratio": declare("minimum_ratio", "number", true),
"relationship": declare("relationship", "string", false),
"direction": declare("direction", "string", false),
"follow_chain": declare("follow_chain", "boolean", false),
"path": declare("path", "stringList", false),
"skip_absent_relationship_ends":
declare("skip_absent_relationship_ends", "boolean", false),
},
"citations": [],
"tags": [],
});
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("spaces-covered");
rule["definitionId"] = json!("axioval:example.coverage");
rule["parameters"] = json!({
"candidate_selector": {"type": "selector", "value": {
"kind": "entityType", "objectType": format!("axioval:example.ifc.{}", candidate.0),
"includeSubtypes": true}},
"minimum_ratio": {"type": "number", "value": 0.9},
});
rule["applicability"]["groups"]["walls"]["selector"]["objectType"] =
json!("axioval:example.ifc.space");
let model = case.write("model.ifc", model);
let definitions = case.write("definitions.json", &definitions.to_string());
let ruleset = case.write("ruleset.json", &ruleset.to_string());
let saved = case.path("result.json");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.output()
.unwrap();
let result = std::fs::read_to_string(&saved)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or(Value::Null);
(output, result)
}
#[test]
fn with_geometry_plan_coverage_measures_real_footprints() {
let (output, result) = plan_coverage(
"geometry-plan-coverage",
&spaces_on_a_slab(),
("slab", "IfcSlab"),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["object_id"]["local_id"], "#26", "{result:#}");
}
#[test]
fn with_geometry_plan_coverage_measures_a_zone_as_the_union_of_its_spaces() {
let (output, result) = plan_coverage(
"geometry-plan-coverage-zone",
&spaces_in_a_zone(false),
("zone", "IfcZone"),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["object_id"]["local_id"], "#46", "{result:#}");
let not_evaluated: Vec<&Value> = result["report"]["not_evaluated"]
.as_array()
.unwrap()
.iter()
.map(|entry| &entry["object_id"]["local_id"])
.collect();
assert_eq!(not_evaluated, [&json!("#50")], "{result:#}");
}
#[test]
fn with_geometry_a_zone_with_a_bodiless_member_has_no_footprint() {
let (output, result) = plan_coverage(
"geometry-plan-coverage-zone-bodiless",
&spaces_in_a_zone(true),
("zone", "IfcZone"),
);
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
assert!(
result["report"]["findings"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
let not_evaluated = result["report"]["not_evaluated"].as_array().unwrap();
assert_eq!(not_evaluated.len(), 4, "{result:#}");
assert!(
not_evaluated
.iter()
.filter(|entry| entry["object_id"]["local_id"] != "#50")
.all(|entry| entry["message"]
.as_str()
.is_some_and(|message| message.contains("has no body"))),
"{result:#}"
);
}
fn plan_area(
name: &str,
model: &str,
subject: (&str, &str),
parameters: &Value,
) -> (Output, Value) {
plan_area_with(name, model, subject, parameters, &json!({}))
}
fn plan_area_with(
name: &str,
model: &str,
subject: (&str, &str),
parameters: &Value,
extra: &Value,
) -> (Output, Value) {
let case = Case::new(name);
let definitions = case.definitions(true);
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(definitions).unwrap()).unwrap();
for (id, name) in [("space", "IfcSpace"), subject] {
definitions["objectTypes"][format!("axioval:example.ifc.{id}")] = json!({
"id": format!("axioval:example.ifc.{id}"),
"name": {"default": name, "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": name}],
"citations": [],
});
}
let declare = |id: &str, kind: &str| {
json!({"id": id, "name": {"default": id, "translations": {}}, "kind": kind,
"required": false, "allowedValues": [], "citations": []})
};
definitions["definitions"]["axioval:example.area"] = json!({
"id": "axioval:example.area",
"name": {"default": "Area", "translations": {}},
"description": {"default": "Plan areas lie within a range.", "translations": {}},
"capability": "axioval:capability.plan-area",
"parameters": {
"minimum": declare("minimum", "number"),
"maximum": declare("maximum", "number"),
"member_selector": declare("member_selector", "selector"),
"measure": declare("measure", "string"),
"relationship": declare("relationship", "string"),
"direction": declare("direction", "string"),
"follow_chain": declare("follow_chain", "boolean"),
"path": declare("path", "stringList"),
"skip_absent_relationship_ends": declare("skip_absent_relationship_ends", "boolean"),
},
"citations": [],
"tags": [],
});
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("areas-bounded");
rule["definitionId"] = json!("axioval:example.area");
rule["parameters"] = parameters.clone();
rule["applicability"]["groups"]["walls"]["selector"]["objectType"] =
json!(format!("axioval:example.ifc.{}", subject.0));
for (field, value) in extra.as_object().into_iter().flatten() {
rule[field] = value.clone();
}
let model = case.write("model.ifc", model);
let definitions = case.write("definitions.json", &definitions.to_string());
let ruleset = case.write("ruleset.json", &ruleset.to_string());
let saved = case.path("result.json");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.output()
.unwrap();
let result = std::fs::read_to_string(&saved)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or(Value::Null);
(output, result)
}
fn flagged_objects(result: &Value, entries: &str) -> Vec<String> {
let mut objects: Vec<String> = result["report"][entries]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.map(|entry| entry["object_id"]["local_id"].as_str().unwrap().to_owned())
.collect();
objects.sort();
objects
}
#[test]
fn with_geometry_plan_area_bounds_each_space_boundary_included() {
let number = |value: f64| json!({"type": "number", "value": value});
let (output, result) = plan_area(
"geometry-plan-area-boundary",
&spaces_in_a_zone(false),
("space", "IfcSpace"),
&json!({"minimum": number(16.0), "maximum": number(16.0)}),
);
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
assert!(
flagged_objects(&result, "findings").is_empty(),
"{result:#}"
);
assert_eq!(
flagged_objects(&result, "not_evaluated"),
["#50"],
"{result:#}"
);
let (output, result) = plan_area(
"geometry-plan-area-exceeded",
&spaces_in_a_zone(false),
("space", "IfcSpace"),
&json!({"maximum": number(15.99)}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
flagged_objects(&result, "findings"),
["#16", "#26", "#46"],
"{result:#}"
);
assert_eq!(
result["report"]["findings"][0]["message"], "plan area is 16 m²; required at most 15.99 m²",
"{result:#}"
);
}
#[test]
fn with_geometry_severity_bands_grade_an_area_by_how_far_it_misses() {
let number = |value: f64| json!({"type": "number", "value": value});
let bands = json!({"severityBands": [
{"below": 0.05, "severity": "info"},
{"below": 0.1, "severity": "warning"},
]});
let severities = |result: &Value| -> Vec<String> {
result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| finding["severity"].as_str().unwrap().to_owned())
.collect()
};
for (name, maximum, severity) in [
("geometry-plan-area-band-info", 15.99, "info"),
("geometry-plan-area-band-warning", 15.0, "warning"),
] {
let (output, result) = plan_area_with(
name,
&spaces_in_a_zone(false),
("space", "IfcSpace"),
&json!({"maximum": number(maximum)}),
&bands,
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(severities(&result), [severity; 3], "{result:#}");
}
}
#[test]
fn with_geometry_plan_area_sums_the_members_of_a_zone() {
let parameters = |maximum: f64| {
json!({
"member_selector": {"type": "selector", "value": {
"kind": "entityType", "objectType": "axioval:example.ifc.space",
"includeSubtypes": true}},
"relationship": {"type": "string", "value": "IfcRelAssignsToGroup"},
"maximum": {"type": "number", "value": maximum},
})
};
let (output, result) = plan_area(
"geometry-plan-area-zone-boundary",
&spaces_in_a_zone(false),
("zone", "IfcZone"),
¶meters(32.0),
);
assert_eq!(
output.status.code(),
Some(0),
"{}\n{result:#}",
stderr(&output)
);
let (output, result) = plan_area(
"geometry-plan-area-zone-exceeded",
&spaces_in_a_zone(false),
("zone", "IfcZone"),
¶meters(31.0),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(flagged_objects(&result, "findings"), ["#90"], "{result:#}");
assert_eq!(
result["report"]["findings"][0]["message"],
"summed plan area of the members is 32 m²; required at most 31 m²",
"{result:#}"
);
let (output, result) = plan_area(
"geometry-plan-area-zone-bodiless",
&spaces_in_a_zone(true),
("zone", "IfcZone"),
¶meters(32.0),
);
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
assert_eq!(
flagged_objects(&result, "not_evaluated"),
["#90"],
"{result:#}"
);
}
fn stacked_slabs() -> String {
let slab = |first: u32, elevation: f64| {
let [
origin,
frame,
placement,
p,
pos,
profile,
solid,
shape,
definition,
object,
] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(|offset| first + offset);
format!(
"#{origin}=IFCCARTESIANPOINT((0.,0.,{elevation:.1}));\n\
#{frame}=IFCAXIS2PLACEMENT3D(#{origin},$,$);\n\
#{placement}=IFCLOCALPLACEMENT($,#{frame});\n\
#{p}=IFCCARTESIANPOINT((5.,4.));\n\
#{pos}=IFCAXIS2PLACEMENT2D(#{p},$);\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{pos},10.,8.);\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,0.2);\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{definition}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{object}=IFCSLAB('00000000000000000000{object:02}',$,$,$,$,#{placement},#{definition},$,.FLOOR.);\n",
)
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
slab(7, 0.0),
slab(17, 3.0),
slab(27, 6.5),
)
}
#[test]
fn with_geometry_slab_stack_spacing_measures_real_elevations() {
let case = Case::new("geometry-slab-stack");
let definitions = case.definitions(true);
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(definitions).unwrap()).unwrap();
definitions["objectTypes"]["axioval:example.ifc.slab"] = json!({
"id": "axioval:example.ifc.slab",
"name": {"default": "IfcSlab", "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "IfcSlab"}],
"citations": [],
});
let declare = |id: &str, kind: &str, required: bool| {
json!({"id": id, "name": {"default": id, "translations": {}}, "kind": kind,
"required": required, "allowedValues": [], "citations": []})
};
definitions["definitions"]["axioval:example.slab-stack"] = json!({
"id": "axioval:example.slab-stack",
"name": {"default": "Slab stack", "translations": {}},
"description": {"default": "Storeys rise at most 3.2 m.", "translations": {}},
"capability": "axioval:capability.slab-stack-spacing",
"parameters": {
"minimum_overlap_ratio": declare("minimum_overlap_ratio", "number", true),
"top_to_top_minimum": declare("top_to_top_minimum", "quantity", false),
"top_to_top_maximum": declare("top_to_top_maximum", "quantity", false),
"bottom_to_bottom_minimum": declare("bottom_to_bottom_minimum", "quantity", false),
"bottom_to_bottom_maximum": declare("bottom_to_bottom_maximum", "quantity", false),
"top_to_bottom_minimum": declare("top_to_bottom_minimum", "quantity", false),
"top_to_bottom_maximum": declare("top_to_bottom_maximum", "quantity", false),
"consistent": declare("consistent", "stringList", false),
"tolerance": declare("tolerance", "quantity", false),
},
"citations": [],
"tags": [],
});
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("slab-stack");
rule["definitionId"] = json!("axioval:example.slab-stack");
rule["parameters"] = json!({
"minimum_overlap_ratio": {"type": "number", "value": 0.5},
"top_to_top_maximum": {"type": "quantity", "value": 3.2, "unit": "m"},
});
rule["applicability"]["groups"]["walls"]["selector"]["objectType"] =
json!("axioval:example.ifc.slab");
let model = case.write("model.ifc", &stacked_slabs());
let definitions = case.write("definitions.json", &definitions.to_string());
let ruleset = case.write("ruleset.json", &ruleset.to_string());
let saved = case.path("result.json");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result: Value = serde_json::from_str(&std::fs::read_to_string(&saved).unwrap()).unwrap();
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["object_id"]["local_id"], "#26", "{result:#}");
}
#[test]
fn an_empty_selection_is_a_source_finding_in_json_summary_listing_and_bcf() {
let case = Case::new("object-count");
let (definitions, ruleset) = case.count_packages();
let model = case.write("model.ifc", &ifc("0000000000000000000002", true));
let saved = case.path("result.json");
let bcf = case.path("issues.bcfzip");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--summary", "--report", saved.to_str().unwrap()])
.args(["--bcf", bcf.to_str().unwrap()])
.env("SOURCE_DATE_EPOCH", "1790416800")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result: Value = serde_json::from_str(&std::fs::read_to_string(&saved).unwrap()).unwrap();
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert!(findings[0].get("object_id").is_none(), "{result:#}");
assert_eq!(findings[0]["source"]["document"], "model.ifc");
assert!(
findings[0]["message"]
.as_str()
.unwrap()
.starts_with("no object matches the selection in source"),
"{result:#}"
);
let summary = stdout(&output);
assert!(
summary.starts_with("status: findings · 1 finding(s)"),
"{summary}"
);
assert!(summary.contains("spaces-exist"), "{summary}");
assert!(
!summary.contains("e.g."),
"no object to give as an example: {summary}"
);
let saved = saved.to_str().unwrap();
let listing = report(&[saved, "--rule", "spaces-exist", "--json"]);
assert_eq!(listing.status.code(), Some(0), "{}", stderr(&listing));
let listing: Value = serde_json::from_slice(&listing.stdout).unwrap();
let entry = &listing["entries"][0];
assert!(entry.get("object").is_none(), "{listing:#}");
assert_eq!(entry["scope"], "source model.ifc");
let text = stdout(&report(&[saved, "--rule", "spaces-exist"]));
assert!(
text.contains("[finding] error spaces-exist (source model.ifc)"),
"{text}"
);
let by_source = report(&[saved, "--object", "model.ifc", "--json"]);
let by_source: Value = serde_json::from_slice(&by_source.stdout).unwrap();
assert_eq!(by_source["total"], 1, "{by_source:#}");
let archive = openbim_bcf::read_path(&bcf).unwrap();
assert!(
archive.diagnostics().is_empty(),
"{:?}",
archive.diagnostics()
);
let topic = &archive.topics().next().unwrap().topic;
assert_eq!(topic.labels, ["spaces-exist", "example"]);
assert!(
topic
.description
.as_deref()
.unwrap()
.contains("Source: ifc-step:model.ifc; no single object"),
"{:?}",
topic.description
);
}
impl Case {
fn count_packages(&self) -> (PathBuf, PathBuf) {
let definitions = self.definitions(true);
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(definitions).unwrap()).unwrap();
definitions["objectTypes"]["axioval:example.ifc.space"] = json!({
"id": "axioval:example.ifc.space",
"name": {"default": "Space", "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "IfcSpace"}],
"citations": [],
});
let parameter = |id: &str, kind: &str| {
json!({"id": id, "name": {"default": id, "translations": {}}, "kind": kind,
"required": false, "allowedValues": [], "citations": []})
};
definitions["definitions"]["axioval:example.count"] = json!({
"id": "axioval:example.count",
"name": {"default": "Object count", "translations": {}},
"description": {"default": "The model has spaces.", "translations": {}},
"capability": "axioval:capability.object-count",
"parameters": {
"minimum": parameter("minimum", "integer"),
"maximum": parameter("maximum", "integer"),
"across_sources": parameter("across_sources", "boolean"),
"disciplines": parameter("disciplines", "stringList"),
},
"citations": [],
"tags": [],
});
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("spaces-exist");
rule["definitionId"] = json!("axioval:example.count");
rule["parameters"] = json!({});
rule["applicability"]["groups"]["walls"]["selector"]["objectType"] =
json!("axioval:example.ifc.space");
(
self.write("definitions.json", &definitions.to_string()),
self.write("ruleset.json", &ruleset.to_string()),
)
}
}
#[test]
fn an_object_count_limited_to_a_discipline_judges_only_its_models() {
let case = Case::new("object-count-disciplines");
let (definitions, ruleset) = case.count_packages();
let mut rules: Value =
serde_json::from_str(&std::fs::read_to_string(&ruleset).unwrap()).unwrap();
rules["root"]["rules"][0]["parameters"] =
json!({"disciplines": {"type": "stringList", "value": ["mep"]}});
let ruleset = case.write("ruleset.json", &rules.to_string());
for name in ["arch.ifc", "mep.ifc", "loose.ifc"] {
case.write(name, &ifc("0000000000000000000002", true));
}
let run = |models: &[&str]| {
let mut command = Command::new(env!("CARGO_BIN_EXE_axioval"));
command.current_dir(&case.dir).arg("check");
for model in models {
command.arg("--model").arg(model);
}
command
.arg("--definitions")
.arg(&definitions)
.arg("--ruleset")
.arg(&ruleset)
.output()
.unwrap()
};
let output = run(&["arch.ifc:architecture", "mep.ifc:mep"]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["source"]["document"], "mep.ifc");
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
let output = run(&["arch.ifc:architecture", "loose.ifc"]);
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
let result = json(&output);
assert_eq!(
result["report"]["not_evaluated"][0]["source"]["document"], "loose.ifc",
"{result:#}"
);
}
fn rooms_without_containment() -> String {
let product = |first: u32, x: f64, length: f64, height: f64, entity: &str| {
body(
first,
x,
length,
height,
&entity.replace("GID", &format!("00000000000000000000{:02}", first + 6)),
)
};
let space = "IFCSPACE('GID',$,$,$,$,#3,REP,$,.ELEMENT.,$,$)";
let furniture = "IFCFURNISHINGELEMENT('GID',$,$,$,$,#3,REP,$)";
let door = "IFCDOOR('GID',$,$,$,$,#3,REP,$,2.1,4.,$,$,$)";
let opening = "IFCOPENINGELEMENT('GID',$,$,$,$,#3,REP,$,.OPENING.)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
{}{}{}{}{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
product(10, 2.0, 4.0, 3.0, space),
product(20, 6.2, 4.0, 3.0, space),
product(30, 1.0, 0.5, 0.9, furniture),
product(40, 3.0, 0.5, 0.9, furniture),
product(50, 7.0, 0.5, 0.9, furniture),
product(60, 4.1, 0.1, 2.1, door),
product(70, -0.1, 0.1, 2.1, door),
product(80, 4.1, 0.2, 2.1, opening),
)
}
fn derived_count(
name: &str,
anchor: (&str, &str),
related: (&str, &str),
relationship: &str,
direction: &str,
minimum: i64,
) -> (Output, Value) {
let case = Case::new(name);
let definitions = case.definitions(true);
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(definitions).unwrap()).unwrap();
for (id, name) in [anchor, related] {
definitions["objectTypes"][format!("axioval:example.ifc.{id}")] = json!({
"id": format!("axioval:example.ifc.{id}"),
"name": {"default": name, "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": name}],
"citations": [],
});
}
let declare = |id: &str, kind: &str| {
json!({"id": id, "name": {"default": id, "translations": {}}, "kind": kind,
"required": false, "allowedValues": [], "citations": []})
};
definitions["definitions"]["axioval:example.related-count"] = json!({
"id": "axioval:example.related-count",
"name": {"default": "Related count", "translations": {}},
"description": {"default": "Each anchor has enough related objects.", "translations": {}},
"capability": "axioval:capability.related-count",
"parameters": {
"related_selector": declare("related_selector", "selector"),
"minimum": declare("minimum", "integer"),
"maximum": declare("maximum", "integer"),
"same_ends": declare("same_ends", "stringList"),
"relationship": declare("relationship", "string"),
"direction": declare("direction", "string"),
"follow_chain": declare("follow_chain", "boolean"),
"path": declare("path", "stringList"),
"skip_absent_relationship_ends":
declare("skip_absent_relationship_ends", "boolean"),
},
"citations": [],
"tags": [],
});
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("derived-count");
rule["definitionId"] = json!("axioval:example.related-count");
rule["parameters"] = json!({
"related_selector": {"type": "selector", "value": {
"kind": "entityType", "objectType": format!("axioval:example.ifc.{}", related.0),
"includeSubtypes": true}},
"minimum": {"type": "integer", "value": minimum},
"relationship": {"type": "string", "value": relationship},
"direction": {"type": "string", "value": direction},
});
rule["applicability"]["groups"]["walls"]["selector"]["objectType"] =
json!(format!("axioval:example.ifc.{}", anchor.0));
let model = case.write("model.ifc", &rooms_without_containment());
let definitions = case.write("definitions.json", &definitions.to_string());
let ruleset = case.write("ruleset.json", &ruleset.to_string());
let saved = case.path("result.json");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.output()
.unwrap();
let result = std::fs::read_to_string(&saved)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or(Value::Null);
(output, result)
}
fn finding_ids(result: &Value) -> Vec<String> {
result["report"]["findings"]
.as_array()
.map(|findings| {
findings
.iter()
.map(|finding| {
finding["object_id"]["local_id"]
.as_str()
.unwrap()
.to_owned()
})
.collect()
})
.unwrap_or_default()
}
#[test]
fn with_geometry_components_are_counted_in_the_space_that_contains_them() {
let (output, result) = derived_count(
"geometry-derived-contained",
("space", "IfcSpace"),
("furniture", "IfcFurnishingElement"),
"axioval:derived.contained-in-space",
"backward",
2,
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_ids(&result), ["#26"], "{result:#}");
let finding = &result["report"]["findings"][0];
assert!(
finding["message"]
.as_str()
.unwrap()
.starts_with("1 related object(s)"),
"{result:#}"
);
assert!(
finding["evidence"]
.as_array()
.unwrap()
.iter()
.all(|item| item["locator"]
.as_str()
.unwrap()
.starts_with("axioval:derived.contained-in-space")),
"{result:#}"
);
}
#[test]
fn with_geometry_a_door_connects_two_spaces_and_an_exit_one() {
let (output, result) = derived_count(
"geometry-derived-adjacent",
("door", "IfcDoor"),
("space", "IfcSpace"),
"axioval:derived.adjacent-space",
"forward",
2,
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_ids(&result), ["#76"], "{result:#}");
let evidence = result["report"]["findings"][0]["evidence"]
.as_array()
.unwrap();
assert!(
evidence.iter().any(|item| {
let locator = item["locator"].as_str().unwrap();
locator.contains("/#76:side=-") && locator.contains(":outside")
}),
"{result:#}"
);
}
#[test]
fn with_geometry_an_opening_void_connects_the_spaces_it_passes_between() {
let (output, result) = derived_count(
"geometry-derived-opening",
("opening", "IfcOpeningElement"),
("space", "IfcSpace"),
"axioval:derived.adjacent-space",
"forward",
2,
);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert!(finding_ids(&result).is_empty(), "{result:#}");
}
#[test]
fn with_geometry_a_malformed_derived_relationship_is_not_evaluated() {
let (output, result) = derived_count(
"geometry-derived-malformed",
("space", "IfcSpace"),
("furniture", "IfcFurnishingElement"),
"axioval:derived.contained-in-space;horizontal=-1",
"backward",
1,
);
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
assert!(finding_ids(&result).is_empty(), "{result:#}");
}
fn registry_signature(capability: &str) -> Value {
let registry = axioval::default_registry().unwrap();
registry
.get(capability)
.unwrap()
.parameters()
.into_iter()
.map(|parameter| {
let id = parameter.name.clone();
let mut declared = json!({"id": id, "name": {"default": id, "translations": {}},
"kind": parameter.parameter_type.package_kind(),
"required": parameter.required, "allowedValues": [], "citations": []});
if let axioval::engine::ParameterType::Table(columns) = parameter.parameter_type {
declared["columns"] = columns
.iter()
.map(|column| {
json!({"id": column.id,
"name": {"default": column.id, "translations": {}},
"kind": column.kind.as_str(), "required": column.required})
})
.collect();
}
(id, declared)
})
.collect::<serde_json::Map<_, _>>()
.into()
}
fn signature(parameters: &[(&str, &str, bool)]) -> Value {
parameters
.iter()
.map(|(id, kind, required)| {
(
(*id).to_owned(),
json!({"id": id, "name": {"default": id, "translations": {}}, "kind": kind,
"required": required, "allowedValues": [], "citations": []}),
)
})
.collect::<serde_json::Map<_, _>>()
.into()
}
impl Case {
fn geometry_rule(
&self,
model: &str,
types: &[(&str, &str)],
capability: &str,
signature: &Value,
applicability: Value,
parameters: Value,
) -> (Output, Value) {
self.write("model.ifc", model);
self.geometry_rule_over(
&["model.ifc"],
types,
capability,
signature,
applicability,
parameters,
)
}
fn geometry_rule_over(
&self,
models: &[&str],
types: &[(&str, &str)],
capability: &str,
signature: &Value,
applicability: Value,
parameters: Value,
) -> (Output, Value) {
self.geometry_rule_with(
models,
types,
(capability, signature),
applicability,
parameters,
&json!({}),
&[],
)
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn geometry_rule_with(
&self,
models: &[&str],
types: &[(&str, &str)],
(capability, signature): (&str, &Value),
applicability: Value,
parameters: Value,
extra: &Value,
args: &[&str],
) -> (Output, Value) {
let definitions = self.definitions(true);
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(definitions).unwrap()).unwrap();
for (id, name) in types {
definitions["objectTypes"][format!("axioval:example.ifc.{id}")] = json!({
"id": format!("axioval:example.ifc.{id}"),
"name": {"default": name, "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": name}],
"citations": [],
});
}
definitions["propertySets"]["axioval:example.ifc.pset-space-common"] = json!({
"id": "axioval:example.ifc.pset-space-common",
"name": {"default": "Pset_SpaceCommon", "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "Pset_SpaceCommon"}],
"citations": [],
});
definitions["propertySets"]["axioval:example.ifc.pset-space-fire-safety"] = json!({
"id": "axioval:example.ifc.pset-space-fire-safety",
"name": {"default": "Pset_SpaceFireSafetyRequirements", "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM,
"name": "Pset_SpaceFireSafetyRequirements"}],
"citations": [],
});
definitions["properties"]["axioval:example.ifc.sprinkler-protection"] = json!({
"id": "axioval:example.ifc.sprinkler-protection",
"name": {"default": "SprinklerProtection", "translations": {}},
"valueKind": "boolean",
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "SprinklerProtection"}],
"citations": [],
});
definitions["properties"]["axioval:example.ifc.is-external"] = json!({
"id": "axioval:example.ifc.is-external",
"name": {"default": "IsExternal", "translations": {}},
"valueKind": "boolean",
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "IsExternal"}],
"citations": [],
});
definitions["propertySets"]["axioval:example.ifc.pset-access"] = json!({
"id": "axioval:example.ifc.pset-access",
"name": {"default": "Access", "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "Access"}],
"citations": [],
});
definitions["properties"]["axioval:example.ifc.clear-width"] = json!({
"id": "axioval:example.ifc.clear-width",
"name": {"default": "ClearWidth", "translations": {}},
"valueKind": "quantity",
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "ClearWidth"}],
"citations": [],
});
definitions["properties"]["axioval:example.ifc.total-thickness"] = json!({
"id": "axioval:example.ifc.total-thickness",
"name": {"default": "TotalThickness", "translations": {}},
"valueKind": "quantity",
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "TotalThickness"}],
"citations": [],
});
declare_name(&mut definitions);
declare_wall_quantities(&mut definitions);
for (id, name) in [("name", "Name"), ("object-type", "ObjectType")] {
definitions["properties"][format!("axioval:example.ifc.{id}")] = json!({
"id": format!("axioval:example.ifc.{id}"),
"name": {"default": name, "translations": {}},
"valueKind": "string",
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": name}],
"citations": [],
});
}
definitions["definitions"]["axioval:example.under-test"] = json!({
"id": "axioval:example.under-test",
"name": {"default": "Under test", "translations": {}},
"description": {"default": "The capability under test.", "translations": {}},
"capability": capability,
"parameters": signature,
"citations": [],
"tags": [],
});
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("under-test");
rule["definitionId"] = json!("axioval:example.under-test");
rule["parameters"] = parameters;
rule["applicability"]["groups"]["walls"]["selector"] = applicability;
for (field, value) in extra.as_object().into_iter().flatten() {
rule[field] = value.clone();
}
let definitions = self.write("definitions.json", &definitions.to_string());
let ruleset = self.write("ruleset.json", &ruleset.to_string());
let saved = self.path("result.json");
let mut command = Command::new(env!("CARGO_BIN_EXE_axioval"));
command.current_dir(&self.dir).arg("check");
for model in models {
command.arg("--model").arg(model);
}
let output = command
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.args(args)
.output()
.unwrap();
let result = std::fs::read_to_string(&saved)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or(Value::Null);
(output, result)
}
}
fn entity(id: &str) -> Value {
json!({"kind": "entityType", "objectType": format!("axioval:example.ifc.{id}"),
"includeSubtypes": true})
}
fn walls_with_openings() -> String {
let gid = |n: u32| format!("{n:0>22}");
let product = |first: u32, x: f64, height: f64, entity: &str| {
body(
first,
x,
0.1,
height,
&entity.replace("GID", &gid(first + 6)),
)
};
let door = "IFCDOOR('GID',$,$,$,$,#3,REP,$,2.1,4.,$,$,$)";
let window = "IFCWINDOW('GID',$,$,$,$,#3,REP,$,1.2,4.,$,$,$)";
let opening = "IFCOPENINGELEMENT('GID',$,$,$,$,#3,REP,$,.OPENING.)";
let mut data = String::new();
for (first, x) in [(10, 2.0), (20, 6.2)] {
data.push_str(&body(
first,
x,
4.0,
3.0,
&format!(
"IFCSPACE('{}',$,$,$,$,#3,REP,$,.ELEMENT.,$,$)",
gid(first + 6)
),
));
}
for (first, void, x, entity, wall) in [
(60, 120, 4.1, door, 200),
(70, 130, -0.1, window, 201),
(90, 140, 8.3, door, 200),
(100, 150, 12.0, window, 201),
(110, 160, 4.1, door, 202),
] {
let height = if entity == window { 1.2 } else { 2.1 };
data.push_str(&product(first, x, height, entity));
data.push_str(&product(void, x, height, opening));
let _ = writeln!(
data,
"#{}=IFCRELVOIDSELEMENT('{}',$,$,$,#{wall},#{});\n\
#{}=IFCRELFILLSELEMENT('{}',$,$,$,#{},#{});",
void + 7,
gid(void + 7),
void + 6,
void + 8,
gid(void + 8),
void + 6,
first + 6,
);
}
for (wall, external) in [(200, Some(".F.")), (201, Some(".T.")), (202, None)] {
let _ = writeln!(data, "#{wall}=IFCWALL('{}',$,$,$,$,#3,$,$,$);", gid(wall));
if let Some(value) = external {
let [single, set, rel] = [10, 20, 30].map(|offset| wall + offset);
let _ = writeln!(
data,
"#{single}=IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN({value}),$);\n\
#{set}=IFCPROPERTYSET('{}',$,'Pset_WallCommon',$,(#{single}));\n\
#{rel}=IFCRELDEFINESBYPROPERTIES('{}',$,$,$,(#{wall}),#{set});",
gid(set),
gid(rel),
);
}
}
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
{data}\
ENDSEC;\nEND-ISO-10303-21;\n"
)
}
#[test]
fn with_geometry_doors_and_windows_connect_the_spaces_their_wall_calls_for() {
let case = Case::new("geometry-opening-spaces");
let (output, result) = case.geometry_rule(
&walls_with_openings(),
&[
("door", "IfcDoor"),
("window", "IfcWindow"),
("space", "IfcSpace"),
],
"axioval:capability.opening-spaces",
&signature(&[
("host_path", "stringList", true),
("host_selector", "selector", true),
("external_property", "propertyReference", true),
("space_path", "stringList", true),
("space_selector", "selector", false),
]),
json!({"kind": "anyOf", "operands": [entity("door"), entity("window")]}),
json!({
"host_path": {"type": "stringList",
"value": ["IfcRelFillsElement:backward", "IfcRelVoidsElement:backward"]},
"host_selector": {"type": "selector", "value": entity("wall")},
"external_property": {"type": "propertyReference",
"property": "axioval:example.ifc.is-external",
"propertySet": "axioval:example.ifc.pset-wall-common"},
"space_path": {"type": "stringList", "value": ["axioval:derived.adjacent-space"]},
"space_selector": {"type": "selector", "value": entity("space")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let mut flagged = finding_ids(&result);
flagged.sort();
assert_eq!(flagged, ["#106", "#96"], "{result:#}");
let message = |id: &str| {
result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.find(|finding| finding["object_id"]["local_id"] == id)
.and_then(|finding| finding["message"].as_str())
.unwrap()
.to_owned()
};
assert!(
message("#96").starts_with(
"relates to 1 space(s) via axioval:derived.adjacent-space; in an internal wall (#200)"
),
"{result:#}"
);
assert!(
message("#106").starts_with("relates to 0 space(s)")
&& message("#106").contains("in an external wall (#201)"),
"{result:#}"
);
let not_evaluated = result["report"]["not_evaluated"].as_array().unwrap();
assert_eq!(not_evaluated.len(), 1, "{result:#}");
assert_eq!(
not_evaluated[0]["object_id"]["local_id"], "#116",
"{result:#}"
);
}
#[test]
fn a_related_step_through_several_relationships_climbs_from_a_door_to_its_wall() {
let run = |case: &str, step: &str| {
Case::new(case).geometry_rule(
&walls_with_openings(),
&[("door", "IfcDoor"), ("window", "IfcWindow")],
"axioval:capability.manual-issue",
&signature(&[
("title", "string", true),
("description", "string", false),
("category", "string", false),
]),
json!({"kind": "allOf", "operands": [
{"kind": "anyOf", "operands": [entity("door"), entity("window")]},
{"kind": "related", "path": [step], "selector": entity("wall")},
]}),
json!({"title": {"type": "string", "value": "in a wall"}}),
)
};
let (output, result) = run(
"related-alternation-hop",
"IfcRelFillsElement|IfcRelVoidsElement:backward",
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
result["report"]["findings"][0]["message"], "in a wall (no object matches the selection)",
"{result:#}"
);
assert!(result["report"]["findings"][0]["object_id"].is_null());
let (output, result) = run(
"related-alternation-chain",
"IfcRelFillsElement|IfcRelVoidsElement:backward+",
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let finding = &result["report"]["findings"][0];
let mut selected: Vec<&str> = std::iter::once(&finding["object_id"])
.chain(finding["related"].as_array().unwrap())
.map(|object| object["local_id"].as_str().unwrap())
.collect();
selected.sort_unstable();
assert_eq!(
selected,
["#106", "#116", "#66", "#76", "#96"],
"{result:#}"
);
}
fn offices_with_windows() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let window = "IFCWINDOW('GID',$,$,$,$,PL,REP,$,1.2,1.,$,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}{}\
#200=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Office'),$);\n\
#201=IFCPROPERTYSET('0000000000000000000201',$,'Pset_SpaceCommon',$,(#200));\n\
#202=IFCRELDEFINESBYPROPERTIES('0000000000000000000202',$,$,$,(#19,#29),#201);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [2.0, 2.0, 0.0], [4.0, 4.0, 3.0], space),
placed_box(20, [6.2, 2.0, 0.5], [4.0, 4.0, 3.0], space),
placed_box(30, [4.1, 2.0, 1.2], [0.1, 1.0, 1.2], window),
placed_box(40, [-0.1, 2.0, 0.8], [0.1, 1.0, 1.2], window),
)
}
#[test]
fn with_geometry_a_window_too_high_above_one_rooms_floor_is_found() {
let case = Case::new("geometry-sill-height");
let reference = json!({"type": "propertyReference",
"property": "axioval:example.ifc.reference",
"propertySet": "axioval:example.ifc.pset-space-common"});
let (output, result) = case.geometry_rule(
&offices_with_windows(),
&[("window", "IfcWindow"), ("space", "IfcSpace")],
"axioval:capability.keyed-limit",
®istry_signature("axioval:capability.keyed-limit"),
entity("window"),
json!({
"limits": {"type": "table", "value": [
{"key_1": {"type": "string", "value": "Office"},
"maximum": {"type": "number", "value": 1.0}},
]},
"quantity": {"type": "string", "value": "sill-height"},
"floor_path": {"type": "stringList", "value": ["axioval:derived.adjacent-space"]},
"key_1": reference,
"key_1_path": {"type": "stringList", "value": ["axioval:derived.adjacent-space"]},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#39", "{result:#}");
assert!(
findings[0].1.starts_with("sill height above the floor of ")
&& findings[0]
.1
.contains("#19 is 1.2 m; required at most 1 m (limit row 0: ")
&& !findings[0].1.contains("#29"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_sill_height_findings_are_categorised_by_the_adjacent_space() {
let case = Case::new("geometry-sill-height-categories");
let reference = json!({"type": "propertyReference",
"property": "axioval:example.ifc.reference",
"propertySet": "axioval:example.ifc.pset-space-common"});
case.write("model.ifc", &offices_with_windows());
let adjacent = json!(["axioval:derived.adjacent-space"]);
let (output, result) = case.geometry_rule_with(
&["model.ifc"],
&[("window", "IfcWindow"), ("space", "IfcSpace")],
(
"axioval:capability.keyed-limit",
®istry_signature("axioval:capability.keyed-limit"),
),
entity("window"),
json!({
"limits": {"type": "table", "value": [
{"key_1": {"type": "string", "value": "Office"},
"maximum": {"type": "number", "value": 1.0}},
]},
"quantity": {"type": "string", "value": "sill-height"},
"floor_path": {"type": "stringList", "value": adjacent},
"key_1": reference,
"key_1_path": {"type": "stringList", "value": adjacent},
}),
&json!({"categories": [{
"propertySet": "axioval:example.ifc.pset-space-common",
"property": "axioval:example.ifc.reference",
"path": adjacent,
}]}),
&[],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#39", "{result:#}");
assert!(
findings[0]
.1
.starts_with("[Office] sill height above the floor of "),
"{result:#}"
);
}
fn halls_with_exits() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let door = "IFCDOOR('GID',$,$,$,$,PL,REP,$,2.1,1.,$,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}{}{}{}\
#200=IFCPROPERTYSINGLEVALUE('SprinklerProtection',$,IFCBOOLEAN(.F.),$);\n\
#201=IFCPROPERTYSET('0000000000000000000201',$,'Pset_SpaceFireSafetyRequirements',$,(#200));\n\
#202=IFCRELDEFINESBYPROPERTIES('0000000000000000000202',$,$,$,(#19),#201);\n\
#210=IFCPROPERTYSINGLEVALUE('SprinklerProtection',$,IFCBOOLEAN(.T.),$);\n\
#211=IFCPROPERTYSET('0000000000000000000211',$,'Pset_SpaceFireSafetyRequirements',$,(#210));\n\
#212=IFCRELDEFINESBYPROPERTIES('0000000000000000000212',$,$,$,(#29),#211);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [10.0, 5.0, 0.0], [20.0, 10.0, 3.0], space),
placed_box(20, [10.0, 25.0, 0.0], [20.0, 10.0, 3.0], space),
placed_box(30, [1.5, 10.05, 0.0], [1.0, 0.1, 2.1], door),
placed_box(40, [4.5, 10.05, 0.0], [1.0, 0.1, 2.1], door),
placed_box(50, [1.5, 30.05, 0.0], [1.0, 0.1, 2.1], door),
placed_box(60, [10.5, 30.05, 0.0], [1.0, 0.1, 2.1], door),
)
}
#[test]
fn with_geometry_exits_too_close_for_their_hall_are_found() {
for (separation, apart) in [
("closest", "2 m apart between closest points"),
("centres", "3 m apart between centres"),
] {
let case = Case::new(&format!("geometry-exit-separation-{separation}"));
let (output, result) = case.geometry_rule(
&halls_with_exits(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.exit-separation",
®istry_signature("axioval:capability.exit-separation"),
entity("space"),
json!({
"exit_path": {"type": "stringList",
"value": ["axioval:derived.adjacent-space:backward"]},
"exit_selector": {"type": "selector", "value": entity("door")},
"separation": {"type": "string", "value": separation},
"flag": {"type": "propertyReference",
"property": "axioval:example.ifc.sprinkler-protection",
"propertySet": "axioval:example.ifc.pset-space-fire-safety"},
"flagged_fraction": {"type": "number", "value": 1.0 / 3.0},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#19", "{result:#}");
assert!(
findings[0].1.contains(apart)
&& findings[0].1.contains(
"required at least 11.1803 m (0.5 × the longest plan diagonal of 22.3607 m, "
)
&& findings[0].1.ends_with("false)"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
}
#[test]
fn with_geometry_an_exit_flag_is_read_from_its_sources_in_order() {
let case = Case::new("geometry-exit-separation-flag-sources");
let source = |path: Option<&str>| {
let mut row = json!({
"property_set": {"type": "string",
"value": "axioval:example.ifc.pset-space-fire-safety"},
"property": {"type": "string", "value": "axioval:example.ifc.sprinkler-protection"},
});
if let Some(path) = path {
row["path"] = json!({"type": "string", "value": path});
}
row
};
let (output, result) = case.geometry_rule(
&halls_with_exits(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.exit-separation",
®istry_signature("axioval:capability.exit-separation"),
entity("space"),
json!({
"exit_path": {"type": "stringList",
"value": ["axioval:derived.adjacent-space:backward"]},
"exit_selector": {"type": "selector", "value": entity("door")},
"flag_sources": {"type": "table", "value": [
source(None),
source(Some("IfcRelContainedInSpatialStructure:backward")),
]},
"flag_default": {"type": "boolean", "value": true},
"flagged_fraction": {"type": "number", "value": 1.0 / 3.0},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#19", "{result:#}");
assert!(findings[0].1.ends_with("false)"), "{result:#}");
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn corridor_and_office_with_windows() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let window = "IFCWINDOW('GID',$,$,$,$,PL,REP,$,1.2,1.,$,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}{}{}\
#200=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Corridor'),$);\n\
#201=IFCPROPERTYSET('0000000000000000000201',$,'Pset_SpaceCommon',$,(#200));\n\
#202=IFCRELDEFINESBYPROPERTIES('0000000000000000000202',$,$,$,(#19),#201);\n\
#210=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Office'),$);\n\
#211=IFCPROPERTYSET('0000000000000000000211',$,'Pset_SpaceCommon',$,(#210));\n\
#212=IFCRELDEFINESBYPROPERTIES('0000000000000000000212',$,$,$,(#29),#211);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [10.0, 1.0, 0.0], [20.0, 2.0, 3.0], space),
placed_box(20, [3.0, 6.5, 0.0], [6.0, 5.0, 3.0], space),
placed_box(30, [20.1, 1.0, 1.0], [0.2, 1.0, 1.2], window),
placed_box(40, [10.5, -0.1, 1.0], [1.0, 0.2, 1.2], window),
placed_box(50, [6.1, 6.5, 1.0], [0.2, 1.0, 1.2], window),
)
}
#[test]
fn with_geometry_a_window_at_a_corridors_end_is_found() {
let case = Case::new("geometry-corridor-end-openings");
let (output, result) = case.geometry_rule(
&corridor_and_office_with_windows(),
&[("window", "IfcWindow"), ("space", "IfcSpace")],
"axioval:capability.corridor-end-openings",
®istry_signature("axioval:capability.corridor-end-openings"),
json!({"kind": "allOf", "operands": [
entity("space"),
{"kind": "property", "propertySet": "axioval:example.ifc.pset-space-common",
"property": "axioval:example.ifc.reference", "operator": "equals",
"value": {"type": "string", "value": "Corridor"}},
]}),
json!({
"opening_path": {"type": "stringList",
"value": ["axioval:derived.adjacent-space:backward"]},
"opening_selector": {"type": "selector", "value": entity("window")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#39", "{result:#}");
assert!(
findings[0]
.1
.starts_with("sits in the end wall of corridor ")
&& findings[0].1.contains("#19")
&& findings[0]
.1
.ends_with("0 m from the wall (20, 0)–(20, 2) and facing 1 m of it"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_property_comparison_finds_components_in_the_same_derived_space() {
let case = Case::new("geometry-same-derived-space");
let (output, result) = case.geometry_rule(
&rooms_without_containment(),
&[("space", "IfcSpace"), ("furniture", "IfcFurnishingElement")],
"axioval:capability.property-comparison",
®istry_signature("axioval:capability.property-comparison"),
entity("furniture"),
json!({
"compared_selector": {"type": "selector", "value": entity("furniture")},
"component_mode": {"type": "string", "value": "same_space"},
"container_selector": {"type": "selector", "value": entity("space")},
"relationship": {"type": "string", "value": "axioval:derived.contained-in-space"},
"direction": {"type": "string", "value": "forward"},
"quantifier": {"type": "string", "value": "count"},
"operator": {"type": "string", "value": "greater_or_equal"},
"target_number": {"type": "number", "value": 1.0},
"factor": {"type": "number", "value": 1.0},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_ids(&result), ["#56"], "{result:#}");
assert!(
result["report"]["findings"][0]["message"]
.as_str()
.unwrap()
.starts_with("count of compared components is 0"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn placed_box(
first: u32,
[x, y, z]: [f64; 3],
[length, width, depth]: [f64; 3],
product: &str,
) -> String {
let [
origin,
frame,
placement,
p,
pos,
profile,
solid,
shape,
definition,
object,
] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(|offset| first + offset);
format!(
"#{origin}=IFCCARTESIANPOINT((0.,0.,{z:.2}));\n\
#{frame}=IFCAXIS2PLACEMENT3D(#{origin},$,$);\n\
#{placement}=IFCLOCALPLACEMENT($,#{frame});\n\
#{p}=IFCCARTESIANPOINT(({x:.2},{y:.2}));\n\
#{pos}=IFCAXIS2PLACEMENT2D(#{p},$);\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{pos},{length:.2},{width:.2});\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,{depth:.2});\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{definition}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{object}={};\n",
product
.replace("GID", &format!("{object:022}"))
.replace("PL", &format!("#{placement}"))
.replace("REP", &format!("#{definition}")),
)
}
fn storeys_with_facades() -> String {
let wall = "IFCWALL('GID',$,$,$,$,PL,REP,$,$)";
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);\n\
#8=IFCUNITASSIGNMENT((#6,#7));\n\
#9=IFCPROJECT('0000000000000000000009',$,'P',$,$,$,$,(#5),#8);\n\
{}{}{}{}{}{}\
#100=IFCBUILDING('0000000000000000000100',$,'B',$,$,#3,$,$,.ELEMENT.,$,$,$);\n\
#101=IFCBUILDINGSTOREY('0000000000000000000101',$,'EG',$,$,#3,$,$,.ELEMENT.,0.);\n\
#102=IFCBUILDINGSTOREY('0000000000000000000102',$,'OG',$,$,#3,$,$,.ELEMENT.,3.);\n\
#103=IFCRELAGGREGATES('0000000000000000000103',$,$,$,#100,(#101,#102));\n\
#104=IFCRELAGGREGATES('0000000000000000000104',$,$,$,#101,(#49));\n\
#105=IFCRELAGGREGATES('0000000000000000000105',$,$,$,#102,(#69));\n\
#106=IFCRELCONTAINEDINSPATIALSTRUCTURE('0000000000000000000106',$,$,$,(#19,#39),#101);\n\
#107=IFCRELCONTAINEDINSPATIALSTRUCTURE('0000000000000000000107',$,$,$,(#59),#102);\n\
#108=IFCRELVOIDSELEMENT('0000000000000000000108',$,$,$,#19,#29);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [5.0, -0.15, 0.0], [10.0, 0.3, 3.0], wall),
placed_box(
20,
[5.0, -0.15, 1.0],
[2.0, 0.5, 1.5],
"IFCOPENINGELEMENT('GID',$,$,$,$,PL,REP,$,.OPENING.)"
),
placed_box(
30,
[5.0, -0.15, 1.0],
[2.0, 0.3, 1.5],
"IFCWINDOW('GID',$,$,$,$,PL,REP,$,1.5,2.,$,$,$)"
),
placed_box(40, [5.0, 2.0, 0.0], [10.0, 4.0, 3.0], space),
placed_box(50, [5.0, -0.15, 3.0], [10.0, 0.3, 3.5], wall),
placed_box(60, [5.0, 2.0, 3.0], [10.0, 4.0, 3.0], space),
)
}
fn finding_messages(result: &Value) -> Vec<(String, String)> {
let mut findings: Vec<(String, String)> = result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| {
(
finding["object_id"]["local_id"]
.as_str()
.unwrap()
.to_owned(),
finding["message"].as_str().unwrap().to_owned(),
)
})
.collect();
findings.sort();
findings
}
fn storey_metric_definitions(case: &Case) -> PathBuf {
let text = |value: &str| json!({"default": value, "translations": {}});
let concept = |name: &str| {
json!({"id": format!("axioval:example.ifc.{name}"), "name": text(name),
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": name}],
"citations": []})
};
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(case.definitions(true)).unwrap()).unwrap();
for name in ["IfcBuilding", "IfcBuildingStorey", "IfcSpace", "IfcWindow"] {
definitions["objectTypes"][format!("axioval:example.ifc.{name}")] = concept(name);
}
let mut elevation = concept("Elevation");
elevation["valueKind"] = json!("quantity");
definitions["properties"]["axioval:example.ifc.Elevation"] = elevation;
let declare = |parameters: &[(&str, &str, bool)]| -> Value {
parameters
.iter()
.chain(&[
("relationship", "string", false),
("direction", "string", false),
("follow_chain", "boolean", false),
("path", "stringList", false),
("skip_absent_relationship_ends", "boolean", false),
])
.map(|(id, kind, required)| {
(
(*id).to_owned(),
json!({"id": id, "name": text(id), "kind": kind,
"required": required, "allowedValues": [], "citations": []}),
)
})
.collect::<serde_json::Map<_, _>>()
.into()
};
let definition = |id: &str, capability: &str, parameters: Value| {
json!({"id": id, "name": text(id), "description": text(id),
"capability": format!("axioval:capability.{capability}"),
"parameters": parameters, "citations": [], "tags": []})
};
definitions["definitions"]["axioval:example.storey-heights"] = definition(
"axioval:example.storey-heights",
"level-spacing",
declare(&[
("member_selector", "selector", true),
("order", "propertyReference", true),
("minimum", "quantity", false),
("maximum", "quantity", false),
("consistent", "boolean", false),
("tolerance", "quantity", false),
("ignore_lowest", "boolean", false),
("ignore_highest", "boolean", false),
("content_path", "stringList", false),
("content_selector", "selector", false),
("space_selector", "selector", false),
("space_path", "stringList", false),
("space_tolerance", "quantity", false),
("space_height", "boolean", false),
("space_elevation", "string", false),
]),
);
definitions["definitions"]["axioval:example.window-to-wall"] = definition(
"axioval:example.window-to-wall",
"area-ratio",
declare(&[
("numerator_selector", "selector", true),
("denominator_selector", "selector", false),
("minimum", "number", false),
("maximum", "number", false),
("numerator_property", "propertyReference", false),
("denominator_property", "propertyReference", false),
("measure", "string", false),
("numerator_measure", "string", false),
("denominator_measure", "string", false),
("numerator_derivation", "string", false),
("empty_numerator_finding", "boolean", false),
("overall_width", "propertyReference", false),
("overall_height", "propertyReference", false),
("light_area_table", "table", false),
("light_type", "propertyReference", false),
("light_type_path", "stringList", false),
("light_size_tolerance", "quantity", false),
("frame_width", "quantity", false),
]),
);
let column = |id: &str, required: bool, dimension: Option<&str>| {
let mut column = json!({"id": id, "name": text(id), "required": required,
"kind": if dimension.is_some() { "quantity" } else { "textPattern" }});
if let Some(dimension) = dimension {
column["unitDimension"] = json!(dimension);
}
column
};
definitions["definitions"]["axioval:example.window-to-wall"]["parameters"]["light_area_table"]
["columns"] = json!([
column("type", false, None),
column("width", true, Some("length")),
column("height", true, Some("length")),
column("light_area", true, Some("area")),
]);
case.write("definitions.json", &definitions.to_string())
}
#[test]
fn with_geometry_storey_heights_and_window_to_wall_ratios_are_measured() {
let case = Case::new("geometry-storey-metrics");
let of = |name: &str| {
json!({"kind": "entityType", "objectType": format!("axioval:example.ifc.{name}"),
"includeSubtypes": true})
};
let text_file = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text_file).unwrap();
let template = ruleset["root"]["rules"][0].clone();
let rule = |id: &str, definition: &str, applies_to: &str, parameters: Value| {
let mut rule = template.clone();
rule["id"] = json!(id);
rule["definitionId"] = json!(definition);
rule["parameters"] = parameters;
rule["applicability"]["groups"]["walls"]["selector"] = of(applies_to);
rule
};
let metres = |value: f64| json!({"type": "quantity", "value": value, "unit": "m"});
ruleset["root"]["rules"] = json!([
rule(
"storey-heights",
"axioval:example.storey-heights",
"IfcBuilding",
json!({
"member_selector": {"type": "selector", "value": of("IfcBuildingStorey")},
"order": {"type": "propertyReference", "property": "axioval:example.ifc.Elevation",
"propertySet": "axioval:attributes"},
"relationship": {"type": "string", "value": "IfcRelAggregates"},
"maximum": metres(3.2),
"content_path": {"type": "stringList",
"value": ["IfcRelContainedInSpatialStructure"]},
"content_selector": {"type": "selector", "value": of("wall")},
"space_selector": {"type": "selector", "value": of("IfcSpace")},
"space_path": {"type": "stringList", "value": ["IfcRelAggregates"]},
"space_tolerance": metres(0.05),
}),
),
rule(
"window-to-wall",
"axioval:example.window-to-wall",
"IfcBuildingStorey",
json!({
"measure": {"type": "string", "value": "facade"},
"numerator_selector": {"type": "selector", "value": of("IfcWindow")},
"denominator_selector": {"type": "selector", "value":
{"kind": "anyOf", "operands": [of("wall"), of("IfcWindow")]}},
"maximum": {"type": "number", "value": 0.09},
"relationship": {"type": "string", "value": "IfcRelContainedInSpatialStructure"},
}),
),
]);
let model = case.write("model.ifc", &storeys_with_facades());
let definitions = storey_metric_definitions(&case);
let ruleset = case.write("ruleset.json", &ruleset.to_string());
let saved = case.path("result.json");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result: Value = serde_json::from_str(&std::fs::read_to_string(&saved).unwrap()).unwrap();
assert_eq!(
finding_messages(&result),
[
(
"#101".to_owned(),
"facade area ratio is 0.0943 (3 m² of 31.8 m²); required at most 0.09".to_owned()
),
(
"#102".to_owned(),
"level height is 3.5 m; required at most 3.2 m".to_owned()
),
(
"#69".to_owned(),
"space height is 3 m and its level's height 3.5 m; they may differ by at most \
0.05 m"
.to_owned()
),
],
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
storey_tables_are_reported(&result, saved.to_str().unwrap());
}
fn storey_tables_are_reported(result: &Value, saved: &str) {
let tables: Vec<(&str, &str, usize)> = result["report"]["tables"]
.as_array()
.unwrap()
.iter()
.map(|table| {
(
table["rule_id"].as_str().unwrap(),
table["name"].as_str().unwrap(),
table["rows"].as_array().unwrap().len(),
)
})
.collect();
assert_eq!(
tables,
[
("storey-heights", "levels", 2),
("storey-heights", "spaces", 2),
("window-to-wall", "ratios", 2),
],
"{result:#}"
);
let summary = stdout(&report(&[saved]));
assert!(
summary.contains("table:\n 2 levels storey-heights\n columns: elevation (m), height (m)"),
"{summary}"
);
assert!(
summary.contains(&format!("axioval report {saved} --section tables\n")),
"{summary}"
);
let listing = stdout(&report(&[
saved,
"--section",
"tables",
"--rule",
"storey-heights",
]));
assert!(
listing.contains("[table] levels storey-heights #102 IFCBUILDINGSTOREY"),
"{listing}"
);
assert!(
listing.contains("elevation 3 m · height 3.5 m"),
"{listing}"
);
assert!(listing.contains("showing 1–4 of 4"), "{listing}");
let listing = report(&[saved, "--section", "tables", "--object", "#101", "--json"]);
let listing: Value = serde_json::from_slice(&listing.stdout).unwrap();
assert_eq!(listing["total"], 2, "{listing:#}");
assert_eq!(listing["entries"][1]["section"], "tables");
assert_eq!(listing["entries"][1]["level"], "ratios");
}
fn walls_file(walls: &[(u32, f64, f64, f64, f64, &str)]) -> String {
walls_of_height(walls, 3.0)
}
fn walls_of_height(walls: &[(u32, f64, f64, f64, f64, &str)], height: f64) -> String {
let mut data = String::new();
for &(first, x, y, length, width, global) in walls {
let [p, pos, profile, solid, shape, product, wall] =
[0, 1, 2, 3, 4, 5, 6].map(|offset| first + offset);
let _ = write!(
data,
"#{p}=IFCCARTESIANPOINT(({x},{y}));\n\
#{pos}=IFCAXIS2PLACEMENT2D(#{p},$);\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{pos},{length},{width});\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,{height:?});\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{product}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{wall}=IFCWALL('{global}',$,$,$,$,#3,#{product},$,$);\n"
);
}
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
{data}ENDSEC;\nEND-ISO-10303-21;\n"
)
}
impl Case {
fn discipline_clash(&self, models: &[&str], extra: &[&str]) -> Output {
self.write(
"arch.ifc",
&walls_file(&[
(10, 2.0, 0.0, 4.0, 0.2, "0000000000000000000A16"),
(20, 2.0, 0.0, 0.2, 4.0, "0000000000000000000A26"),
]),
);
self.write(
"struct.ifc",
&walls_file(&[(10, 0.5, 0.0, 0.2, 4.0, "0000000000000000000S16")]),
);
let (definitions, ruleset) = self.clash_packages();
let mut ruleset: Value =
serde_json::from_str(&std::fs::read_to_string(&ruleset).unwrap()).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["applicability"]["groups"]["walls"]["selector"] = json!({"kind": "allOf", "operands": [
{"kind": "entityType", "objectType": "axioval:example.ifc.wall", "includeSubtypes": true},
{"kind": "discipline", "value": "architecture"},
]});
rule["parameters"]["counterparts"]["value"] =
json!({"kind": "discipline", "value": "structure"});
let ruleset = self.write("ruleset.json", &ruleset.to_string());
let mut command = Command::new(env!("CARGO_BIN_EXE_axioval"));
command.current_dir(&self.dir).arg("check");
for model in models {
command.arg("--model").arg(model);
}
command
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(extra)
.env("SOURCE_DATE_EPOCH", "1790416800")
.output()
.unwrap()
}
}
#[test]
fn a_clash_rule_between_two_disciplines_finds_a_clash_across_files() {
let case = Case::new("discipline-clash");
let saved = case.path("result.json");
let bcf = case.path("issues.bcfzip");
let output = case.discipline_clash(
&["arch.ifc:architecture", "struct.ifc:structure"],
&[
"--geometry",
"--report",
saved.to_str().unwrap(),
"--bcf",
bcf.to_str().unwrap(),
],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result: Value = serde_json::from_str(&std::fs::read_to_string(&saved).unwrap()).unwrap();
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["object_id"]["source"]["document"], "arch.ifc");
assert_eq!(findings[0]["object_id"]["local_id"], "#16");
assert_eq!(
findings[0]["related"][0]["source"]["document"],
"struct.ifc"
);
assert_eq!(findings[0]["related"][0]["local_id"], "#16");
let message = findings[0]["message"].as_str().unwrap();
assert!(message.contains("penetration 0.1000 m"), "{message}");
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
assert_eq!(result["geometry"]["exact"], 3, "{result:#}");
assert_eq!(
result["objects"]["ifc-step:struct.ifc/#16"]["global_id"], "0000000000000000000S16",
"{result:#}"
);
assert!(std::fs::metadata(&bcf).unwrap().len() > 0);
let summary = stdout(&report(&[saved.to_str().unwrap()]));
assert!(summary.contains("arch.ifc/#16"), "{summary}");
let listing = stdout(&report(&[
saved.to_str().unwrap(),
"--object",
"arch.ifc/#16",
]));
assert!(listing.contains("showing 1–1 of 1"), "{listing}");
}
#[test]
fn a_discipline_scoped_rule_over_an_undeclared_model_is_not_evaluated() {
let case = Case::new("discipline-undeclared");
let output = case.discipline_clash(&["arch.ifc:architecture", "struct.ifc"], &["--geometry"]);
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
let result = json(&output);
let outcomes = result["report"]["not_evaluated"].as_array().unwrap();
assert!(
outcomes
.iter()
.any(|outcome| outcome["source"]["document"] == "struct.ifc"
&& outcome["message"]
.as_str()
.unwrap()
.contains("declares no discipline")),
"{result:#}"
);
}
#[test]
fn two_models_with_one_file_name_or_a_bad_discipline_are_refused() {
let case = Case::new("discipline-refused");
std::fs::create_dir_all(case.path("other")).unwrap();
std::fs::write(
case.path("other/arch.ifc"),
walls_file(&[(10, 0.0, 0.0, 1.0, 0.2, "0000000000000000000O16")]),
)
.unwrap();
let output = case.discipline_clash(&["arch.ifc", "other/arch.ifc"], &[]);
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(
stderr(&output).contains("share the file name `arch.ifc`"),
"{}",
stderr(&output)
);
let output = case.discipline_clash(&["arch.ifc:Architecture"], &[]);
assert_eq!(output.status.code(), Some(2), "{}", stderr(&output));
assert!(
stderr(&output).contains("invalid discipline"),
"{}",
stderr(&output)
);
}
fn authored(application: &str, project: &str) -> String {
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCPERSON($,'Doe','Jane',$,$,$,$,$);\n\
#2=IFCORGANIZATION($,'Firm',$,$,$);\n\
#3=IFCPERSONANDORGANIZATION(#1,#2,$);\n\
#4=IFCAPPLICATION(#2,'1','{application}','A');\n\
#5=IFCOWNERHISTORY(#3,#4,$,.ADDED.,$,$,$,0);\n\
#6=IFCPROJECT('0000000000000000000006',#5,'{project}',$,$,$,$,$,$);\n\
#10=IFCWALL('0000000000000000000010',#5,$,$,$,$,$,$,$);\n\
ENDSEC;\nEND-ISO-10303-21;\n"
)
}
impl Case {
fn authored_check(&self, models: &[&str], selector: &Value, extra: &[&str]) -> Output {
self.write(
"arch.ifc",
&authored("Modeller Architecture 2024", "Clinic"),
);
self.write("struct.ifc", &authored("Modeller Structure 2024", "Clinic"));
let definitions = self.definitions(true);
let mut ruleset: Value = serde_json::from_str(
&std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap(),
)
.unwrap();
ruleset["root"]["rules"][0]["applicability"]["groups"]["walls"]["selector"] = json!({
"kind": "allOf",
"operands": [
{"kind": "entityType", "objectType": "axioval:example.ifc.wall", "includeSubtypes": true},
selector,
],
});
let ruleset = self.write("ruleset.json", &ruleset.to_string());
let mut command = Command::new(env!("CARGO_BIN_EXE_axioval"));
command.current_dir(&self.dir).arg("check");
for model in models {
command.arg("--model").arg(model);
}
command
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(extra)
.output()
.unwrap()
}
}
fn finding_documents(result: &Value) -> Vec<String> {
let mut documents: Vec<String> = result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| {
finding["object_id"]["source"]["document"]
.as_str()
.unwrap()
.to_owned()
})
.collect();
documents.sort();
documents
}
#[test]
fn a_source_selector_selects_the_objects_of_models_a_matching_application_wrote() {
let case = Case::new("source-application");
let output = case.authored_check(
&["arch.ifc", "struct.ifc"],
&json!({
"kind": "source",
"field": "application",
"operator": "like",
"value": {"type": "string", "value": "*Architecture*"},
}),
&[],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
assert_eq!(finding_documents(&result), ["arch.ifc"], "{result:#}");
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
let output = case.authored_check(
&["arch.ifc", "struct.ifc"],
&json!({"kind": "anyOf", "operands": [
{
"kind": "source",
"field": "fileName",
"operator": "equals",
"value": {"type": "string", "value": "struct.ifc"},
},
{
"kind": "source",
"field": "schema",
"operator": "equals",
"value": {"type": "string", "value": "IFC2X3"},
},
]}),
&[],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
assert_eq!(finding_documents(&result), ["struct.ifc"], "{result:#}");
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
fn either_discipline() -> Value {
json!({"kind": "anyOf", "operands": [
{"kind": "discipline", "value": "architecture"},
{"kind": "discipline", "value": "structure"},
]})
}
#[test]
fn a_discipline_map_assigns_disciplines_by_application_and_records_how() {
let case = Case::new("discipline-map");
let map = [
"--discipline-map",
"application:*Architecture*=architecture",
"--discipline-map",
"application:*Structure*=structure",
];
let output = case.authored_check(&["arch.ifc", "struct.ifc"], &either_discipline(), &map);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
assert_eq!(
finding_documents(&result),
["arch.ifc", "struct.ifc"],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
assert_eq!(
result["sources"],
json!([
{
"source": "ifc-step:arch.ifc",
"discipline": "architecture",
"discipline_origin": "mapped",
"mapped_by": "application:*Architecture*=architecture",
"mapped_value": "Modeller Architecture 2024",
},
{
"source": "ifc-step:struct.ifc",
"discipline": "structure",
"discipline_origin": "mapped",
"mapped_by": "application:*Structure*=structure",
"mapped_value": "Modeller Structure 2024",
},
]),
"{result:#}"
);
let output = case.authored_check(
&["arch.ifc:structure", "struct.ifc"],
&json!({"kind": "discipline", "value": "architecture"}),
&map[..2],
);
assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
let result = json(&output);
assert_eq!(
result["sources"][0]["discipline"], "structure",
"{result:#}"
);
assert_eq!(result["sources"][0]["discipline_origin"], "declared");
assert_eq!(
result["sources"][1]["unmapped"],
"no rule of the discipline map matches"
);
let outcomes = result["report"]["not_evaluated"].as_array().unwrap();
assert!(
outcomes
.iter()
.any(|outcome| outcome["source"]["document"] == "struct.ifc"
&& outcome["message"]
.as_str()
.unwrap()
.contains("declares no discipline")),
"{result:#}"
);
}
#[test]
fn a_malformed_discipline_map_is_a_usage_error() {
let case = Case::new("discipline-map-usage");
for rule in [
"application*Architecture*=architecture",
"owner:*=architecture",
"application:*=Architecture",
"application:x\\=architecture",
] {
let output = case.authored_check(
&["arch.ifc"],
&either_discipline(),
&["--discipline-map", rule],
);
assert_eq!(output.status.code(), Some(2), "{rule}: {}", stderr(&output));
}
}
impl Case {
fn ruleset_as(&self, package: &str) -> PathBuf {
let mut ruleset: Value = serde_json::from_str(
&std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap(),
)
.unwrap();
ruleset["package"]["id"] = json!(package);
ruleset["root"]["rules"][0]["id"] = json!("r1");
self.write(&format!("{package}.json"), &ruleset.to_string())
}
fn check_rulesets(&self, rulesets: &[PathBuf]) -> Output {
let model = self.write("model.ifc", &ifc("0000000000000000000002", false));
let definitions = self.definitions(true);
let mut command = Command::new(env!("CARGO_BIN_EXE_axioval"));
command
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions);
for ruleset in rulesets {
command.arg("--ruleset").arg(ruleset);
}
command.output().unwrap()
}
}
#[test]
fn several_rulesets_run_in_one_check_under_ids_qualified_by_package() {
let case = Case::new("several-rulesets");
let rulesets = [
case.ruleset_as("org.example.client"),
case.ruleset_as("org.example.discipline"),
];
let output = case.check_rulesets(&rulesets);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
let findings: Vec<(&str, &str)> = result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| {
(
finding["rule_id"].as_str().unwrap(),
finding["object_id"]["local_id"].as_str().unwrap(),
)
})
.collect();
assert_eq!(
findings,
[
("org.example.client/r1", "#2"),
("org.example.discipline/r1", "#2"),
],
"{result:#}"
);
let output = case.check_rulesets(&rulesets[..1]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(json(&output)["report"]["findings"][0]["rule_id"], "r1");
let output = case.check_rulesets(&[rulesets[0].clone(), rulesets[0].clone()]);
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(
stderr(&output).contains("duplicate ruleset package `org.example.client`"),
"{}",
stderr(&output)
);
}
fn discipline_cell(subject: &str, counterpart: &str, tolerance: f64, severity: &str) -> Value {
json!({
"label": {"type": "string", "value": format!("{subject} x {counterpart}")},
"severity": {"type": "string", "value": severity},
"subject_discipline": {"type": "string", "value": subject},
"counterpart_discipline": {"type": "string", "value": counterpart},
"penetration_tolerance_metres": {"type": "number", "value": tolerance},
})
}
impl Case {
fn clash_matrix(&self, cells: &Value, extra: &Value) -> (Output, Value) {
let case = self;
case.write(
"arch.ifc",
&walls_file(&[
(10, 2.0, 0.0, 4.0, 0.2, "0000000000000000000A16"),
(20, 2.0, 20.0, 4.0, 0.2, "0000000000000000000A26"),
]),
);
case.write(
"struct.ifc",
&walls_file(&[(10, 0.5, 0.0, 0.2, 4.0, "0000000000000000000S16")]),
);
case.write(
"mep.ifc",
&walls_file(&[(10, 0.5, 20.0, 0.2, 4.0, "0000000000000000000M16")]),
);
let (definitions, ruleset) = case.clash_packages();
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(&definitions).unwrap()).unwrap();
let definition = &mut definitions["definitions"]["axioval:example.clash"];
definition["capability"] = json!("axioval:capability.clash-matrix");
definition["parameters"] = registry_signature("axioval:capability.clash-matrix");
let mut ruleset: Value =
serde_json::from_str(&std::fs::read_to_string(&ruleset).unwrap()).unwrap();
let mut parameters = json!({
"counterparts": {"type": "selector", "value": entity("wall")},
"cells": {"type": "table", "value": cells},
"system_path": {"type": "string", "value": "IfcRelAssignsToGroup:backward"},
});
for (name, value) in extra.as_object().unwrap() {
parameters[name] = value.clone();
}
ruleset["root"]["rules"][0]["parameters"] = parameters;
let definitions = case.write("definitions.json", &definitions.to_string());
let ruleset = case.write("ruleset.json", &ruleset.to_string());
let saved = case.path("result.json");
let _ = std::fs::remove_file(&saved);
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.current_dir(&case.dir)
.arg("check")
.args(["--model", "arch.ifc:architecture"])
.args(["--model", "struct.ifc:structure"])
.args(["--model", "mep.ifc:mep"])
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.env("SOURCE_DATE_EPOCH", "1790416800")
.output()
.unwrap();
let result = std::fs::read_to_string(&saved)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or(Value::Null);
(output, result)
}
}
#[test]
fn a_clash_matrix_judges_each_discipline_pair_with_its_own_cell_across_files() {
let case = Case::new("clash-matrix");
let (output, result) = case.clash_matrix(
&json!([
discipline_cell("architecture", "structure", 0.05, "warning"),
discipline_cell("architecture", "mep", 0.15, "error"),
]),
&json!({}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
let finding = &findings[0];
assert_eq!(finding["severity"], "warning", "{finding:#}");
let documents = [
finding["object_id"]["source"]["document"].as_str().unwrap(),
finding["related"][0]["source"]["document"]
.as_str()
.unwrap(),
];
assert!(
documents == ["arch.ifc", "struct.ifc"] || documents == ["struct.ifc", "arch.ifc"],
"{finding:#}"
);
let message = finding["message"].as_str().unwrap();
assert!(message.contains("penetration 0.1000 m"), "{message}");
assert!(
message.ends_with("(clash matrix cell 0 `architecture x structure`)"),
"{message}"
);
let (output, result) = case.clash_matrix(
&json!([discipline_cell(
"architecture",
"structure",
0.15,
"warning"
)]),
&json!({"report_unmatched": {"type": "boolean", "value": true}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
let message = findings[0]["message"].as_str().unwrap();
assert!(
message.starts_with("no clash matrix cell covers")
&& message.contains("discipline `mep`")
&& message.contains("discipline `architecture`"),
"{message}"
);
}
fn walls_with_layers() -> String {
let wall = "IFCWALL('GID',$,$,$,$,PL,REP,$,.STANDARD.)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}\
#40=IFCCARTESIANPOINT((10.,0.,0.));\n\
#41=IFCDIRECTION((0.,1.,0.));\n\
#42=IFCAXIS2PLACEMENT3D(#40,#4,#41);\n\
#43=IFCLOCALPLACEMENT($,#42);\n\
#44=IFCCARTESIANPOINT((2.,0.));\n\
#45=IFCAXIS2PLACEMENT2D(#44,$);\n\
#46=IFCRECTANGLEPROFILEDEF(.AREA.,$,#45,4.,0.24);\n\
#47=IFCEXTRUDEDAREASOLID(#46,#2,#4,3.);\n\
#48=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#47));\n\
#50=IFCPRODUCTDEFINITIONSHAPE($,$,(#48));\n\
#49=IFCWALL('0000000000000000000049',$,$,$,$,#43,#50,$,.STANDARD.);\n\
#300=IFCMATERIAL('Concrete',$,$);\n\
#301=IFCMATERIALLAYER(#300,0.3,$,$,$,$,$);\n\
#302=IFCMATERIALLAYERSET((#301),'W300',$);\n\
#303=IFCMATERIALLAYERSETUSAGE(#302,.AXIS2.,.POSITIVE.,0.,$);\n\
#304=IFCRELASSOCIATESMATERIAL('0000000000000000000304',$,$,$,(#19,#29),#303);\n\
#311=IFCMATERIALLAYER(#300,0.24,$,$,$,$,$);\n\
#312=IFCMATERIALLAYERSET((#311),'W240',$);\n\
#313=IFCMATERIALLAYERSETUSAGE(#312,.AXIS2.,.POSITIVE.,0.,$);\n\
#314=IFCRELASSOCIATESMATERIAL('0000000000000000000314',$,$,$,(#49),#313);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [2.0, 0.0, 0.0], [4.0, 0.3, 3.0], wall),
placed_box(20, [2.0, 5.0, 0.0], [4.0, 0.25, 3.0], wall),
)
}
#[test]
fn with_geometry_a_wall_body_is_measured_against_its_layer_thickness() {
let case = Case::new("geometry-layer-thickness");
let (output, result) = case.geometry_rule(
&walls_with_layers(),
&[("wall", "IfcWall")],
"axioval:capability.body-extent",
®istry_signature("axioval:capability.body-extent"),
entity("wall"),
json!({
"axis": {"type": "string", "value": "forward"},
"target_property": {"type": "propertyReference",
"property": "axioval:example.ifc.total-thickness",
"propertySet": "axioval:material"},
"tolerance": {"type": "quantity", "value": 1.0, "unit": "mm"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#29", "{result:#}");
assert!(
findings[0]
.1
.starts_with("body extent along `forward` is 0.25 m; ")
&& findings[0].1.ends_with(" states 0.3 m within 0.001 m"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_meshes_with_too_many_triangles_are_found() {
let case = Case::new("geometry-triangle-count");
let (output, result) = case.geometry_rule(
&walls_with_layers(),
&[("wall", "IfcWall")],
"axioval:capability.triangle-count",
®istry_signature("axioval:capability.triangle-count"),
entity("wall"),
json!({"maximum": {"type": "integer", "value": 11}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 3, "{result:#}");
assert!(
findings
.iter()
.all(|(_, message)| message == "mesh has 12 triangles; at most 11 allowed"),
"{result:#}"
);
}
#[test]
fn with_geometry_walls_are_graded_by_how_much_structure_stands_under_them() {
let case = Case::new("geometry-counterpart-coverage");
case.write(
"arch.ifc",
&walls_file(&[
(10, 2.0, 0.0, 4.0, 0.2, "0000000000000000000A16"),
(20, 2.0, 5.0, 4.0, 0.2, "0000000000000000000A26"),
(30, 2.0, 10.0, 4.0, 0.2, "0000000000000000000A36"),
]),
);
case.write(
"struct.ifc",
&walls_of_height(
&[
(10, 2.0, 0.0, 4.0, 0.2, "0000000000000000000S16"),
(20, 1.0, 10.0, 2.0, 0.2, "0000000000000000000S26"),
],
2.95,
),
);
let walls_of = |discipline: &str| {
json!({"kind": "allOf", "operands": [
entity("wall"), {"kind": "discipline", "value": discipline},
]})
};
let (output, result) = case.geometry_rule_over(
&["arch.ifc:architecture", "struct.ifc:structure"],
&[],
"axioval:capability.counterpart-coverage",
®istry_signature("axioval:capability.counterpart-coverage"),
walls_of("architecture"),
json!({
"counterparts": {"type": "selector", "value": walls_of("structure")},
"horizontal_tolerance": {"type": "quantity", "value": 0.02, "unit": "m"},
"vertical_tolerance": {"type": "quantity", "value": 0.1, "unit": "m"},
"info_above": {"type": "number", "value": 0.01},
"warning_above": {"type": "number", "value": 0.25},
"error_above": {"type": "number", "value": 0.75},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let mut findings: Vec<(String, String, String)> = result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| {
assert_eq!(
finding["object_id"]["source"]["document"], "arch.ifc",
"{finding:#}"
);
(
finding["object_id"]["local_id"]
.as_str()
.unwrap()
.to_owned(),
finding["severity"].as_str().unwrap().to_owned(),
finding["message"].as_str().unwrap().to_owned(),
)
})
.collect();
findings.sort();
assert_eq!(findings.len(), 3, "{result:#}");
assert_eq!(findings[0].0, "#26");
assert_eq!(findings[0].1, "error");
assert!(
findings[0].2.starts_with("height: 1 of the height"),
"{findings:#?}"
);
assert_eq!(findings[1].0, "#26");
assert_eq!(findings[1].1, "error");
assert!(
findings[1]
.2
.starts_with("plan: 1 of the footprint (0.8 of 0.8 m²)"),
"{findings:#?}"
);
assert_eq!(findings[2].0, "#36");
assert_eq!(findings[2].1, "warning");
assert!(
findings[2]
.2
.starts_with("plan: 0.495 of the footprint (0.396 of 0.8 m²)"),
"{findings:#?}"
);
assert_eq!(
result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.find(|finding| finding["object_id"]["local_id"] == "#36")
.unwrap()["related"][0]["source"]["document"],
"struct.ifc"
);
}
#[test]
fn with_geometry_a_wall_over_two_heights_of_structure_is_found_only_in_elevation() {
let case = Case::new("geometry-counterpart-elevation");
case.write(
"arch.ifc",
&walls_file(&[(10, 2.0, 0.0, 4.0, 0.2, "0000000000000000000A16")]),
);
case.write(
"full.ifc",
&walls_file(&[(10, 1.0, 0.0, 2.0, 0.2, "0000000000000000000F16")]),
);
case.write(
"half.ifc",
&walls_of_height(&[(10, 3.0, 0.0, 2.0, 0.2, "0000000000000000000H16")], 1.5),
);
let walls_of = |discipline: &str| {
json!({"kind": "allOf", "operands": [
entity("wall"), {"kind": "discipline", "value": discipline},
]})
};
let check = |measure: &str| {
case.geometry_rule_over(
&[
"arch.ifc:architecture",
"full.ifc:structure",
"half.ifc:structure",
],
&[],
"axioval:capability.counterpart-coverage",
®istry_signature("axioval:capability.counterpart-coverage"),
walls_of("architecture"),
json!({
"counterparts": {"type": "selector", "value": walls_of("structure")},
"tolerance": {"type": "quantity", "value": 0.02, "unit": "m"},
"measure": {"type": "string", "value": measure},
"info_above": {"type": "number", "value": 0.01},
"warning_above": {"type": "number", "value": 0.25},
}),
)
};
let (output, result) = check("plan_and_height");
assert_eq!(output.status.code(), Some(0), "{result:#}");
let (output, result) = check("elevation");
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert!(
findings[0]
.1
.starts_with("elevation: 0.2442 of the elevation (2.9304 of 12 m²)"),
"{findings:#?}"
);
}
fn profiled(first: u32, origin: [f64; 3], points: &[[f64; 2]], product: &str) -> String {
swept(first, origin, points, 1.2, product)
}
fn swept(
first: u32,
[x, y, z]: [f64; 3],
points: &[[f64; 2]],
depth: f64,
product: &str,
) -> String {
let [
location,
position,
placement,
polyline,
profile,
solid,
shape,
definition,
object,
] = [0, 1, 2, 3, 4, 5, 6, 7, 8].map(|offset| first + offset);
let mut text = String::new();
let mut corners = Vec::new();
for (index, [along, up]) in points.iter().chain(points.first()).enumerate() {
let corner = first + 10 + u32::try_from(index).unwrap();
writeln!(text, "#{corner}=IFCCARTESIANPOINT(({along:.3},{up:.3}));").unwrap();
corners.push(format!("#{corner}"));
}
write!(
text,
"#{location}=IFCCARTESIANPOINT(({x:.2},{y:.2},{z:.2}));\n\
#{position}=IFCAXIS2PLACEMENT3D(#{location},#9,#10);\n\
#{placement}=IFCLOCALPLACEMENT($,#2);\n\
#{polyline}=IFCPOLYLINE(({}));\n\
#{profile}=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#{polyline});\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#{position},#4,{depth:.3});\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{definition}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{object}={};\n",
corners.join(","),
product
.replace("GID", &format!("{object:022}"))
.replace("PL", &format!("#{placement}"))
.replace("REP", &format!("#{definition}")),
)
.unwrap();
text
}
fn stair_profile(risers: &[f64]) -> Vec<[f64; 2]> {
let top: f64 = risers.iter().sum();
let mut points = vec![
[0.0, 0.0],
[0.28 * f64::from(u32::try_from(risers.len()).unwrap()), 0.0],
];
points.push([points[1][0], top]);
let mut elevation = top;
for step in (0..risers.len()).rev() {
let front = 0.28 * f64::from(u32::try_from(step).unwrap());
points.push([front, elevation]);
elevation -= risers[step];
if step > 0 {
points.push([front, elevation]);
}
}
points
}
fn ramp_profile(length: f64) -> Vec<[f64; 2]> {
vec![
[0.0, 0.0],
[length + 2.0, 0.0],
[length + 2.0, 0.6],
[length + 1.0, 0.6],
[1.0, 0.1],
[0.0, 0.1],
]
}
fn stairs_and_ramps() -> String {
let flight = "IFCSTAIRFLIGHT('GID',$,$,$,$,PL,REP,$,$,$,$,$,$)";
let ramp = "IFCRAMPFLIGHT('GID',$,$,$,$,PL,REP,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
profiled(100, [0.0, 0.0, 0.0], &stair_profile(&[0.17; 4]), flight),
profiled(
200,
[0.0, 5.0, 0.0],
&stair_profile(&[0.17, 0.17, 0.21, 0.17]),
flight
),
profiled(300, [5.0, 0.0, 0.0], &ramp_profile(6.0), ramp),
profiled(400, [5.0, 5.0, 0.0], &ramp_profile(3.0), ramp),
placed_box(
500,
[0.56, -0.6, 2.5],
[0.3, 2.0, 0.3],
"IFCBEAM('GID',$,$,$,$,PL,REP,$,$)"
),
)
}
#[test]
fn with_geometry_a_sprinkler_is_measured_to_the_sloped_slab_right_above_it() {
let case = Case::new("geometry-distance-surfaces");
let model = format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
profiled(
100,
[0.0, 1.0, 0.0],
&[[0.0, 3.0], [4.0, 4.0], [4.0, 4.2], [0.0, 3.2]],
"IFCSLAB('GID',$,$,$,$,PL,REP,$,$)",
),
placed_box(
200,
[2.0, 0.5, 2.5],
[0.2, 0.2, 0.1],
"IFCBUILDINGELEMENTPROXY('GID',$,$,$,$,PL,REP,$,$)",
),
);
let check = |surfaces: bool| {
let mut parameters = json!({
"counterparts": {"type": "selector", "value": entity("slab")},
"maximum_metres": {"type": "number", "value": 0.5},
"projection": {"type": "string", "value": "vertical"},
"vertical_direction": {"type": "string", "value": "above"},
});
if surfaces {
parameters["subject_surface"] = json!({"type": "string", "value": "top"});
parameters["counterpart_surface"] = json!({"type": "string", "value": "nearest"});
}
case.geometry_rule(
&model,
&[
("slab", "IfcSlab"),
("sprinkler", "IfcBuildingElementProxy"),
],
"axioval:capability.distance",
®istry_signature("axioval:capability.distance"),
entity("sprinkler"),
parameters,
)
};
let (output, result) = check(false);
assert_eq!(output.status.code(), Some(0), "{result:#}");
let (output, result) = check(true);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert!(
findings[0].1.contains(
"vertical distance from its top to the nearest surface 0.8750 m above, farther \
than the allowed 0.5000 m"
),
"{findings:#?}"
);
}
#[test]
fn with_geometry_an_irregular_riser_and_too_little_headroom_are_found() {
let case = Case::new("geometry-stair-flights");
let (output, result) = case.geometry_rule(
&stairs_and_ramps(),
&[("flight", "IfcStairFlight"), ("beam", "IfcBeam")],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("flight"),
json!({
"riser_minimum": {"type": "quantity", "value": 14, "unit": "cm"},
"riser_maximum": {"type": "quantity", "value": 19, "unit": "cm"},
"riser_tolerance": {"type": "quantity", "value": 5, "unit": "mm"},
"going_minimum": {"type": "quantity", "value": 26, "unit": "cm"},
"minimum_headroom": {"type": "quantity", "value": 2, "unit": "m"},
"headroom_obstacles": {"type": "selector", "value": entity("beam")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 3, "{result:#}");
assert_eq!(findings[0].0, "#108", "{result:#}");
assert!(
findings[0]
.1
.starts_with("headroom above the walking surface is 1.99 m under ")
&& findings[0].1.ends_with("#509; at least 2 m required"),
"{result:#}"
);
assert_eq!(
findings[1..],
[
(
"#208".to_owned(),
"riser 3 of 4 is 0.21 m; 0.14 m to 0.19 m required".to_owned()
),
(
"#208".to_owned(),
"risers differ by 0.04 m (0.17 m, 0.17 m, 0.21 m, 0.17 m); at most 0.005 m \
allowed"
.to_owned()
),
],
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_a_ramp_too_steep_for_its_run_is_found() {
let case = Case::new("geometry-ramps");
let (output, result) = case.geometry_rule(
&stairs_and_ramps(),
&[("ramp", "IfcRampFlight")],
"axioval:capability.ramp-geometry",
®istry_signature("axioval:capability.ramp-geometry"),
entity("ramp"),
json!({
"slope_limits": {"type": "table", "value": [
{"maximum_slope": {"type": "number", "value": 0.0834},
"maximum_rise": {"type": "quantity", "value": 0.76, "unit": "m"}},
]},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#408".to_owned(),
"run 1 of 1 rises 0.5 m over 3 m, a slope of 0.166667; required slope at most \
0.0834 rising at most 0.76 m"
.to_owned()
)],
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn landings_and_soffits() -> String {
let flight = "IFCSTAIRFLIGHT('GID',$,$,$,$,PL,REP,$,$,$,$,$,$)";
let soffit = [
[0.0, 0.0],
[1.12, 0.4],
[1.12, 0.68],
[0.84, 0.68],
[0.84, 0.51],
[0.56, 0.51],
[0.56, 0.34],
[0.28, 0.34],
[0.28, 0.17],
[0.0, 0.17],
];
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
profiled(100, [0.0, 0.0, 0.0], &stair_profile(&[0.17; 4]), flight),
placed_box(
200,
[1.52, -0.6, 0.48],
[0.8, 1.2, 0.2],
"IFCSLAB('GID',$,$,$,$,PL,REP,$,.LANDING.)"
),
placed_box(
300,
[0.0, -0.6, -0.2],
[4.0, 3.0, 0.2],
"IFCSLAB('GID',$,$,$,$,PL,REP,$,.FLOOR.)"
),
profiled(400, [5.0, 0.0, 1.0], &soffit, flight),
placed_box(
500,
[5.56, -0.6, 0.0],
[3.0, 3.0, 3.0],
"IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)"
),
)
}
#[test]
fn with_geometry_a_shallow_landing_and_a_low_soffit_are_found() {
let case = Case::new("geometry-stair-landings");
let (output, result) = case.geometry_rule(
&landings_and_soffits(),
&[
("flight", "IfcStairFlight"),
("slab", "IfcSlab"),
("space", "IfcSpace"),
],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("flight"),
json!({
"width_minimum": {"type": "quantity", "value": 1.2, "unit": "m"},
"landing_objects": {"type": "selector", "value": entity("slab")},
"landing_depth_minimum": {"type": "quantity", "value": 1.2, "unit": "m"},
"landing_at_least_walking_width": {"type": "boolean", "value": true},
"minimum_headroom_below": {"type": "quantity", "value": 2, "unit": "m"},
"headroom_below_spaces": {"type": "selector", "value": entity("space")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 2, "{result:#}");
assert_eq!(
findings[0],
(
"#108".to_owned(),
"the landing at the top of the flight is 1.08 m deep; at least 1.2 m and the \
flight's width (1.2 m) required"
.to_owned()
),
"{result:#}"
);
assert_eq!(findings[1].0, "#408", "{result:#}");
assert!(
findings[1]
.1
.starts_with("headroom below the flight is 1 m over the floor of ")
&& findings[1].1.ends_with("#509; at least 2 m required"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn rail_profile(height: f64) -> Vec<[f64; 2]> {
let top = [
[-0.3, 0.17 + height],
[0.0, 0.17 + height],
[0.84, 0.68 + height],
[1.14, 0.68 + height],
];
let mut points: Vec<[f64; 2]> = top.iter().map(|[x, z]| [*x, z - 0.05]).collect();
points.extend(top.iter().rev());
points
}
fn handrails_and_ramp_ends() -> String {
let flight = "IFCSTAIRFLIGHT('GID',$,$,$,$,PL,REP,$,$,$,$,$,$)";
let rail = "IFCRAILING('GID',$,$,$,$,PL,REP,$,.HANDRAIL.)";
let ramp = "IFCRAMPFLIGHT('GID',$,$,$,$,PL,REP,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
profiled(100, [0.0, 0.0, 0.0], &stair_profile(&[0.17; 4]), flight),
swept(200, [0.0, 0.05, 0.0], &rail_profile(0.9), 0.05, rail),
swept(300, [0.0, -1.2, 0.0], &rail_profile(0.75), 0.05, rail),
profiled(400, [5.0, 0.0, 0.0], &ramp_profile(6.0), ramp),
placed_box(
500,
[5.2, -0.6, 0.1],
[0.3, 0.3, 0.8],
"IFCFURNISHINGELEMENT('GID',$,$,$,$,PL,REP,$)"
),
)
}
#[test]
fn with_geometry_a_handrail_too_low_above_the_nosing_line_is_found() {
let case = Case::new("geometry-stair-handrails");
let (output, result) = case.geometry_rule(
&handrails_and_ramp_ends(),
&[("flight", "IfcStairFlight"), ("rail", "IfcRailing")],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("flight"),
json!({
"handrail_objects": {"type": "selector", "value": entity("rail")},
"handrail_reach_across": {"type": "quantity", "value": 0.2, "unit": "m"},
"handrail_reach_above": {"type": "quantity", "value": 1.5, "unit": "m"},
"handrail_height_minimum": {"type": "quantity", "value": 0.8, "unit": "m"},
"handrail_height_maximum": {"type": "quantity", "value": 1.1, "unit": "m"},
"handrail_extension_minimum": {"type": "quantity", "value": 30, "unit": "cm"},
"handrail_sides": {"type": "string", "value": "both"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#108", "{result:#}");
assert!(
findings[0].1.starts_with("handrail ")
&& findings[0].1.ends_with(
"#308 runs 0.75 m above the pitch line of the flight at its lowest; 0.8 m to \
1.1 m required"
),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn rail_piece_profile(height: f64, from: f64, to: f64) -> Vec<[f64; 2]> {
let top = |x: f64| 0.17 + height + x.clamp(0.0, 0.84) * 0.51 / 0.84;
let mut along = vec![from];
along.extend([0.0, 0.84].into_iter().filter(|x| *x > from && *x < to));
along.push(to);
let mut points: Vec<[f64; 2]> = along.iter().map(|x| [*x, top(*x) - 0.05]).collect();
points.extend(along.iter().rev().map(|x| [*x, top(*x)]));
points
}
fn handrail_in_pieces() -> String {
let flight = "IFCSTAIRFLIGHT('GID',$,$,$,$,PL,REP,$,$,$,$,$,$)";
let rail = "IFCRAILING('GID',$,$,$,$,PL,REP,$,.HANDRAIL.)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
profiled(100, [0.0, 0.0, 0.0], &stair_profile(&[0.17; 4]), flight),
swept(
200,
[0.0, 0.05, 0.0],
&rail_piece_profile(0.9, -0.3, 0.4),
0.05,
rail
),
swept(
300,
[0.0, 0.05, 0.0],
&rail_piece_profile(0.9, 0.5, 1.14),
0.05,
rail
),
swept(400, [0.0, -1.2, 0.0], &rail_profile(0.9), 0.05, rail),
)
}
#[test]
fn with_geometry_a_gap_between_handrail_pieces_is_found() {
let case = Case::new("geometry-stair-handrail-pieces");
let run = |gap: f64| {
case.geometry_rule(
&handrail_in_pieces(),
&[("flight", "IfcStairFlight"), ("rail", "IfcRailing")],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("flight"),
json!({
"handrail_objects": {"type": "selector", "value": entity("rail")},
"handrail_reach_across": {"type": "quantity", "value": 0.2, "unit": "m"},
"handrail_reach_above": {"type": "quantity", "value": 1.5, "unit": "m"},
"handrail_height_minimum": {"type": "quantity", "value": 0.8, "unit": "m"},
"handrail_extension_minimum": {"type": "quantity", "value": 30, "unit": "cm"},
"handrail_gap_maximum": {"type": "quantity", "value": gap, "unit": "m"},
"handrail_sides": {"type": "string", "value": "both"},
}),
)
};
let (output, result) = run(0.05);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#108", "{result:#}");
assert!(
findings[0].1.ends_with(
"#308 along the left side of the flight leave a gap of 0.1 m in plan; at most 0.05 m \
allowed"
),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
let (output, result) = run(0.15);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert!(finding_messages(&result).is_empty(), "{result:#}");
}
#[test]
fn with_geometry_an_obstacle_at_the_foot_of_a_ramp_is_found() {
let case = Case::new("geometry-ramp-ends");
let (output, result) = case.geometry_rule(
&handrails_and_ramp_ends(),
&[
("ramp", "IfcRampFlight"),
("furniture", "IfcFurnishingElement"),
],
"axioval:capability.ramp-geometry",
®istry_signature("axioval:capability.ramp-geometry"),
entity("ramp"),
json!({
"end_space_depth": {"type": "quantity", "value": 1.5, "unit": "m"},
"end_space_width": {"type": "quantity", "value": 1.5, "unit": "m"},
"end_space_height": {"type": "quantity", "value": 2, "unit": "m"},
"end_space_obstacles": {"type": "selector", "value": entity("furniture")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#408", "{result:#}");
assert!(
findings[0].1.ends_with(
"#509 obstructs the free space at the bottom of the ramp (1.5 m deep, 1.5 m wide)"
),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_a_ramp_without_a_stair_nearby_is_found() {
let case = Case::new("geometry-stair-near-ramp");
let near = |maximum: f64| {
case.geometry_rule(
&handrails_and_ramp_ends(),
&[("ramp", "IfcRampFlight"), ("flight", "IfcStairFlight")],
"axioval:capability.distance",
®istry_signature("axioval:capability.distance"),
entity("ramp"),
json!({
"counterparts": {"type": "selector", "value": entity("flight")},
"mode": {"type": "string", "value": "nearest"},
"maximum_metres": {"type": "number", "value": maximum},
"projection": {"type": "string", "value": "horizontal"},
}),
)
};
let (output, result) = near(3.0);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_ids(&result), ["#408"], "{result:#}");
let (output, result) = near(4.0);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert!(finding_ids(&result).is_empty(), "{result:#}");
}
fn stepped(points: &[[f64; 2]], cells: &[(Vec<usize>, f64)]) -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
let mut heights: Vec<Vec<f64>> = vec![vec![0.0]; points.len()];
for (corners, height) in cells {
for corner in corners {
heights[*corner].push(*height);
}
}
let mut positions = Vec::new();
let mut first = Vec::with_capacity(points.len());
for (point, levels) in points.iter().zip(&mut heights) {
levels.sort_by(f64::total_cmp);
levels.dedup();
first.push(positions.len());
positions.extend(levels.iter().map(|z| [point[0], point[1], *z]));
}
let vertex = |point: usize, height: f64| {
first[point]
+ heights[point]
.iter()
.position(|z| z.total_cmp(&height).is_eq())
.unwrap()
};
let owner: std::collections::BTreeMap<(usize, usize), f64> = cells
.iter()
.flat_map(|(corners, height)| {
(0..corners.len()).map(|k| ((corners[k], corners[(k + 1) % corners.len()]), *height))
})
.collect();
let mut triangles = Vec::new();
for (corners, height) in cells {
for k in 1..corners.len() - 1 {
triangles.push([
vertex(corners[0], *height),
vertex(corners[k], *height),
vertex(corners[k + 1], *height),
]);
triangles.push([
vertex(corners[0], 0.0),
vertex(corners[k + 1], 0.0),
vertex(corners[k], 0.0),
]);
}
for k in 0..corners.len() {
let (a, b) = (corners[k], corners[(k + 1) % corners.len()]);
let other = owner.get(&(b, a)).copied().unwrap_or(0.0);
if other >= *height {
continue;
}
let side = |point: usize| -> Vec<usize> {
heights[point]
.iter()
.filter(|z| **z >= other && **z <= *height)
.map(|z| vertex(point, *z))
.collect()
};
let (left, right) = (side(a), side(b));
let (mut i, mut j) = (0, 0);
while i + 1 < left.len() || j + 1 < right.len() {
if j + 1 < right.len()
&& (i + 1 == left.len()
|| positions[right[j + 1]][2] <= positions[left[i + 1]][2])
{
triangles.push([left[i], right[j], right[j + 1]]);
j += 1;
} else {
triangles.push([left[i], right[j], left[i + 1]]);
i += 1;
}
}
}
}
(positions, triangles)
}
fn quarter_turn_flight() -> String {
quarter_turn_flight_with("")
}
fn quarter_turn_flight_with(records: &str) -> String {
let slope = 1.0 / 3.0_f64.sqrt();
let points = [
[0.0, 0.0],
[0.0, 1.0],
[0.28, 0.0],
[0.28, 1.0],
[0.56, 0.0],
[0.56, 1.0],
[0.84, 0.0],
[0.84, 1.0],
[0.84 + slope, 0.0],
[1.84, 0.0],
[1.84, 1.0 - slope],
[1.84, 1.0],
[0.84, 1.28],
[1.84, 1.28],
[0.84, 1.56],
[1.84, 1.56],
[0.84, 1.84],
[1.84, 1.84],
];
let cells: Vec<(Vec<usize>, f64)> = [
vec![0, 2, 3, 1],
vec![2, 4, 5, 3],
vec![4, 6, 7, 5],
vec![6, 8, 7],
vec![8, 9, 10, 7],
vec![10, 11, 7],
vec![7, 11, 13, 12],
vec![12, 13, 15, 14],
vec![14, 15, 17, 16],
]
.into_iter()
.zip(1_u32..)
.map(|(cell, step)| (cell, 0.18 * f64::from(step)))
.collect();
let (positions, triangles) = stepped(&points, &cells);
let coordinates = positions
.iter()
.map(|[x, y, z]| format!("({x:?},{y:?},{z:?})"))
.collect::<Vec<_>>()
.join(",");
let indices = triangles
.iter()
.map(|[a, b, c]| format!("({},{},{})", a + 1, b + 1, c + 1))
.collect::<Vec<_>>()
.join(",");
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#100=IFCCARTESIANPOINTLIST3D(({coordinates}));\n\
#101=IFCTRIANGULATEDFACESET(#100,$,.T.,({indices}),$);\n\
#102=IFCSHAPEREPRESENTATION(#5,'Body','Tessellation',(#101));\n\
#103=IFCPRODUCTDEFINITIONSHAPE($,$,(#102));\n\
#104=IFCLOCALPLACEMENT($,#2);\n\
#105=IFCSTAIRFLIGHT('{:022}',$,$,$,$,#104,#103,$,$,$,$,$,$);\n\
{records}ENDSEC;\nEND-ISO-10303-21;\n",
105
)
}
fn swept_along_y(
first: u32,
[x, y, z]: [f64; 3],
points: &[[f64; 2]],
depth: f64,
product: &str,
) -> String {
let at = |offset: u32| first + offset;
let mut text = String::new();
let mut corners = Vec::new();
for (index, [along, up]) in points.iter().chain(points.first()).enumerate() {
let corner = first + 20 + u32::try_from(index).unwrap();
writeln!(text, "#{corner}=IFCCARTESIANPOINT(({along:.3},{up:.3}));").unwrap();
corners.push(format!("#{corner}"));
}
write!(
text,
"#{a}=IFCDIRECTION((1.,0.,0.));\n\
#{b}=IFCDIRECTION((0.,1.,0.));\n\
#{c}=IFCCARTESIANPOINT(({x:.2},{y:.2},{z:.2}));\n\
#{d}=IFCAXIS2PLACEMENT3D(#{c},#{a},#{b});\n\
#{e}=IFCLOCALPLACEMENT($,#2);\n\
#{f}=IFCPOLYLINE(({}));\n\
#{g}=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#{f});\n\
#{h}=IFCDIRECTION((0.,0.,1.));\n\
#{i}=IFCEXTRUDEDAREASOLID(#{g},#{d},#{h},{depth:.3});\n\
#{j}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{i}));\n\
#{k}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{j}));\n\
#{l}={};\n",
corners.join(","),
product
.replace("GID", &format!("{:022}", at(11)))
.replace("PL", &format!("#{}", at(4)))
.replace("REP", &format!("#{}", at(10))),
a = at(0),
b = at(1),
c = at(2),
d = at(3),
e = at(4),
f = at(5),
g = at(6),
h = at(7),
i = at(8),
j = at(9),
k = at(10),
l = at(11),
)
.unwrap();
text
}
fn quarter_turn_with_landings_and_rail() -> String {
let slope = 1.0 / 3.0_f64.sqrt();
let rail = "IFCRAILING('GID',$,$,$,$,PL,REP,$,.HANDRAIL.)";
let profile = |top: &[[f64; 2]]| {
let mut points: Vec<[f64; 2]> = top.iter().map(|[a, z]| [*a, z - 0.05]).collect();
points.extend(top.iter().rev());
points
};
let lower = profile(&[
[-0.3, 1.08],
[0.0, 1.08],
[0.84, 1.62],
[0.84 + slope, 1.80],
[1.89, 1.98],
]);
let upper = profile(&[
[-0.1, 1.98],
[1.0 - slope, 1.98],
[1.0, 2.16],
[1.28, 2.34],
[1.56, 2.52],
[1.86, 2.52],
]);
let slab = "IFCSLAB('GID',$,$,$,$,PL,REP,$,.FLOOR.)";
quarter_turn_flight_with(&format!(
"#4=IFCDIRECTION((0.,0.,1.));\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}{}{}",
placed_box(200, [-0.75, 0.5, -0.2], [1.5, 1.4, 0.2], slab),
placed_box(300, [1.34, 2.42, 1.42], [1.0, 1.16, 0.2], slab),
swept(400, [0.0, -0.05, 0.0], &lower, 0.05, rail),
swept_along_y(500, [1.84, 0.0, 0.0], &upper, 0.05, rail),
))
}
#[test]
fn with_geometry_a_quarter_turns_landings_and_handrail_are_placed_in_its_parts() {
let case = Case::new("geometry-quarter-turn-ends");
let (output, result) = case.geometry_rule(
&quarter_turn_with_landings_and_rail(),
&[
("flight", "IfcStairFlight"),
("slab", "IfcSlab"),
("rail", "IfcRailing"),
],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("flight"),
json!({
"landing_objects": {"type": "selector", "value": entity("slab")},
"landing_depth_minimum": {"type": "quantity", "value": 1.45, "unit": "m"},
"landing_at_least_walking_width": {"type": "boolean", "value": true},
"handrail_objects": {"type": "selector", "value": entity("rail")},
"handrail_reach_across": {"type": "quantity", "value": 0.2, "unit": "m"},
"handrail_reach_above": {"type": "quantity", "value": 1.5, "unit": "m"},
"handrail_height_minimum": {"type": "quantity", "value": 0.7, "unit": "m"},
"handrail_height_maximum": {"type": "quantity", "value": 1.2, "unit": "m"},
"handrail_extension_minimum": {"type": "quantity", "value": 0.3, "unit": "m"},
"handrail_gap_maximum": {"type": "quantity", "value": 0.05, "unit": "m"},
"handrail_sides": {"type": "string", "value": "one"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#105".to_owned(),
"the landing at the top of the flight is 1.44 m deep; at least 1.45 m and the \
flight's width (1 m) required"
.to_owned()
)],
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_a_quarter_turn_flights_winders_are_found_and_its_width_left_open() {
let case = Case::new("geometry-quarter-turn");
let (output, result) = case.geometry_rule(
&quarter_turn_flight(),
&[("flight", "IfcStairFlight")],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("flight"),
json!({
"riser_maximum": {"type": "quantity", "value": 19, "unit": "cm"},
"going_minimum": {"type": "quantity", "value": 26, "unit": "cm"},
"winder_angle_maximum": {"type": "quantity", "value": 25, "unit": "deg"},
"width_minimum": {"type": "quantity", "value": 1.1, "unit": "m"},
"forbid_open_risers": {"type": "boolean", "value": true},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#105".to_owned(),
"winder angle 4 of 8 is 30°, winder angle 5 of 8 is 30°, winder angle 6 of 8 is 30°; \
at most 25° required"
.to_owned()
)],
"{result:#}"
);
let not_evaluated = result["report"]["not_evaluated"].as_array().unwrap();
assert_eq!(not_evaluated.len(), 1, "{result:#}");
assert!(
not_evaluated[0]["message"]
.as_str()
.is_some_and(|message| message.starts_with("the flight's width is not measured")),
"{result:#}"
);
}
fn rooms_doors_and_a_stair() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let door = "IFCDOOR('GID',$,$,$,$,PL,REP,$,2.1,0.9,$,$,$)";
let stair = "IFCSTAIR('GID',$,$,$,$,PL,REP,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}{}{}{}{}\
#200=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Lobby'),$);\n\
#201=IFCPROPERTYSET('0000000000000000000201',$,'Pset_SpaceCommon',$,(#200));\n\
#202=IFCRELDEFINESBYPROPERTIES('0000000000000000000202',$,$,$,(#19),#201);\n\
#210=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Room'),$);\n\
#211=IFCPROPERTYSET('0000000000000000000211',$,'Pset_SpaceCommon',$,(#210));\n\
#212=IFCRELDEFINESBYPROPERTIES('0000000000000000000212',$,$,$,(#29,#39,#49),#211);\n\
#220=IFCPROPERTYSINGLEVALUE('ClearWidth',$,IFCPOSITIVELENGTHMEASURE(0.85),$);\n\
#221=IFCPROPERTYSET('0000000000000000000221',$,'Access',$,(#220));\n\
#222=IFCRELDEFINESBYPROPERTIES('0000000000000000000222',$,$,$,(#59),#221);\n\
#230=IFCPROPERTYSINGLEVALUE('ClearWidth',$,IFCPOSITIVELENGTHMEASURE(0.75),$);\n\
#231=IFCPROPERTYSET('0000000000000000000231',$,'Access',$,(#230));\n\
#232=IFCRELDEFINESBYPROPERTIES('0000000000000000000232',$,$,$,(#69),#231);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [2.0, 2.0, 0.0], [4.0, 4.0, 3.0], space),
placed_box(20, [6.1, 2.0, 0.0], [3.8, 4.0, 3.0], space),
placed_box(30, [-2.1, 2.0, 0.0], [3.8, 4.0, 3.0], space),
placed_box(40, [2.0, 2.0, 3.3], [4.0, 4.0, 3.0], space),
placed_box(50, [4.1, 1.45, 0.0], [0.1, 0.9, 2.1], door),
placed_box(60, [-0.1, 1.45, 0.0], [0.1, 0.9, 2.1], door),
placed_box(70, [2.0, 1.75, 0.0], [1.0, 2.5, 3.3], stair),
)
}
#[test]
fn with_geometry_an_accessible_route_finds_a_narrow_door_and_a_stairs_only_room() {
let case = Case::new("geometry-accessible-route");
let reference = |value: &str| {
json!({"kind": "allOf", "operands": [
entity("space"),
{"kind": "property", "propertySet": "axioval:example.ifc.pset-space-common",
"property": "axioval:example.ifc.reference", "operator": "equals",
"value": {"type": "string", "value": value}},
]})
};
let (output, result) = case.geometry_rule(
&rooms_doors_and_a_stair(),
&[
("space", "IfcSpace"),
("door", "IfcDoor"),
("stair", "IfcStair"),
],
"axioval:capability.accessible-route",
®istry_signature("axioval:capability.accessible-route"),
reference("Room"),
json!({
"route_selector": {"type": "selector", "value": entity("space")},
"start_selector": {"type": "selector", "value": reference("Lobby")},
"portal_selector": {"type": "selector", "value": entity("door")},
"stair_selector": {"type": "selector", "value": entity("stair")},
"width_metres": {"type": "number", "value": 0.8},
"door_width_metres": {"type": "number", "value": 0.8},
"clear_width_property": {"type": "propertyReference",
"property": "axioval:example.ifc.clear-width",
"propertySet": "axioval:example.ifc.pset-access"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 2, "{result:#}");
assert_eq!(findings[0].0, "#39", "{result:#}");
assert!(
findings[0]
.1
.contains("#69 states a clear width of 0.75 m, less than the 0.8 m required"),
"{result:#}"
);
assert_eq!(findings[1].0, "#49", "{result:#}");
assert!(
findings[1]
.1
.contains("is connected to the starts by stairs only"),
"{result:#}"
);
let related = |local: &str| -> Vec<String> {
result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.find(|finding| finding["object_id"]["local_id"] == local)
.unwrap()["related"]
.as_array()
.unwrap()
.iter()
.map(|related| related["local_id"].as_str().unwrap().to_owned())
.collect()
};
assert_eq!(related("#39"), ["#69"]);
assert_eq!(related("#49"), ["#79"]);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn a_corridor_pinched_by_columns() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let door = "IFCDOOR('GID',$,$,$,$,PL,REP,$,2.1,0.9,$,$,$)";
let column = "IFCCOLUMN('GID',$,$,$,$,PL,REP,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}{}{}{}{}\
#200=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Lobby'),$);\n\
#201=IFCPROPERTYSET('0000000000000000000201',$,'Pset_SpaceCommon',$,(#200));\n\
#202=IFCRELDEFINESBYPROPERTIES('0000000000000000000202',$,$,$,(#19),#201);\n\
#210=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Room'),$);\n\
#211=IFCPROPERTYSET('0000000000000000000211',$,'Pset_SpaceCommon',$,(#210));\n\
#212=IFCRELDEFINESBYPROPERTIES('0000000000000000000212',$,$,$,(#39),#211);\n\
#220=IFCPROPERTYSINGLEVALUE('ClearWidth',$,IFCPOSITIVELENGTHMEASURE(0.85),$);\n\
#221=IFCPROPERTYSET('0000000000000000000221',$,'Access',$,(#220));\n\
#222=IFCRELDEFINESBYPROPERTIES('0000000000000000000222',$,$,$,(#49,#59),#221);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [2.0, 2.0, 0.0], [4.0, 4.0, 3.0], space),
placed_box(20, [7.1, 1.6, 0.0], [5.8, 1.2, 3.0], space),
placed_box(30, [12.1, 2.0, 0.0], [3.8, 4.0, 3.0], space),
placed_box(40, [4.1, 1.6, 0.0], [0.1, 0.9, 2.1], door),
placed_box(50, [10.1, 1.6, 0.0], [0.1, 0.9, 2.1], door),
placed_box(60, [7.15, 1.2, 0.0], [0.3, 0.4, 3.0], column),
placed_box(70, [7.15, 2.0, 0.0], [0.3, 0.4, 3.0], column),
)
}
#[test]
fn with_geometry_an_accessible_route_names_where_a_corridor_is_obstructed() {
let case = Case::new("geometry-accessible-route-pinch");
let reference = |value: &str| {
json!({"kind": "allOf", "operands": [
entity("space"),
{"kind": "property", "propertySet": "axioval:example.ifc.pset-space-common",
"property": "axioval:example.ifc.reference", "operator": "equals",
"value": {"type": "string", "value": value}},
]})
};
let (output, result) = case.geometry_rule(
&a_corridor_pinched_by_columns(),
&[
("space", "IfcSpace"),
("door", "IfcDoor"),
("column", "IfcColumn"),
],
"axioval:capability.accessible-route",
®istry_signature("axioval:capability.accessible-route"),
reference("Room"),
json!({
"route_selector": {"type": "selector", "value": entity("space")},
"start_selector": {"type": "selector", "value": reference("Lobby")},
"portal_selector": {"type": "selector", "value": entity("door")},
"obstacle_selector": {"type": "selector", "value": entity("column")},
"width_metres": {"type": "number", "value": 0.8},
"door_width_metres": {"type": "number", "value": 0.8},
"obstruction_depth_metres": {"type": "number", "value": 0.01},
"clear_width_property": {"type": "propertyReference",
"property": "axioval:example.ifc.clear-width",
"propertySet": "axioval:example.ifc.pset-access"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#39", "{result:#}");
assert!(
findings[0]
.1
.contains("#29 is obstructed near (7.15, 1.6, 0) for a body 0.8 m wide by"),
"{result:#}"
);
let related: Vec<&str> = result["report"]["findings"][0]["related"]
.as_array()
.unwrap()
.iter()
.map(|related| related["local_id"].as_str().unwrap())
.collect();
assert_eq!(related, ["#29", "#69", "#79"], "{result:#}");
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn doors_in_a_wall() -> String {
let door = |width: f64, operation: &str| {
format!("IFCDOOR('GID',$,$,$,$,PL,REP,$,2.1,{width:.2},.DOOR.,.{operation}.,$)")
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}\
#200=IFCPROPERTYSINGLEVALUE('ClearWidth',$,IFCPOSITIVELENGTHMEASURE(1.15),$);\n\
#201=IFCPROPERTYSINGLEVALUE('ThresholdHeight',$,IFCLENGTHMEASURE(0.03),$);\n\
#202=IFCPROPERTYSET('0000000000000000000202',$,'DoorAccessibility',$,(#200,#201));\n\
#203=IFCRELDEFINESBYPROPERTIES('0000000000000000000203',$,$,$,(#39),#202);\n\
#210=IFCPROPERTYSINGLEVALUE('ThresholdHeight',$,IFCLENGTHMEASURE(0.),$);\n\
#211=IFCPROPERTYSET('0000000000000000000211',$,'DoorAccessibility',$,(#210));\n\
#212=IFCRELDEFINESBYPROPERTIES('0000000000000000000212',$,$,$,(#19,#29),#211);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(
10,
[0.5, 0.0, 0.0],
[1.0, 0.1, 2.1],
&door(1.0, "SINGLE_SWING_LEFT")
),
placed_box(
20,
[1.95, 0.0, 0.0],
[0.9, 0.1, 2.1],
&door(0.9, "SINGLE_SWING_RIGHT")
),
placed_box(
30,
[10.5, 0.0, 0.0],
[1.0, 0.1, 2.1],
&door(1.25, "DOUBLE_DOOR_SINGLE_SWING"),
),
)
}
fn door_packages(case: &Case) -> (PathBuf, PathBuf) {
let text = |value: &str| json!({"default": value, "translations": {}});
let concept = |name: &str| {
json!({"id": format!("axioval:example.ifc.{name}"), "name": text(name),
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": name}],
"citations": []})
};
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(case.definitions(true)).unwrap()).unwrap();
definitions["objectTypes"]["axioval:example.ifc.IfcDoor"] = concept("IfcDoor");
definitions["propertySets"]["axioval:example.ifc.DoorAccessibility"] =
concept("DoorAccessibility");
for (name, kind) in [
("OverallWidth", "quantity"),
("OperationType", "enum"),
("ClearWidth", "quantity"),
("ThresholdHeight", "quantity"),
] {
let mut property = concept(name);
property["valueKind"] = json!(kind);
definitions["properties"][format!("axioval:example.ifc.{name}")] = property;
}
for (id, capability) in [
("door-clear-width", "keyed-limit"),
("door-threshold", "keyed-limit"),
("door-spacing", "distance"),
] {
let capability = format!("axioval:capability.{capability}");
definitions["definitions"][format!("axioval:example.{id}")] = json!({
"id": format!("axioval:example.{id}"), "name": text(id), "description": text(id),
"capability": capability, "parameters": registry_signature(&capability),
"citations": [], "tags": [],
});
}
let reference = |set: &str, name: &str| {
json!({"type": "propertyReference", "property": format!("axioval:example.ifc.{name}"),
"propertySet": set})
};
let accessibility = "axioval:example.ifc.DoorAccessibility";
let operation = reference("axioval:attributes", "OperationType");
let doors = entity("IfcDoor");
let text_file = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text_file).unwrap();
let template = ruleset["root"]["rules"][0].clone();
let rule = |id: &str, parameters: Value| {
let mut rule = template.clone();
rule["id"] = json!(id);
rule["definitionId"] = json!(format!("axioval:example.{id}"));
rule["parameters"] = parameters;
rule["applicability"]["groups"]["walls"]["selector"] = doors.clone();
rule
};
ruleset["root"]["rules"] = json!([
rule(
"door-clear-width",
json!({
"limits": {"type": "table", "value": [
{"key_1": {"type": "string", "value": "SINGLE_SWING_*"},
"minimum": {"type": "number", "value": 0.9}},
{"key_1": {"type": "string", "value": "DOUBLE_DOOR_*"},
"minimum": {"type": "number", "value": 1.2}},
]},
"quantity": {"type": "string", "value": "clear-width"},
"quantity_property": reference(accessibility, "ClearWidth"),
"overall_width": reference("axioval:attributes", "OverallWidth"),
"width_deduction": {"type": "quantity", "value": 0.1, "unit": "m"},
"key_1": operation,
}),
),
rule(
"door-threshold",
json!({
"limits": {"type": "table", "value": [
{"key_1": {"type": "string", "value": "*"},
"maximum": {"type": "number", "value": 0.02}},
]},
"quantity": {"type": "string", "value": "property"},
"quantity_property": reference(accessibility, "ThresholdHeight"),
"key_1": operation,
}),
),
rule(
"door-spacing",
json!({
"counterparts": {"type": "selector", "value": doors},
"mode": {"type": "string", "value": "none_closer_than"},
"projection": {"type": "string", "value": "horizontal"},
"minimum_metres": {"type": "number", "value": 1.5},
}),
),
]);
(
case.write("definitions.json", &definitions.to_string()),
case.write("ruleset.json", &ruleset.to_string()),
)
}
#[test]
fn with_geometry_door_clear_widths_thresholds_and_spacing_are_checked() {
let case = Case::new("geometry-doors");
let (definitions, ruleset) = door_packages(&case);
let model = case.write("model.ifc", &doors_in_a_wall());
let saved = case.path("result.json");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result: Value = serde_json::from_str(&std::fs::read_to_string(&saved).unwrap()).unwrap();
let mut findings: Vec<(String, String, String)> = result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| {
(
finding["rule_id"].as_str().unwrap().to_owned(),
finding["object_id"]["local_id"]
.as_str()
.unwrap()
.to_owned(),
finding["message"].as_str().unwrap().to_owned(),
)
})
.collect();
findings.sort();
let found: Vec<(&str, &str)> = findings
.iter()
.map(|(rule, object, _)| (rule.as_str(), object.as_str()))
.collect();
assert_eq!(
found,
[
("door-clear-width", "#29"),
("door-clear-width", "#39"),
("door-spacing", "#19"),
("door-spacing", "#29"),
("door-threshold", "#39"),
],
"{result:#}"
);
assert_eq!(
findings[0].2,
"clear width (axioval:attributes.axioval:example.ifc.OverallWidth 0.9 m less the rule's \
deduction 0.1 m, an approximation) is 0.8 m; required at least 0.9 m (limit row 0: \
axioval:attributes.axioval:example.ifc.OperationType `SINGLE_SWING_RIGHT`)",
"{result:#}"
);
assert!(
findings[1]
.2
.starts_with("clear width (axioval:example.ifc.DoorAccessibility.")
&& findings[1].2.contains("is 1.15 m; required at least 1.2 m"),
"{result:#}"
);
assert!(
findings[2]
.2
.ends_with("/#29 at horizontal distance 0.5000 m"),
"{result:#}"
);
assert!(
findings[4].2.contains("is 0.03 m; required at most 0.02 m"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_a_forbidden_connection_and_a_missing_exit_are_found() {
let case = Case::new("geometry-space-connection");
let (output, result) = case.geometry_rule(
&walls_with_openings(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.space-connection",
®istry_signature("axioval:capability.space-connection"),
entity("space"),
json!({
"connections": {"type": "table", "value": [
{"from": {"type": "selector", "value": entity("space")},
"to": {"type": "selector", "value": entity("space")},
"access": {"type": "string", "value": "forbidden"},
"access_type": {"type": "string", "value": "doors"}},
{"label": {"type": "string", "value": "exit"},
"from": {"type": "selector", "value": entity("space")},
"exit": {"type": "string", "value": "required"},
"access_type": {"type": "string", "value": "doors"}},
]},
"access_path": {"type": "stringList", "value": ["axioval:derived.adjacent-space"]},
"door_selector": {"type": "selector", "value": entity("door")},
"space_selector": {"type": "selector", "value": entity("space")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 3, "{result:#}");
let of = |id: &str| -> Vec<&str> {
findings
.iter()
.filter(|(object, _)| object == id)
.map(|(_, message)| message.as_str())
.collect()
};
assert!(
of("#16")
.iter()
.any(|message| message.starts_with("has direct access to ")
&& message.contains("/#26 through ")
&& message.ends_with("which row 0 forbids for a door")),
"{result:#}"
);
assert!(
of("#16").contains(&"has no door directly to the outside, which row 1 (exit) requires"),
"{result:#}"
);
assert_eq!(of("#26").len(), 1, "{result:#}");
assert!(of("#26")[0].contains("/#16 through "), "{result:#}");
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn rooms_through_an_opening() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let opening = "IFCOPENINGELEMENT('GID',$,$,$,$,PL,REP,$,.OPENING.)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [2.0, 2.0, 0.0], [4.0, 4.0, 3.0], space),
placed_box(20, [6.2, 2.0, 0.0], [4.0, 4.0, 3.0], space),
placed_box(30, [4.1, 3.5, 0.0], [0.2, 1.0, 2.1], opening),
)
}
#[test]
fn with_geometry_a_walking_distance_too_long_is_found() {
let case = Case::new("geometry-space-distance");
let row = |measure: &str| {
json!({"from": {"type": "selector", "value": entity("space")},
"to": {"type": "selector", "value": entity("space")},
"measure": {"type": "string", "value": measure},
"maximum": {"type": "number", "value": 4.5}})
};
let (output, result) = case.geometry_rule(
&rooms_through_an_opening(),
&[("space", "IfcSpace")],
"axioval:capability.space-distance",
®istry_signature("axioval:capability.space-distance"),
entity("space"),
json!({
"distances": {"type": "table", "value": [row("straight"), row("walking")]},
"walking_radius": {"type": "number", "value": 0.3},
"walking_height": {"type": "number", "value": 2.0},
"walking_step": {"type": "number", "value": 0.02},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 2, "{result:#}");
for (space, message) in &findings {
assert!(["#19", "#29"].contains(&space.as_str()), "{result:#}");
assert!(
message.starts_with("the nearest destination, ")
&& message.contains(" m away walking; row 1 allows at most 4.5 m"),
"{result:#}"
);
}
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_the_closest_distance_is_measured_between_bodies() {
let case = Case::new("geometry-space-distance-closest");
let row = |measure: &str| {
json!({"from": {"type": "selector", "value": entity("space")},
"to": {"type": "selector", "value": entity("space")},
"measure": {"type": "string", "value": measure},
"maximum": {"type": "number", "value": 1.0}})
};
let (output, result) = case.geometry_rule(
&rooms_through_an_opening(),
&[("space", "IfcSpace")],
"axioval:capability.space-distance",
®istry_signature("axioval:capability.space-distance"),
entity("space"),
json!({"distances": {"type": "table", "value": [row("closest"), row("straight")]}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 2, "{result:#}");
for (_, message) in &findings {
assert!(
message.contains(" m away in a straight line between centres; row 1 allows"),
"{result:#}"
);
}
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn washrooms() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let wc = "IFCSANITARYTERMINAL('GID',$,$,$,$,PL,REP,$,.TOILETPAN.)";
let basin = "IFCFURNISHINGELEMENT('GID',$,$,$,$,PL,REP,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [1.5, 1.5, 0.0], [3.0, 3.0, 2.5], space),
placed_box(20, [1.2, 0.35, 0.0], [0.4, 0.7, 0.4], wc),
placed_box(30, [0.45, 0.35, 0.8], [0.3, 0.3, 0.2], basin),
placed_box(40, [5.5, 1.5, 0.0], [3.0, 3.0, 2.5], space),
placed_box(50, [5.2, 0.35, 0.0], [0.4, 0.7, 0.4], wc),
)
}
#[test]
fn with_geometry_an_obstructed_wc_transfer_area_is_found() {
let case = Case::new("geometry-component-clearance");
let not_a_space = json!({"kind": "not", "operand": entity("space")});
let (output, result) = case.geometry_rule(
&washrooms(),
&[("space", "IfcSpace"), ("terminal", "IfcSanitaryTerminal")],
"axioval:capability.component-clearance",
®istry_signature("axioval:capability.component-clearance"),
entity("terminal"),
json!({
"side": {"type": "string", "value": "left"},
"front_axis": {"type": "string", "value": "forward"},
"width": {"type": "quantity", "value": 70, "unit": "cm"},
"depth": {"type": "quantity", "value": 90, "unit": "cm"},
"height": {"type": "quantity", "value": 2, "unit": "m"},
"align": {"type": "string", "value": "right"},
"height_reference": {"type": "string", "value": "floor"},
"space_path": {"type": "stringList",
"value": ["axioval:derived.contained-in-space:forward"]},
"within_space": {"type": "boolean", "value": true},
"obstacles": {"type": "selector", "value": not_a_space},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#29".to_owned(),
"left clearance (0.7 m wide, 0.9 m deep, 2 m high) is obstructed by \
ifc-step:model.ifc/#39"
.to_owned()
)],
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn washroom_with_walls() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let wc = "IFCSANITARYTERMINAL('GID',$,$,$,$,PL,REP,$,.TOILETPAN.)";
let wall = "IFCWALL('GID',$,$,$,$,PL,REP,$,$)";
let chair = "IFCFURNISHINGELEMENT('GID',$,$,$,$,PL,REP,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [1.5, 1.5, 0.0], [3.0, 3.0, 2.5], space),
placed_box(20, [1.2, 0.35, 0.0], [0.4, 0.7, 0.4], wc),
placed_box(30, [1.5, -0.1, 0.0], [3.4, 0.2, 2.5], wall),
placed_box(40, [-0.1, 1.5, 0.0], [0.2, 3.4, 2.5], wall),
placed_box(50, [1.2, 1.3, 0.0], [0.2, 0.2, 0.9], chair),
)
}
#[test]
fn with_geometry_a_front_is_derived_from_the_wall_behind_a_wc() {
let case = Case::new("geometry-clearance-against-wall");
let (output, result) = case.geometry_rule(
&washroom_with_walls(),
&[
("wall", "IfcWall"),
("terminal", "IfcSanitaryTerminal"),
("furniture", "IfcFurnishingElement"),
],
"axioval:capability.component-clearance",
®istry_signature("axioval:capability.component-clearance"),
entity("terminal"),
json!({
"side": {"type": "string", "value": "front"},
"front_axis": {"type": "string", "value": "against-wall"},
"wall_selector": {"type": "selector", "value": entity("wall")},
"wall_reach": {"type": "quantity", "value": 1.5, "unit": "m"},
"wall_inset": {"type": "quantity", "value": 1, "unit": "cm"},
"width": {"type": "quantity", "value": 0.8, "unit": "m"},
"depth": {"type": "quantity", "value": 1.2, "unit": "m"},
"height": {"type": "quantity", "value": 2, "unit": "m"},
"height_reference": {"type": "string", "value": "bottom"},
"obstacles": {"type": "selector", "value": entity("furniture")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#29".to_owned(),
"front clearance (0.8 m wide, 1.2 m deep, 2 m high) is obstructed by \
ifc-step:model.ifc/#59"
.to_owned()
)],
"{result:#}"
);
}
#[test]
fn with_geometry_one_free_side_is_enough_under_any() {
let case = Case::new("geometry-clearance-any-side");
let not_a_space = json!({"kind": "not", "operand": entity("space")});
let run = |quantifier: &str| {
case.geometry_rule(
&washrooms(),
&[("space", "IfcSpace"), ("terminal", "IfcSanitaryTerminal")],
"axioval:capability.component-clearance",
®istry_signature("axioval:capability.component-clearance"),
entity("terminal"),
json!({
"sides": {"type": "stringList", "value": ["left", "right"]},
"quantifier": {"type": "string", "value": quantifier},
"front_axis": {"type": "string", "value": "forward"},
"width": {"type": "quantity", "value": 70, "unit": "cm"},
"depth": {"type": "quantity", "value": 90, "unit": "cm"},
"height_reference": {"type": "string", "value": "floor"},
"top_datum": {"type": "string", "value": "floor"},
"top_offset": {"type": "quantity", "value": 2, "unit": "m"},
"align": {"type": "string", "value": "right"},
"space_path": {"type": "stringList",
"value": ["axioval:derived.contained-in-space:forward"]},
"obstacles": {"type": "selector", "value": not_a_space.clone()},
}),
)
};
let (output, result) = run("any");
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert!(finding_messages(&result).is_empty(), "{result:#}");
let (output, result) = run("all");
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#29".to_owned(),
"left clearance (0.7 m wide, 0.9 m deep, up to 2 m above the floor) is obstructed \
by ifc-step:model.ifc/#39"
.to_owned()
)],
"{result:#}"
);
}
#[test]
fn with_geometry_a_wc_axis_far_from_the_side_wall_is_found() {
let case = Case::new("geometry-centre-line-distance");
let (output, result) = case.geometry_rule(
&washroom_with_walls(),
&[("wall", "IfcWall"), ("terminal", "IfcSanitaryTerminal")],
"axioval:capability.centre-line-distance",
®istry_signature("axioval:capability.centre-line-distance"),
entity("terminal"),
json!({
"wall_selector": {"type": "selector", "value": entity("wall")},
"centre_line": {"type": "string", "value": "against-wall"},
"sides": {"type": "string", "value": "nearest"},
"minimum": {"type": "quantity", "value": 405, "unit": "mm"},
"maximum": {"type": "quantity", "value": 455, "unit": "mm"},
"reach": {"type": "quantity", "value": 1.5, "unit": "m"},
"inset": {"type": "quantity", "value": 1, "unit": "cm"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#29".to_owned(),
"centre line to the nearest wall: too far: 1.2 m from ifc-step:model.ifc/#49, more \
than the maximum 0.455 m"
.to_owned()
)],
"{result:#}"
);
}
fn turned_box(
first: u32,
[x, y, z]: [f64; 3],
[length, width, depth]: [f64; 3],
degrees: f64,
product: &str,
) -> String {
let [
origin,
frame,
placement,
p,
direction,
pos,
profile,
solid,
shape,
definition,
object,
] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(|offset| first + offset);
let (sin, cos) = degrees.to_radians().sin_cos();
format!(
"#{origin}=IFCCARTESIANPOINT((0.,0.,{z:?}));\n\
#{frame}=IFCAXIS2PLACEMENT3D(#{origin},$,$);\n\
#{placement}=IFCLOCALPLACEMENT($,#{frame});\n\
#{p}=IFCCARTESIANPOINT(({x:?},{y:?}));\n\
#{direction}=IFCDIRECTION(({cos:?},{sin:?}));\n\
#{pos}=IFCAXIS2PLACEMENT2D(#{p},#{direction});\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{pos},{length:?},{width:?});\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,{depth:?});\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{definition}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{object}={};\n",
product
.replace("GID", &format!("{object:022}"))
.replace("PL", &format!("#{placement}"))
.replace("REP", &format!("#{definition}")),
)
}
fn model_with(data: &str) -> String {
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);\n\
#8=IFCUNITASSIGNMENT((#6,#7));\n\
#9=IFCPROJECT('0000000000000000000009',$,'P',$,$,$,$,(#5),#8);\n\
{data}ENDSEC;\nEND-ISO-10303-21;\n"
)
}
fn car_park() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,.PARKING.,$)";
let column = "IFCCOLUMN('GID',$,$,$,$,PL,REP,$,.COLUMN.)";
let turned_centre = 6.0 + 7.3 / 2.0_f64.sqrt() / 2.0;
model_with(&format!(
"{}{}{}{}{}",
placed_box(
10,
[10.0, 3.0, -0.2],
[30.0, 6.0, 0.2],
"IFCSLAB('GID',$,$,$,$,PL,REP,$,.FLOOR.)"
),
placed_box(20, [1.25, 8.5, 0.0], [2.5, 5.0, 2.2], space),
turned_box(30, [10.0, turned_centre, 0.0], [4.8, 2.5, 2.2], 45.0, space),
placed_box(50, [-0.3, 8.2, 0.0], [0.4, 0.4, 3.0], column),
placed_box(60, [1.2, 11.25, 0.0], [0.4, 0.4, 3.0], column),
))
}
#[test]
fn with_geometry_a_size_bound_applies_only_to_the_bays_its_states_select() {
let case = Case::new("geometry-parking-bay-filter");
let check = |orientations: &[&str]| {
case.geometry_rule(
&car_park(),
&[("space", "IfcSpace"), ("slab", "IfcSlab")],
"axioval:capability.parking-bay",
®istry_signature("axioval:capability.parking-bay"),
entity("space"),
json!({
"min_length": {"type": "quantity", "value": 5, "unit": "m"},
"applies_when": {"type": "string", "value": "filter"},
"orientations": {"type": "stringList", "value": orientations},
"aisles": {"type": "selector", "value": entity("slab")},
"aisle_reach": {"type": "quantity", "value": 0.1, "unit": "m"},
"angle_tolerance": {"type": "quantity", "value": 5, "unit": "deg"},
}),
)
};
let (output, result) = check(&["angled"]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let found = finding_messages(&result);
assert_eq!(found.len(), 1, "{result:#}");
assert_eq!(found[0].0, "#40", "{result:#}");
assert!(
found[0].1.ends_with("(a bay with orientation angled)"),
"{result:#}"
);
let (output, result) = check(&["perpendicular"]);
assert_eq!(output.status.code(), Some(0), "{result:#}");
}
#[test]
fn with_geometry_parking_spaces_are_checked_along_their_own_axes() {
let case = Case::new("geometry-parking-bay");
let (output, result) = case.geometry_rule(
&car_park(),
&[
("space", "IfcSpace"),
("slab", "IfcSlab"),
("column", "IfcColumn"),
],
"axioval:capability.parking-bay",
®istry_signature("axioval:capability.parking-bay"),
entity("space"),
json!({
"min_width": {"type": "quantity", "value": 2.4, "unit": "m"},
"min_length": {"type": "quantity", "value": 5, "unit": "m"},
"min_height": {"type": "quantity", "value": 2.1, "unit": "m"},
"aisles": {"type": "selector", "value": entity("slab")},
"aisle_reach": {"type": "quantity", "value": 0.1, "unit": "m"},
"orientation": {"type": "string", "value": "perpendicular"},
"angle_tolerance": {"type": "quantity", "value": 5, "unit": "deg"},
"obstacles": {"type": "selector", "value": entity("column")},
"obstruction_reach": {"type": "quantity", "value": 0.2, "unit": "m"},
"end_obstructions": {"type": "string", "value": "none"},
"side_obstructions": {"type": "string", "value": "one"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let found = finding_messages(&result);
assert_eq!(found.len(), 3, "{result:#}");
assert_eq!(
found[0],
(
"#29".to_owned(),
"1 of its ends obstructed by ifc-step:model.ifc/#69, none allowed".to_owned()
),
"{result:#}"
);
assert_eq!(found[1].0, "#40");
assert!(
found[1]
.1
.starts_with("length along the bay's own axes is 4.8 m; at least 5 m"),
"{found:#?}"
);
assert_eq!(found[2].0, "#40");
assert!(
found[2]
.1
.starts_with("the bay is not perpendicular to any aisle within 0.1 m"),
"{found:#?}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn walls_on_a_storey() -> String {
let wall = "IFCWALL('GID',$,$,$,$,PL,REP,$,$)";
model_with(&format!(
"{}{}{}{}{}\
#100=IFCBUILDING('0000000000000000000100',$,'B',$,$,#3,$,$,.ELEMENT.,$,$,$);\n\
#101=IFCBUILDINGSTOREY('0000000000000000000101',$,'EG',$,$,#3,$,$,.ELEMENT.,0.);\n\
#103=IFCRELAGGREGATES('0000000000000000000103',$,$,$,#100,(#101));\n\
#106=IFCRELCONTAINEDINSPATIALSTRUCTURE('0000000000000000000106',$,$,$,(#19,#29,#39,#49,#59),#101);\n",
placed_box(
10,
[5.0, 4.0, -0.3],
[10.0, 8.0, 0.3],
"IFCSLAB('GID',$,$,$,$,PL,REP,$,.FLOOR.)"
),
placed_box(20, [5.0, 0.1, 0.0], [10.0, 0.2, 3.0], wall),
placed_box(30, [6.0, 5.1, 0.0], [8.0, 0.2, 3.0], wall),
placed_box(40, [5.0, 5.7, 0.0], [10.0, 0.2, 3.0], wall),
placed_box(50, [0.1, 2.9, 0.0], [0.2, 5.4, 3.0], wall),
))
}
#[test]
fn with_geometry_close_parallel_walls_and_an_uncovered_strip_are_found() {
let case = Case::new("geometry-wall-spacing");
let contained = json!({"type": "stringList",
"value": ["IfcRelContainedInSpatialStructure"]});
let (output, result) = case.geometry_rule(
&walls_on_a_storey(),
&[("storey", "IfcBuildingStorey"), ("slab", "IfcSlab")],
"axioval:capability.wall-spacing",
®istry_signature("axioval:capability.wall-spacing"),
entity("storey"),
json!({
"members": {"type": "selector", "value": entity("wall")},
"member_path": contained,
"angle_tolerance": {"type": "quantity", "value": 5, "unit": "deg"},
"minimum": {"type": "quantity", "value": 1, "unit": "m"},
"maximum": {"type": "quantity", "value": 6, "unit": "m"},
"footprints": {"type": "selector", "value": entity("slab")},
"footprint_path": contained,
"uncovered_above": {"type": "quantity", "value": 1, "unit": "m2"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let found = finding_messages(&result);
assert_eq!(found.len(), 2, "{result:#}");
assert_eq!(found[0].0, "#101");
assert!(
found[0].1.starts_with(
"22 m² of ifc-step:model.ifc/#19 lies outside every band between parallel \
members at most 6 m apart"
),
"{found:#?}"
);
assert!(
found[1].1.starts_with(
"ifc-step:model.ifc/#39 and ifc-step:model.ifc/#49 are parallel and 0.4 m apart \
in plan; at least 1 m required"
),
"{found:#?}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_clash_volume_tolerance_is_read_from_the_ifc_bodies() {
let case = Case::new("clash-volume-tolerance");
let (output, result) = case.wall_clash(
&crossing_walls(),
&json!({"volume_tolerance_cubic_metres": {"type": "number", "value": 0.1}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = sorted_findings(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert!(findings[0].1.contains("sharing 0.120000"), "{findings:?}");
let (output, result) = case.wall_clash(
&crossing_walls(),
&json!({"volume_tolerance_cubic_metres": {"type": "number", "value": 0.13}}),
);
assert_eq!(
output.status.code(),
Some(0),
"{}{result:#}",
stderr(&output)
);
assert!(finding_ids(&result).is_empty(), "{result:#}");
}
fn columns_in_a_wall() -> String {
let wall = "IFCWALL('GID',$,$,$,$,PL,REP,$,$)";
let column = "IFCCOLUMN('GID',$,$,$,$,PL,REP,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [2.0, 0.15, 0.0], [4.0, 0.3, 3.0], wall),
placed_box(20, [1.0, 0.12, 0.4], [0.2, 0.2, 2.1], column),
placed_box(30, [3.0, 0.15, 0.5], [0.2, 0.2, 2.0], column),
placed_box(40, [6.0, 2.0, 0.0], [0.3, 0.3, 3.0], column),
)
}
impl Case {
fn containment(&self, parameters: &Value) -> (Output, Value) {
let mut bound = json!({
"counterparts": {"type": "selector", "value": entity("wall")},
"minimum_volume_ratio": {"type": "number", "value": 0.99},
});
for (name, value) in parameters.as_object().unwrap() {
bound[name] = value.clone();
}
self.geometry_rule(
&columns_in_a_wall(),
&[("column", "IfcColumn")],
"axioval:capability.containment",
®istry_signature("axioval:capability.containment"),
entity("column"),
bound,
)
}
}
fn cover_row(faces: &str, minimum: f64) -> Value {
json!({"faces": {"type": "string", "value": faces},
"minimum_metres": {"type": "number", "value": minimum}})
}
#[test]
fn with_geometry_a_column_with_too_little_side_cover_is_found() {
let case = Case::new("containment-cover");
let (output, result) = case.containment(&json!({
"cover": {"type": "table", "value": [
cover_row("side", 0.04),
cover_row("top", 0.4),
cover_row("bottom", 0.45),
]},
}));
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
let findings = sorted_findings(&result);
assert_eq!(findings.len(), 2, "{findings:?}");
assert!(findings.iter().all(|(id, _)| id == "#29"), "{findings:?}");
assert!(
findings.iter().any(
|(_, message)| message.starts_with("side cover") && message.contains("is 0.0200 m")
),
"{findings:?}"
);
assert!(
findings
.iter()
.any(|(_, message)| message.starts_with("bottom cover")
&& message.contains("is 0.4000 m")),
"{findings:?}"
);
}
#[test]
fn with_geometry_containment_findings_are_nested_by_two_properties() {
let case = Case::new("containment-categories");
let bcf = case.path("issues.bcfzip");
let case = Case::new("containment-categories");
let named = columns_in_a_wall().replace(
"IFCCOLUMN('0000000000000000000029',$,$,$,$,",
"IFCCOLUMN('0000000000000000000029',$,'C1',$,'Precast',",
);
assert_ne!(named, columns_in_a_wall(), "the column template changed");
case.write("model.ifc", &named);
let attribute = |id: &str| json!({"propertySet": "axioval:attributes", "property": format!("axioval:example.ifc.{id}")});
let (output, result) = case.geometry_rule_with(
&["model.ifc"],
&[("column", "IfcColumn")],
(
"axioval:capability.containment",
®istry_signature("axioval:capability.containment"),
),
entity("column"),
json!({
"counterparts": {"type": "selector", "value": entity("wall")},
"minimum_volume_ratio": {"type": "number", "value": 0.99},
"cover": {"type": "table", "value": [cover_row("side", 0.04)]},
}),
&json!({"categories": [attribute("name"), attribute("object-type")]}),
&["--bcf", bcf.to_str().unwrap()],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = sorted_findings(&result);
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].0, "#29", "{findings:?}");
assert!(
findings[0].1.starts_with("[C1] [Precast] side cover"),
"{findings:?}"
);
assert_eq!(
result["report"]["findings"][0]["categories"],
json!(["C1", "Precast"]),
"{result:#}"
);
let archive = openbim_bcf::read_path(&bcf).unwrap();
let labels = &archive.topics().next().unwrap().topic.labels;
assert!(
labels.contains(&"Category: C1 / Precast".to_owned()),
"{labels:?}"
);
}
#[test]
fn with_geometry_orphans_and_counts_are_found() {
let case = Case::new("containment-counts");
let (output, result) = case.containment(&json!({
"report_orphans": {"type": "boolean", "value": true},
"maximum_count": {"type": "integer", "value": 1},
}));
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = sorted_findings(&result);
assert_eq!(findings.len(), 2, "{findings:?}");
assert_eq!(findings[0].0, "#19", "{findings:?}");
assert_eq!(
findings[0].1,
"holds 2 inner elements, more than the maximum 1"
);
assert_eq!(findings[1].0, "#49", "{findings:?}");
assert!(
findings[1].1.starts_with("lies in no outer element"),
"{findings:?}"
);
let (output, result) = case.containment(&json!({
"maximum_count": {"type": "integer", "value": 2},
}));
assert_eq!(
output.status.code(),
Some(0),
"{}{result:#}",
stderr(&output)
);
}
#[test]
fn with_geometry_escape_routes_too_long_and_too_few_exits_are_found() {
let case = Case::new("geometry-escape-route");
let sprinklered = json!({"kind": "property",
"propertySet": "axioval:example.ifc.pset-space-fire-safety",
"property": "axioval:example.ifc.sprinkler-protection", "operator": "equals",
"value": {"type": "boolean", "value": true}});
let (output, result) = case.geometry_rule(
&halls_with_exits(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.escape-route",
®istry_signature("axioval:capability.escape-route"),
entity("space"),
json!({
"uses": {"type": "table", "value": [
{"label": {"type": "string", "value": "assembly"},
"spaces": {"type": "selector", "value": sprinklered},
"exits": {"type": "integer", "value": 3}},
{"label": {"type": "string", "value": "open plan"},
"spaces": {"type": "selector", "value": entity("space")},
"maximum_travel": {"type": "number", "value": 15.0},
"exits": {"type": "integer", "value": 2}},
]},
"exit_path": {"type": "stringList",
"value": ["axioval:derived.adjacent-space:backward"]},
"exit_selector": {"type": "selector", "value": entity("door")},
"walking_height": {"type": "number", "value": 2.0},
"walking_step": {"type": "number", "value": 0.02},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 2, "{result:#}");
assert_eq!(findings[0].0, "#19", "{result:#}");
assert!(
findings[0]
.1
.starts_with("its farthest point, around (20.00, 0.00), lies ")
&& findings[0].1.contains("18.47")
&& findings[0].1.ends_with(
" m from the nearest exit walking; use 1 (open plan) allows at most 15 m of \
travel"
),
"{result:#}"
);
assert_eq!(
findings[1],
(
"#29".to_owned(),
"has 2 exit(s) via axioval:derived.adjacent-space; use 0 (assembly) requires at \
least 3"
.to_owned()
),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_travel_over_a_section_counts_by_its_factor() {
let case = Case::new("geometry-escape-route-weighted");
let (output, result) = case.geometry_rule(
&halls_with_exits(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.escape-route",
®istry_signature("axioval:capability.escape-route"),
entity("space"),
json!({
"uses": {"type": "table", "value": [
{"label": {"type": "string", "value": "open plan"},
"spaces": {"type": "selector", "value": entity("space")},
"maximum_travel": {"type": "number", "value": 30.0}},
]},
"sections": {"type": "table", "value": [
{"label": {"type": "string", "value": "slow floor"},
"objects": {"type": "selector", "value": entity("space")},
"factor": {"type": "number", "value": 2.0}},
]},
"exit_path": {"type": "stringList",
"value": ["axioval:derived.adjacent-space:backward"]},
"exit_selector": {"type": "selector", "value": entity("door")},
"walking_height": {"type": "number", "value": 2.0},
"walking_step": {"type": "number", "value": 0.02},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert!(
findings.iter().any(|(object, message)| object == "#19"
&& message.starts_with("its farthest point, around (20.00, 0.00), lies ")
&& message.contains(
" m from the nearest exit walking, counting the walk on section 0 (slow floor) \
by its factors; use 0 (open plan) allows at most 30 m of travel"
)),
"{result:#}"
);
}
#[test]
fn with_geometry_travel_over_a_section_counts_by_its_factor_while_climbing() {
let case = Case::new("geometry-escape-route-weighted-stairs");
let (output, result) = case.geometry_rule(
&halls_with_exits(),
&[
("door", "IfcDoor"),
("space", "IfcSpace"),
("stair", "IfcStair"),
],
"axioval:capability.escape-route",
®istry_signature("axioval:capability.escape-route"),
entity("space"),
json!({
"uses": {"type": "table", "value": [
{"label": {"type": "string", "value": "open plan"},
"spaces": {"type": "selector", "value": entity("space")},
"maximum_travel": {"type": "number", "value": 30.0}},
]},
"sections": {"type": "table", "value": [
{"label": {"type": "string", "value": "slow floor"},
"objects": {"type": "selector", "value": entity("space")},
"factor": {"type": "number", "value": 2.0}},
]},
"exit_path": {"type": "stringList",
"value": ["axioval:derived.adjacent-space:backward"]},
"exit_selector": {"type": "selector", "value": entity("door")},
"walking_height": {"type": "number", "value": 2.0},
"walking_step": {"type": "number", "value": 0.02},
"stair_selector": {"type": "selector", "value": entity("stair")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert!(
findings.iter().any(|(object, message)| object == "#19"
&& message.starts_with("its farthest point, around (20.00, 0.00), lies ")
&& message.contains(
" m from the nearest exit walking, counting the walk on section 0 (slow floor) \
by its factors; use 0 (open plan) allows at most 30 m of travel"
)),
"{result:#}"
);
}
#[test]
fn with_geometry_escape_routes_climb_only_the_stairs_the_rule_selects() {
let case = Case::new("geometry-escape-route-stairs");
let (output, result) = case.geometry_rule(
&halls_with_exits(),
&[
("door", "IfcDoor"),
("space", "IfcSpace"),
("stair", "IfcStair"),
],
"axioval:capability.escape-route",
®istry_signature("axioval:capability.escape-route"),
entity("space"),
json!({
"uses": {"type": "table", "value": [
{"spaces": {"type": "selector", "value": entity("space")},
"maximum_travel": {"type": "number", "value": 15.0}},
]},
"exit_path": {"type": "stringList",
"value": ["axioval:derived.adjacent-space:backward"]},
"exit_selector": {"type": "selector", "value": entity("door")},
"walking_height": {"type": "number", "value": 2.0},
"walking_step": {"type": "number", "value": 0.02},
"stair_selector": {"type": "selector", "value": entity("stair")},
"stair_length": {"type": "string", "value": "horizontal-plus-vertical"},
"vertical_factor": {"type": "number", "value": 2.0},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#19", "{result:#}");
assert!(
findings[0].1.contains("18.47") && findings[0].1.contains("walking"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_passages_too_narrow_for_their_occupants_are_found() {
let case = Case::new("geometry-escape-route-passages");
let (output, result) = case.geometry_rule(
&halls_with_exits(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.escape-route",
®istry_signature("axioval:capability.escape-route"),
entity("space"),
json!({
"uses": {"type": "table", "value": [
{"spaces": {"type": "selector", "value": entity("space")},
"area_per_occupant": {"type": "number", "value": 2.0}},
]},
"widths": {"type": "table", "value": [
{"occupants": {"type": "integer", "value": 500},
"width": {"type": "number", "value": 1.2},
"passage_width": {"type": "number", "value": 12.0}},
]},
"exit_path": {"type": "stringList",
"value": ["axioval:derived.adjacent-space:backward"]},
"exit_selector": {"type": "selector", "value": entity("door")},
"passage_selector": {"type": "selector", "value": entity("space")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
let passages: Vec<&(String, String)> = findings
.iter()
.filter(|(_, message)| message.starts_with("passage "))
.collect();
assert_eq!(passages.len(), 2, "{result:#}");
for ((object, message), hall) in passages.into_iter().zip(["#19", "#29"]) {
assert_eq!(object, hall, "{result:#}");
assert!(
message.contains(
" is at most 10 m wide (the shorter side of the rectangle enclosing its \
footprint); 100 occupant(s) relying on it (from "
) && message.ends_with(") require at least 12 m"),
"{message}"
);
}
assert_eq!(
findings
.iter()
.filter(|(_, message)| message.starts_with("exit ")
&& message.contains("is at most 1.005 m wide"))
.count(),
4,
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn metre_model(body: &str) -> String {
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{body}\
ENDSEC;\nEND-ISO-10303-21;\n"
)
}
fn plan_prism(first: u32, points: &[[f64; 2]], z: f64, height: f64, product: &str) -> String {
let [
origin,
frame,
placement,
polyline,
profile,
solid,
shape,
definition,
object,
] = [0, 1, 2, 3, 4, 5, 6, 7, 8].map(|offset| first + offset);
let mut text = String::new();
let mut corners = Vec::new();
for (index, [x, y]) in points.iter().chain(points.first()).enumerate() {
let corner = first + 10 + u32::try_from(index).unwrap();
writeln!(text, "#{corner}=IFCCARTESIANPOINT(({x:.3},{y:.3}));").unwrap();
corners.push(format!("#{corner}"));
}
write!(
text,
"#{origin}=IFCCARTESIANPOINT((0.,0.,{z:.3}));\n\
#{frame}=IFCAXIS2PLACEMENT3D(#{origin},$,$);\n\
#{placement}=IFCLOCALPLACEMENT($,#{frame});\n\
#{polyline}=IFCPOLYLINE(({}));\n\
#{profile}=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#{polyline});\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,{height:.3});\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{definition}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{object}={};\n",
corners.join(","),
product
.replace("GID", &format!("{object:022}"))
.replace("PL", &format!("#{placement}"))
.replace("REP", &format!("#{definition}")),
)
.unwrap();
text
}
fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> [[f64; 2]; 4] {
[[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
}
const SPACE: &str = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
fn store_rooms() -> String {
let door = "IFCDOOR('GID',$,$,$,$,PL,REP,$,2.1,1.,$,$,$)";
metre_model(&format!(
"{}{}{}",
plan_prism(10, &rectangle(0.0, 0.0, 6.0, 4.0), 0.0, 3.0, SPACE),
plan_prism(40, &rectangle(10.0, 0.0, 16.0, 4.0), 0.0, 1.5, SPACE),
plan_prism(70, &rectangle(2.5, -0.2, 3.5, 0.0), 0.0, 2.1, door),
))
}
#[test]
fn with_geometry_shelf_capacity_lays_bands_around_the_door_clearance() {
let case = Case::new("geometry-shelf-capacity");
let number = |value: f64| json!({"type": "number", "value": value});
let (output, result) = case.geometry_rule(
&store_rooms(),
&[("space", "IfcSpace"), ("door", "IfcDoor")],
"axioval:capability.shelf-capacity",
®istry_signature("axioval:capability.shelf-capacity"),
entity("space"),
json!({
"minimum_running_metres": number(80.0),
"shelf_depth_metres": number(0.5),
"horizontal_spacing_metres": number(1.0),
"vertical_spacing_metres": number(0.5),
"bottom_elevation_metres": number(0.0),
"top_elevation_metres": number(2.0),
"door_clearance_metres": number(1.0),
"access_path": {"type": "stringList", "value": ["axioval:derived.adjacent-space"]},
"door_selector": {"type": "selector", "value": entity("door")},
"space_selector": {"type": "selector", "value": entity("space")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 2, "{result:#}");
assert!(
findings.iter().all(|(space, _)| space == "#48"),
"{result:#}"
);
assert!(
findings.iter().any(|(_, message)| message
== "space too low for the shelving: clear height 1.5 m below the shelving's top \
elevation 2 m"),
"{result:#}"
);
assert!(
findings
.iter()
.any(|(_, message)| message == "shelf running metres 72.000 below required 80.000"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn spaces_with_niches() -> String {
let niche = |x: f64, width: f64, depth: f64| {
vec![
[x, 0.0],
[x + 4.0, 0.0],
[x + 4.0, depth],
[x + 4.0 + width, depth],
[x + 4.0 + width, 0.0],
[x + 10.0, 0.0],
[x + 10.0, 6.0],
[x, 6.0],
]
};
metre_model(&format!(
"{}{}",
plan_prism(10, &niche(0.0, 2.0, 1.5), 0.0, 3.0, SPACE),
plan_prism(40, &niche(20.0, 1.5, 0.5), 0.0, 3.0, SPACE),
))
}
#[test]
fn with_geometry_a_recess_too_narrow_for_its_depth_is_found() {
let case = Case::new("geometry-recess-width");
let number = |value: f64| json!({"type": "number", "value": value});
let (output, result) = case.geometry_rule(
&spaces_with_niches(),
&[("space", "IfcSpace")],
"axioval:capability.recess-width",
®istry_signature("axioval:capability.recess-width"),
entity("space"),
json!({
"requirements": {"type": "table", "value": [
{"maximum_depth_metres": number(1.0), "minimum_width_metres": number(1.0)},
{"minimum_depth_metres": number(1.0), "minimum_width_per_depth": number(1.5)},
]},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#18", "{result:#}");
assert!(
findings[0]
.1
.ends_with("is 2 m wide and 1.5 m deep; row 1 requires at least 2.25 m"),
"{result:#}"
);
}
fn light_wells() -> String {
let space = |first: u32, x: f64, width: f64, z: f64| {
plan_prism(first, &rectangle(x, 0.0, x + width, 3.0), z, 3.0, SPACE)
};
let zone = |id: u32, name: &str, members: &[u32]| {
let members: Vec<String> = members.iter().map(|member| format!("#{member}")).collect();
format!(
"#{id}=IFCZONE('{id:022}',$,'{name}',$,$,$);\n\
#{}=IFCRELASSIGNSTOGROUP('{:022}',$,$,$,({}),$,#{id});\n",
id + 1,
id + 1,
members.join(",")
)
};
metre_model(&format!(
"{}{}{}{}{}{}{}{}{}{}{}",
space(10, 0.0, 3.0, 0.0),
space(40, 0.5, 3.0, 3.0),
space(70, 0.0, 3.0, 6.0),
space(100, 10.0, 3.0, 0.0),
space(130, 10.0, 3.0, 4.0),
space(160, 20.0, 1.5, 0.0),
space(190, 20.0, 1.5, 3.0),
zone(300, "W1", &[18, 48, 78]),
zone(310, "W2", &[108, 138]),
zone(320, "W3", &[168, 198]),
"",
))
}
#[test]
fn with_geometry_light_wells_are_checked_for_contiguity_area_and_width() {
let case = Case::new("geometry-light-well");
let number = |value: f64| json!({"type": "number", "value": value});
let (output, result) = case.geometry_rule(
&light_wells(),
&[("space", "IfcSpace"), ("zone", "IfcZone")],
"axioval:capability.light-well",
®istry_signature("axioval:capability.light-well"),
entity("zone"),
json!({
"member_path": {"type": "stringList", "value": ["IfcRelAssignsToGroup:forward"]},
"requirements": {"type": "table", "value": [
{"maximum_height_metres": number(10.0),
"minimum_area_square_metres": number(6.0),
"minimum_width_metres": number(2.0)},
]},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
let of = |zone: &str| -> Vec<&str> {
findings
.iter()
.filter(|(object, _)| object == zone)
.map(|(_, message)| message.as_str())
.collect()
};
assert!(of("#300").is_empty(), "{result:#}");
let gap = of("#310");
assert_eq!(gap.len(), 1, "{result:#}");
assert!(
gap[0].contains("starts 1 m above the top of") && gap[0].ends_with("not contiguous"),
"{result:#}"
);
let narrow = of("#320");
assert_eq!(narrow.len(), 2, "{result:#}");
assert!(
narrow[0].starts_with("the well's section area is 4.5 m²; row 0 requires at least 6"),
"{result:#}"
);
assert!(
narrow[1].starts_with("the well's width is 1.5 m; row 0 requires at least 2"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn components_along_walls() -> String {
let wall = "IFCWALL('GID',$,$,$,$,PL,REP,$,$)";
let proxy = "IFCBUILDINGELEMENTPROXY('GID',$,$,$,$,PL,REP,$,$)";
let external = |wall: u32, value: &str| {
let [single, set, rel] = [0, 1, 2].map(|offset| 400 + wall + offset);
format!(
"#{single}=IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN({value}),$);\n\
#{set}=IFCPROPERTYSET('{set:022}',$,'Pset_WallCommon',$,(#{single}));\n\
#{rel}=IFCRELDEFINESBYPROPERTIES('{rel:022}',$,$,$,(#{wall}),#{set});\n"
)
};
metre_model(&format!(
"{}{}{}{}{}{}",
plan_prism(10, &rectangle(0.0, -0.3, 10.0, 0.0), 0.0, 3.0, wall),
plan_prism(40, &rectangle(0.0, 5.0, 10.0, 5.2), 0.0, 3.0, wall),
plan_prism(70, &rectangle(1.0, 0.0, 2.0, 0.6), 0.0, 1.0, proxy),
plan_prism(100, &rectangle(4.0, 4.4, 5.0, 5.0), 0.0, 1.0, proxy),
external(18, ".T."),
external(48, ".F."),
))
}
#[test]
fn with_geometry_a_component_away_from_every_external_wall_is_found() {
let case = Case::new("geometry-envelope-adjacency");
let external_walls = json!({"kind": "allOf", "operands": [
entity("wall"),
{"kind": "property", "propertySet": "axioval:example.ifc.pset-wall-common",
"property": "axioval:example.ifc.is-external", "operator": "equals",
"value": {"type": "boolean", "value": true}},
]});
let (output, result) = case.geometry_rule(
&components_along_walls(),
&[("wall", "IfcWall"), ("proxy", "IfcBuildingElementProxy")],
"axioval:capability.distance",
®istry_signature("axioval:capability.distance"),
entity("proxy"),
json!({
"counterparts": {"type": "selector", "value": external_walls},
"mode": {"type": "string", "value": "nearest"},
"maximum_metres": {"type": "number", "value": 0.05},
"projection": {"type": "string", "value": "horizontal"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_ids(&result), ["#108"], "{result:#}");
}
fn ifc4_model(body: &str) -> String {
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{body}\
ENDSEC;\nEND-ISO-10303-21;\n"
)
}
fn desks_and_doors() -> String {
let desk = "IFCFURNISHINGELEMENT('GID',$,$,$,$,PL,REP,$)";
let wall = "IFCWALL('GID',$,$,$,$,PL,REP,$,$)";
let door = "IFCDOOR('GID',$,$,$,$,PL,REP,$,2.,1.,$,$,$)";
let column = "IFCCOLUMN('GID',$,$,$,$,PL,REP,$,$)";
ifc4_model(&format!(
"{}{}{}{}{}{}",
placed_box(10, [0.0, 0.0, 0.0], [1.0, 0.6, 0.8], desk),
placed_box(20, [2.0, 0.0, 0.0], [0.2, 6.0, 3.0], wall),
placed_box(30, [4.0, 0.0, 0.0], [0.1, 1.0, 2.0], door),
placed_box(40, [20.0, 0.0, 0.0], [1.0, 0.6, 0.8], desk),
placed_box(50, [22.0, 0.0, 0.0], [0.2, 0.2, 3.0], column),
placed_box(60, [24.0, 0.0, 0.0], [0.1, 1.0, 2.0], door),
))
}
#[test]
fn with_geometry_a_door_hidden_by_a_wall_is_found_and_one_past_a_column_is_seen() {
let case = Case::new("geometry-component-visibility");
let (output, result) = case.geometry_rule(
&desks_and_doors(),
&[
("desk", "IfcFurnishingElement"),
("door", "IfcDoor"),
("column", "IfcColumn"),
],
"axioval:capability.component-visibility",
®istry_signature("axioval:capability.component-visibility"),
entity("desk"),
json!({
"targets": {"type": "selector", "value": entity("door")},
"blockers": {"type": "selector", "value": {"kind": "anyOf", "operands": [
entity("wall"), entity("column"),
]}},
"eye_height": {"type": "quantity", "value": 1.2, "unit": "m"},
"radius": {"type": "quantity", "value": 6, "unit": "m"},
"mode": {"type": "string", "value": "at-least"},
"minimum": {"type": "integer", "value": 1},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#19".to_owned(),
"0 target(s) within 6 m of the eye 1.2 m above the base of ifc-step:model.ifc/#19 \
are in view; required at least 1; 1 hidden"
.to_owned()
)],
"{result:#}"
);
assert_eq!(
result["report"]["findings"][0]["related"][0]["local_id"], "#39",
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn sprinklered_rooms() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let sprinkler = "IFCFIRESUPPRESSIONTERMINAL('GID',$,$,$,$,PL,REP,$,.SPRINKLER.)";
let wall = "IFCWALL('GID',$,$,$,$,PL,REP,$,$)";
ifc4_model(&format!(
"{}{}{}{}{}{}",
placed_box(10, [5.0, 2.0, 0.0], [10.0, 4.0, 3.0], space),
placed_box(20, [2.5, 2.0, 2.6], [0.2, 0.2, 0.1], sprinkler),
placed_box(30, [5.0, 12.0, 0.0], [10.0, 4.0, 3.0], space),
placed_box(40, [2.5, 12.0, 2.6], [0.2, 0.2, 0.1], sprinkler),
placed_box(50, [5.1, 1.75, 0.0], [0.2, 3.5, 3.0], wall),
placed_box(60, [7.5, 12.0, 2.6], [0.2, 0.2, 0.1], sprinkler),
))
}
#[test]
fn with_geometry_rooms_are_judged_by_how_much_of_them_their_sprinklers_reach() {
let case = Case::new("geometry-effective-coverage");
let types = [
("space", "IfcSpace"),
("sprinkler", "IfcFireSuppressionTerminal"),
];
let signature = registry_signature("axioval:capability.effective-coverage");
let (output, result) = case.geometry_rule(
&sprinklered_rooms(),
&types,
"axioval:capability.effective-coverage",
&signature,
entity("space"),
json!({
"sources": {"type": "selector", "value": entity("sprinkler")},
"mode": {"type": "string", "value": "grown"},
"range": {"type": "quantity", "value": 3, "unit": "m"},
"minimum_ratio": {"type": "number", "value": 0.9},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let found = finding_messages(&result);
assert_eq!(found.len(), 1, "{result:#}");
assert_eq!(found[0].0, "#19");
assert!(
found[0].1.starts_with("between 0.5")
&& found[0]
.1
.ends_with("(grown by 3 m); required at least 0.9"),
"{found:#?}"
);
let (output, result) = case.geometry_rule(
&sprinklered_rooms(),
&types,
"axioval:capability.effective-coverage",
&signature,
entity("space"),
json!({
"sources": {"type": "selector", "value": entity("sprinkler")},
"mode": {"type": "string", "value": "visible"},
"range": {"type": "quantity", "value": 20, "unit": "m"},
"minimum_ratio": {"type": "number", "value": 0.9},
"blockers": {"type": "selector", "value": entity("wall")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let found = finding_messages(&result);
assert_eq!(found.len(), 1, "{result:#}");
assert_eq!(found[0].0, "#19");
assert!(found[0].1.contains("(visible by 20 m)"), "{found:#?}");
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn rooms_with_a_sprinkler_next_door() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let opening = "IFCOPENINGELEMENT('GID',$,$,$,$,PL,REP,$,.OPENING.)";
let sprinkler = "IFCFIRESUPPRESSIONTERMINAL('GID',$,$,$,$,PL,REP,$,.SPRINKLER.)";
ifc4_model(&format!(
"{}{}{}{}",
placed_box(10, [2.0, 2.0, 0.0], [4.0, 4.0, 3.0], space),
placed_box(20, [6.2, 2.0, 0.0], [4.0, 4.0, 3.0], space),
placed_box(30, [4.1, 3.5, 0.0], [0.2, 1.0, 2.1], opening),
placed_box(40, [6.2, 3.5, 2.6], [0.2, 0.2, 0.1], sprinkler),
))
}
#[test]
fn with_geometry_an_effect_continues_through_an_opening_into_the_next_room() {
let case = Case::new("geometry-effective-coverage-connected");
let types = [
("space", "IfcSpace"),
("opening", "IfcOpeningElement"),
("sprinkler", "IfcFireSuppressionTerminal"),
];
let signature = registry_signature("axioval:capability.effective-coverage");
let check = |parameters: serde_json::Value| {
case.geometry_rule(
&rooms_with_a_sprinkler_next_door(),
&types,
"axioval:capability.effective-coverage",
&signature,
entity("space"),
parameters,
)
};
let mut parameters = json!({
"sources": {"type": "selector", "value": entity("sprinkler")},
"mode": {"type": "string", "value": "travel"},
"range": {"type": "quantity", "value": 3, "unit": "m"},
"minimum_ratio": {"type": "number", "value": 0.05},
});
let (output, result) = check(parameters.clone());
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let found = finding_messages(&result);
assert_eq!(found.len(), 1, "{result:#}");
assert_eq!(found[0].0, "#19", "{result:#}");
parameters["access_path"] = json!({"type": "stringList",
"value": ["axioval:derived.adjacent-space"]});
parameters["opening_selector"] = json!({"type": "selector", "value": entity("opening")});
let (output, result) = check(parameters);
assert_eq!(output.status.code(), Some(0), "{result:#}");
}
struct Boundaries {
next: u32,
data: String,
}
fn triple(values: [f64; 3]) -> String {
format!("({:.1},{:.1},{:.1})", values[0], values[1], values[2])
}
impl Boundaries {
fn id(&mut self) -> u32 {
self.next += 1;
self.next
}
fn ring(&mut self, points: &[[f64; 2]]) -> u32 {
let mut refs = Vec::new();
for point in points {
let entity = self.id();
writeln!(
self.data,
"#{entity}=IFCCARTESIANPOINT(({:.1},{:.1}));",
point[0], point[1]
)
.unwrap();
refs.push(format!("#{entity}"));
}
let polyline = self.id();
writeln!(
self.data,
"#{polyline}=IFCPOLYLINE(({},{}));",
refs.join(","),
refs[0]
)
.unwrap();
polyline
}
fn add(
&mut self,
space: u32,
location: [f64; 3],
axis: [f64; 3],
reference: [f64; 3],
size: [f64; 2],
hole: Option<[f64; 4]>,
) {
let [origin, normal, first, frame, plane] = [0; 5].map(|_| self.id());
writeln!(
self.data,
"#{origin}=IFCCARTESIANPOINT({});",
triple(location)
)
.unwrap();
writeln!(self.data, "#{normal}=IFCDIRECTION({});", triple(axis)).unwrap();
writeln!(self.data, "#{first}=IFCDIRECTION({});", triple(reference)).unwrap();
writeln!(
self.data,
"#{frame}=IFCAXIS2PLACEMENT3D(#{origin},#{normal},#{first});"
)
.unwrap();
writeln!(self.data, "#{plane}=IFCPLANE(#{frame});").unwrap();
let [width, height] = size;
let outer = self.ring(&[[0.0, 0.0], [width, 0.0], [width, height], [0.0, height]]);
let inner = hole
.map(|[left, bottom, right, top]| {
let ring = self.ring(&[[left, bottom], [right, bottom], [right, top], [left, top]]);
format!("#{ring}")
})
.unwrap_or_default();
let [bounded, connection, relation] = [0; 3].map(|_| self.id());
writeln!(
self.data,
"#{bounded}=IFCCURVEBOUNDEDPLANE(#{plane},#{outer},({inner}));"
)
.unwrap();
writeln!(
self.data,
"#{connection}=IFCCONNECTIONSURFACEGEOMETRY(#{bounded},$);"
)
.unwrap();
writeln!(
self.data,
"#{relation}=IFCRELSPACEBOUNDARY('{relation:022}',$,$,$,#{space},#99,\
#{connection},.PHYSICAL.,.INTERNAL.);"
)
.unwrap();
}
}
fn placed_space(first: u32, x: f64) -> String {
let [
origin,
frame,
placement,
centre,
position,
profile,
solid,
shape,
definition,
space,
] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(|offset| first + offset);
format!(
"#{origin}=IFCCARTESIANPOINT(({x:.1},5.,0.));\n\
#{frame}=IFCAXIS2PLACEMENT3D(#{origin},$,$);\n\
#{placement}=IFCLOCALPLACEMENT($,#{frame});\n\
#{centre}=IFCCARTESIANPOINT((2.,1.5));\n\
#{position}=IFCAXIS2PLACEMENT2D(#{centre},$);\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{position},4.,3.);\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,2.5);\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{definition}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{space}=IFCSPACE('{space:022}',$,$,$,$,#{placement},#{definition},$,.ELEMENT.,$,$);\n"
)
}
fn spaces_with_boundaries() -> String {
let mut boundaries = Boundaries {
next: 1000,
data: String::new(),
};
let (up, south, west) = ([0.0, 0.0, 1.0], [0.0, -1.0, 0.0], [1.0, 0.0, 0.0]);
let (east, north) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
let floor_hole = Some([1.0, 1.0, 2.0, 2.0]);
boundaries.add(39, [0.0; 3], up, east, [4.0, 3.0], floor_hole);
boundaries.add(39, [0.0, 0.0, 2.5], up, east, [4.0, 3.0], None);
boundaries.add(39, [0.0; 3], south, east, [4.0, 2.5], None);
boundaries.add(39, [0.0, 3.0, 0.0], south, east, [4.0, 2.5], None);
boundaries.add(39, [0.0; 3], west, north, [3.0, 2.5], None);
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}\
#80=IFCCARTESIANPOINT((0.,0.,0.));\n\
#81=IFCCARTESIANPOINT((4.,0.,0.));\n\
#82=IFCCARTESIANPOINT((4.,3.,0.));\n\
#83=IFCPOLYLOOP((#80,#81,#82));\n\
#84=IFCFACEOUTERBOUND(#83,.T.);\n\
#85=IFCPLANE(#2);\n\
#86=IFCFACESURFACE((#84),#85,.T.);\n\
#87=IFCCONNECTIONSURFACEGEOMETRY(#86,$);\n\
#88=IFCRELSPACEBOUNDARY('0000000000000000000088',$,$,$,#69,#99,#87,.PHYSICAL.,.INTERNAL.);\n\
{}\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_space(30, 10.0),
placed_space(60, 20.0),
placed_box(
90,
[30.0, 0.0, 0.0],
[4.0, 0.2, 3.0],
"IFCWALL('GID',$,$,$,$,PL,REP,$,.STANDARD.)"
),
boundaries.data,
)
}
#[test]
fn with_geometry_space_boundaries_are_measured_against_the_space_surface() {
let case = Case::new("geometry-space-boundary-coverage");
let (output, result) = case.geometry_rule(
&spaces_with_boundaries(),
&[("space", "IfcSpace")],
"axioval:capability.space-boundary-coverage",
®istry_signature("axioval:capability.space-boundary-coverage"),
entity("space"),
json!({
"minimum_covered_share": {"type": "number", "value": 0.9},
"maximum_uncovered_area": {"type": "quantity", "value": 0.5, "unit": "m2"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[
(
"#39".to_owned(),
"declared boundaries cover 85.59% of the 59 m² surface, leaving 8.5 m² \
uncovered; at least 90% required"
.to_owned()
),
(
"#39".to_owned(),
"declared boundaries leave 8.5 m² of the 59 m² surface uncovered; at most \
0.5 m² allowed"
.to_owned()
),
(
"#69".to_owned(),
"declared boundaries cover 10.17% of the 59 m² surface, leaving 53 m² \
uncovered; at least 90% required"
.to_owned()
),
(
"#69".to_owned(),
"declared boundaries leave 53 m² of the 59 m² surface uncovered; at most \
0.5 m² allowed"
.to_owned()
),
],
"{result:#}"
);
assert!(
result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.all(|finding| finding["evidence"][0]["exact"] == json!(true)),
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
fn profiled_columns() -> String {
let column = |id: u32, profile: &str| {
format!(
"#{a}={profile};\n#{b}=IFCEXTRUDEDAREASOLID(#{a},#2,#4,3.);\n\
#{c}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{b}));\n\
#{d}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{c}));\n\
#{id}=IFCCOLUMN('00000000000000000000{id:02}',$,$,$,$,#3,#{d},$,.COLUMN.);\n",
a = id + 1,
b = id + 2,
c = id + 3,
d = id + 4,
)
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#9=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);\n\
#7=IFCUNITASSIGNMENT((#6,#9));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}\
#40=IFCCARTESIANPOINT((0.,0.));\n\
#41=IFCCARTESIANPOINT((0.3,0.));\n\
#42=IFCCARTESIANPOINT((0.,0.3));\n\
#43=IFCPOLYLINE((#40,#41,#42,#40));\n\
{}\
ENDSEC;\nEND-ISO-10303-21;\n",
column(
10,
"IFCISHAPEPROFILEDEF(.AREA.,'HEA300',$,0.3,0.29,0.0085,0.014,0.027,$,$)"
),
column(
20,
"IFCISHAPEPROFILEDEF(.AREA.,'HEA300',$,0.3,0.295,0.0085,0.014,0.027,$,$)"
),
column(30, "IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#43)"),
)
}
#[test]
fn column_profiles_are_checked_against_a_table_of_allowed_profiles() {
let case = Case::new("allowed-profile");
let mut signature = registry_signature("axioval:capability.allowed-profile");
for column in signature["profiles"]["columns"].as_array_mut().unwrap() {
if column["kind"] == "quantity" {
column["unitDimension"] = json!("length");
}
}
let millimetres = |value: f64| json!({"type": "quantity", "value": value, "unit": "mm"});
let (output, result) = case.geometry_rule(
&profiled_columns(),
&[("column", "IfcColumn")],
"axioval:capability.allowed-profile",
&signature,
entity("column"),
json!({
"profiles": {"type": "table", "value": [
{"type": {"type": "string", "value": "i-shape"},
"name": {"type": "string", "value": "HEA*"},
"width": millimetres(300.0), "depth": millimetres(290.0)},
]},
"tolerance": millimetres(1.0),
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[
(
"#20".to_owned(),
"profile `i-shape` `HEA300` is not an allowed profile; nearest is row 1 \
(`i-shape` `HEA*`): depth 0.295 m, allowed 0.29 m within 0.001 m"
.to_owned()
),
(
"#30".to_owned(),
"arbitrary profile `arbitrary-closed`: no allowed profile is of its type"
.to_owned()
),
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
fn hosts_with_openings() -> String {
let opening = |id: u32, host: u32, profile: &str, at: [f64; 3], axis: &str, depth: f64| {
format!(
"#{a}={profile};\n#{b}=IFCCARTESIANPOINT(({},{},{}));\n\
#{c}=IFCAXIS2PLACEMENT3D(#{b},{axis});\n\
#{d}=IFCEXTRUDEDAREASOLID(#{a},#{c},#4,{depth});\n\
#{e}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{d}));\n\
#{f}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{e}));\n\
#{id}=IFCOPENINGELEMENT('{id:022}',$,$,$,$,#3,#{f},$,.OPENING.);\n\
#{g}=IFCRELVOIDSELEMENT('{g:022}',$,$,$,#{host},#{id});\n",
at[0],
at[1],
at[2],
a = id + 1,
b = id + 2,
c = id + 3,
d = id + 4,
e = id + 5,
f = id + 6,
g = id + 7,
)
};
let window = |id: u32, x: f64| {
opening(
id,
10,
"IFCRECTANGLEPROFILEDEF(.AREA.,$,$,1.,1.2)",
[x, 0.1, 1.5],
"#6,#7",
0.2,
)
};
let hole = |id: u32, x: f64, z: f64| {
opening(
id,
50,
"IFCCIRCLEPROFILEDEF(.AREA.,$,$,0.05)",
[x, -0.2, 3.0 + z],
"#8,#7",
0.4,
)
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCDIRECTION((0.,-1.,0.));\n\
#7=IFCDIRECTION((1.,0.,0.));\n\
#8=IFCDIRECTION((0.,1.,0.));\n\
#20=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#21=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);\n\
#22=IFCUNITASSIGNMENT((#20,#21));\n\
#23=IFCPROJECT('0000000000000000000023',$,'P',$,$,$,$,(#5),#22);\n\
#11=IFCCARTESIANPOINT((2.5,0.));\n\
#12=IFCAXIS2PLACEMENT2D(#11,$);\n\
#13=IFCRECTANGLEPROFILEDEF(.AREA.,$,#12,5.,0.2);\n\
#14=IFCEXTRUDEDAREASOLID(#13,#2,#4,3.);\n\
#15=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#14));\n\
#16=IFCPRODUCTDEFINITIONSHAPE($,$,(#15));\n\
#10=IFCWALL('0000000000000000000010',$,$,$,$,#3,#16,$,.STANDARD.);\n\
#51=IFCISHAPEPROFILEDEF(.AREA.,'I300',$,0.3,0.3,0.01,0.02,$,$,$);\n\
#52=IFCCARTESIANPOINT((0.,0.,3.));\n\
#55=IFCAXIS2PLACEMENT3D(#52,#7,#8);\n\
#56=IFCEXTRUDEDAREASOLID(#51,#55,#4,6.);\n\
#57=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#56));\n\
#58=IFCPRODUCTDEFINITIONSHAPE($,$,(#57));\n\
#50=IFCBEAM('0000000000000000000050',$,$,$,$,#3,#58,$,.BEAM.);\n\
{}{}{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
window(100, 2.0),
window(200, 3.6),
window(300, 4.7),
hole(400, 3.0, 0.0),
hole(500, 0.2, 0.0),
hole(600, 4.5, 0.1),
)
}
fn opening_zone(name: &str, host: &str, parameters: Value) -> (Output, Value) {
let case = Case::new(name);
let mut parameters = parameters;
parameters["host_path"] =
json!({"type": "stringList", "value": ["IfcRelVoidsElement:backward"]});
parameters["host_selector"] = json!({"type": "selector", "value": entity(host)});
case.geometry_rule(
&hosts_with_openings(),
&[
("opening", "IfcOpeningElement"),
("wall", "IfcWall"),
("beam", "IfcBeam"),
],
"axioval:capability.opening-zone",
®istry_signature("axioval:capability.opening-zone"),
entity("opening"),
parameters,
)
}
#[test]
fn wall_openings_are_checked_against_the_walls_outline_and_each_other() {
let metres = |value: f64| json!({"type": "quantity", "value": value, "unit": "m"});
let (output, result) = opening_zone(
"opening-zone-wall",
"wall",
json!({
"length_axis": {"type": "string", "value": "profile-x"},
"height_axis": {"type": "string", "value": "extrusion"},
"end_distance": metres(0.5),
"edge_distance": metres(0.5),
"opening_spacing": metres(0.8),
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[
(
"#100".to_owned(),
"opening is 0.6 m clear of another opening in its host #10; 0.8 m required"
.to_owned()
),
(
"#200".to_owned(),
"opening is 0.1 m clear of another opening in its host #10; 0.8 m required"
.to_owned()
),
(
"#300".to_owned(),
"opening is 0.1 m clear of another opening in its host #10; 0.8 m required"
.to_owned()
),
(
"#300".to_owned(),
"opening lies partly outside its host #10: along its length it spans 1.7 m to \
2.7 m, the host -2.5 m to 2.5 m"
.to_owned()
),
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
#[test]
fn wall_openings_are_checked_against_the_dimensioning_table() {
let (output, result) = opening_zone(
"opening-zone-dimensions",
"wall",
json!({
"length_axis": {"type": "string", "value": "profile-x"},
"height_axis": {"type": "string", "value": "extrusion"},
"dimensions": {"type": "table", "value": [
{"name": {"type": "string", "value": "window to side"},
"source": {"type": "selector", "value": entity("opening")},
"edge": {"type": "string", "value": "side"},
"minimum": {"type": "quantity", "value": 1.0, "unit": "m"}},
]},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[
(
"#200".to_owned(),
"opening is 0.9 m from the side of its host #10 along its length; at least 1 m \
required (dimension `window to side`)"
.to_owned()
),
(
"#300".to_owned(),
"opening lies partly outside its host #10: along its length it spans 1.7 m to \
2.7 m, the host -2.5 m to 2.5 m"
.to_owned()
),
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
#[test]
fn beam_holes_are_checked_against_the_web_zone_and_the_beams_ends() {
let (output, result) = opening_zone(
"opening-zone-beam",
"beam",
json!({
"length_axis": {"type": "string", "value": "extrusion"},
"height_axis": {"type": "string", "value": "profile-y"},
"end_distance": {"type": "quantity", "value": 300.0, "unit": "mm"},
"zone": {"type": "string", "value": "web"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[
(
"#500".to_owned(),
"opening is 0.15 m from an end of its host #50; 0.3 m required".to_owned()
),
(
"#600".to_owned(),
"opening reaches 0.02 m into the flanges of its host #50; 0 m clear required"
.to_owned()
),
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
fn beam_with_ducts() -> String {
let duct = |id: u32, x: f64| {
format!(
"#{a}=IFCRECTANGLEPROFILEDEF(.AREA.,$,$,0.1,0.1);\n\
#{b}=IFCCARTESIANPOINT(({x},-1.,3.));\n\
#{c}=IFCAXIS2PLACEMENT3D(#{b},#8,#7);\n\
#{d}=IFCEXTRUDEDAREASOLID(#{a},#{c},#4,2.);\n\
#{e}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{d}));\n\
#{f}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{e}));\n\
#{id}=IFCDUCTSEGMENT('{id:022}',$,$,$,$,#3,#{f},$,.RIGIDSEGMENT.);\n",
a = id + 1,
b = id + 2,
c = id + 3,
d = id + 4,
e = id + 5,
f = id + 6,
)
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#7=IFCDIRECTION((1.,0.,0.));\n\
#8=IFCDIRECTION((0.,1.,0.));\n\
#20=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#21=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);\n\
#22=IFCUNITASSIGNMENT((#20,#21));\n\
#23=IFCPROJECT('0000000000000000000023',$,'P',$,$,$,$,(#5),#22);\n\
#51=IFCISHAPEPROFILEDEF(.AREA.,'I300',$,0.3,0.3,0.01,0.02,$,$,$);\n\
#52=IFCCARTESIANPOINT((0.,0.,3.));\n\
#55=IFCAXIS2PLACEMENT3D(#52,#7,#8);\n\
#56=IFCEXTRUDEDAREASOLID(#51,#55,#4,6.);\n\
#57=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#56));\n\
#58=IFCPRODUCTDEFINITIONSHAPE($,$,(#57));\n\
#50=IFCBEAM('0000000000000000000050',$,$,$,$,#3,#58,$,.BEAM.);\n\
{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
duct(100, 3.0),
duct(200, 0.2),
duct(300, 6.5),
)
}
#[test]
fn with_geometry_ducts_through_a_beam_without_voids_are_checked_in_the_beam() {
let case = Case::new("opening-zone-penetration");
let (output, result) = case.geometry_rule(
&beam_with_ducts(),
&[("duct", "IfcDuctSegment"), ("beam", "IfcBeam")],
"axioval:capability.opening-zone",
®istry_signature("axioval:capability.opening-zone"),
entity("duct"),
json!({
"host_path": {"type": "stringList", "value": ["axioval:derived.intersects"]},
"host_selector": {"type": "selector", "value": entity("beam")},
"length_axis": {"type": "string", "value": "extrusion"},
"height_axis": {"type": "string", "value": "profile-y"},
"end_distance": {"type": "quantity", "value": 0.3, "unit": "m"},
"edge_distance": {"type": "quantity", "value": 0.05, "unit": "m"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#200".to_owned(),
"opening is 0.15 m from an end of its host #50; 0.3 m required".to_owned()
)],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
assert_eq!(result["geometry"]["exact"], 4, "{result:#}");
assert_eq!(result["geometry"]["unmeasured"], json!([]), "{result:#}");
let cited = result.to_string();
assert!(
cited.contains("axioval:derived.intersects:"),
"the derivation is cited: {result:#}"
);
}
#[test]
fn with_geometry_a_duct_in_either_of_two_allowed_zones_passes() {
let run = |name: &str, ends: f64| {
let metres = |value: f64| json!({"type": "quantity", "value": value, "unit": "m"});
Case::new(name).geometry_rule(
&beam_with_ducts(),
&[("duct", "IfcDuctSegment"), ("beam", "IfcBeam")],
"axioval:capability.opening-zone",
®istry_signature("axioval:capability.opening-zone"),
entity("duct"),
json!({
"host_path": {"type": "stringList", "value": ["axioval:derived.intersects"]},
"host_selector": {"type": "selector", "value": entity("beam")},
"length_axis": {"type": "string", "value": "extrusion"},
"height_axis": {"type": "string", "value": "profile-y"},
"zones": {"type": "table", "value": [
{"name": {"type": "string", "value": "middle"},
"end_fraction": {"type": "number", "value": 0.4}},
{"name": {"type": "string", "value": "ends"},
"end_minimum": metres(ends),
"top_minimum": metres(0.05)},
]},
}),
)
};
let (output, result) = run("opening-zone-zones", 0.1);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert_eq!(finding_messages(&result), [], "{result:#}");
let (output, result) = run("opening-zone-zones-strict", 0.2);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#200".to_owned(),
"opening lies outside any of the 2 allowed zones of its host #50: nearest is zone \
`ends`, where it is 0.15 m from an end (0.2 m required)"
.to_owned()
)],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
const PROPERTY_KINDS: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#90=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);\n\
#91=IFCUNITASSIGNMENT((#90));\n\
#92=IFCPROJECT('0000000000000000000092',$,'P',$,$,$,$,$,#91);\n\
#1=IFCWALL('0000000000000000000001',$,$,$,$,$,$,$,$);\n\
#2=IFCWALL('0000000000000000000002',$,$,$,$,$,$,$,$);\n\
#3=IFCPROPERTYENUMERATION('Status',(IFCLABEL('NEW'),IFCLABEL('EXISTING'),IFCLABEL('DEMOLISH')),$);\n\
#4=IFCPROPERTYENUMERATEDVALUE('Status',$,(IFCLABEL('NEW')),#3);\n\
#5=IFCPROPERTYENUMERATEDVALUE('Status',$,(IFCLABEL('EXISTING'),IFCLABEL('DEMOLISH')),#3);\n\
#6=IFCPROPERTYBOUNDEDVALUE('Span',$,IFCLENGTHMEASURE(5000.),IFCLENGTHMEASURE(1000.),$,$);\n\
#7=IFCPROPERTYBOUNDEDVALUE('Span',$,$,IFCLENGTHMEASURE(1000.),$,$);\n\
#8=IFCPROPERTYTABLEVALUE('Load',$,(IFCREAL(1.),IFCREAL(2.)),(IFCREAL(10.),IFCREAL(20.)),$,$,$,$);\n\
#10=IFCPROPERTYSET('0000000000000000000010',$,'Pset_Kinds',$,(#4,#6,#8));\n\
#11=IFCPROPERTYSET('0000000000000000000011',$,'Pset_Kinds',$,(#5,#7));\n\
#12=IFCRELDEFINESBYPROPERTIES('0000000000000000000012',$,$,$,(#1),#10);\n\
#13=IFCRELDEFINESBYPROPERTIES('0000000000000000000013',$,$,$,(#2),#11);\n\
#20=IFCPROPERTYSINGLEVALUE('CheckA',$,IFCLABEL('ok'),$);\n\
#21=IFCPROPERTYSINGLEVALUE('CheckB',$,IFCLABEL(''),$);\n\
#22=IFCPROPERTYSET('0000000000000000000022',$,'Pset_Checks',$,(#20,#21));\n\
#23=IFCRELDEFINESBYPROPERTIES('0000000000000000000023',$,$,$,(#1,#2),#22);\n\
#24=IFCPROPERTYSINGLEVALUE('Note',$,IFCTEXT('temporary'),$);\n\
#25=IFCPROPERTYSET('0000000000000000000025',$,'Pset_Draft',$,(#24));\n\
#26=IFCRELDEFINESBYPROPERTIES('0000000000000000000026',$,$,$,(#2),#25);\n\
ENDSEC;\nEND-ISO-10303-21;\n";
fn property_packages(case: &Case, rules: &[(&str, &str, Value)]) -> (PathBuf, PathBuf) {
let text = |value: &str| json!({"default": value, "translations": {}});
let concept = |name: &str| {
json!({"id": format!("axioval:example.ifc.{name}"), "name": text(name),
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": name}],
"citations": []})
};
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(case.definitions(true)).unwrap()).unwrap();
definitions["propertySets"]["axioval:example.ifc.Pset_Kinds"] = concept("Pset_Kinds");
for name in ["Status", "Span", "Load"] {
let mut property = concept(name);
property["valueKind"] = json!("string");
definitions["properties"][format!("axioval:example.ifc.{name}")] = property;
}
for (id, capability, _) in rules {
let capability = format!("axioval:capability.{capability}");
definitions["definitions"][format!("axioval:example.{id}")] = json!({
"id": format!("axioval:example.{id}"), "name": text(id), "description": text(id),
"capability": capability, "parameters": registry_signature(&capability),
"citations": [], "tags": [],
});
}
let text_file = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text_file).unwrap();
let template = ruleset["root"]["rules"][0].clone();
ruleset["root"]["rules"] = rules
.iter()
.map(|(id, _, parameters)| {
let mut rule = template.clone();
rule["id"] = json!(id);
rule["definitionId"] = json!(format!("axioval:example.{id}"));
rule["parameters"] = parameters.clone();
rule
})
.collect();
(
case.write("definitions.json", &definitions.to_string()),
case.write("ruleset.json", &ruleset.to_string()),
)
}
fn check_packages(case: &Case, model: &str, packages: (PathBuf, PathBuf)) -> (Output, Value) {
let model = case.write("model.ifc", model);
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(packages.0)
.arg("--ruleset")
.arg(packages.1)
.output()
.unwrap();
let result = json(&output);
(output, result)
}
fn rule_findings(result: &Value) -> Vec<(String, String, String)> {
let mut findings: Vec<(String, String, String)> = result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| {
(
finding["rule_id"].as_str().unwrap().to_owned(),
finding["object_id"]["local_id"]
.as_str()
.unwrap()
.to_owned(),
finding["message"].as_str().unwrap().to_owned(),
)
})
.collect();
findings.sort();
findings
}
#[test]
fn enumerated_bounded_and_table_values_are_checked_value_by_value() {
let case = Case::new("property-kinds");
let reference = |name: &str| {
json!({"type": "propertyReference", "property": format!("axioval:example.ifc.{name}"),
"propertySet": "axioval:example.ifc.Pset_Kinds"})
};
let string = |value: &str| json!({"type": "string", "value": value});
let packages = property_packages(
&case,
&[
(
"status-new",
"property-value",
json!({"property": reference("Status"),
"values": {"type": "stringList", "value": ["NEW"]},
"quantifier": string("any")}),
),
(
"span-within",
"property-value",
json!({"property": reference("Span"),
"min_inclusive": string("0.5"), "max_inclusive": string("6"),
"quantifier": string("all"),
"si_units": {"type": "boolean", "value": true}}),
),
(
"load-listed",
"property-value",
json!({"property": reference("Load"),
"values": {"type": "stringList", "value": ["20"]},
"quantifier": string("any"), "optional": {"type": "boolean", "value": true}}),
),
],
);
let (output, result) = check_packages(&case, PROPERTY_KINDS, packages);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
rule_findings(&result),
[
(
"span-within".to_owned(),
"#2".to_owned(),
"property axioval:example.ifc.Span is [range from 1 m, open above], open above, \
so not all its values meet the upper bound"
.to_owned()
),
(
"status-new".to_owned(),
"#2".to_owned(),
"property axioval:example.ifc.Status is [`EXISTING`, `DEMOLISH`], and none of \
its values meets the constraints"
.to_owned()
),
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
#[test]
fn a_table_whose_columns_differ_is_checked_by_the_cells_of_the_declared_type() {
let case = Case::new("property-table-columns");
let reference = json!({"type": "propertyReference", "property": "axioval:example.ifc.Load",
"propertySet": "axioval:example.ifc.Pset_Kinds"});
let string = |value: &str| json!({"type": "string", "value": value});
let optional = json!({"type": "boolean", "value": true});
let packages = property_packages(
&case,
&[
(
"load-label",
"property-value",
json!({"property": reference, "data_type": string("IFCLABEL"),
"values": {"type": "stringList", "value": ["B"]},
"quantifier": string("any"), "optional": optional}),
),
(
"load-length",
"property-value",
json!({"property": reference, "data_type": string("IFCLENGTHMEASURE"),
"values": {"type": "stringList", "value": ["3"]},
"quantifier": string("any"), "optional": optional,
"si_units": {"type": "boolean", "value": true}}),
),
(
"load-real",
"property-data-type",
json!({"property": reference, "data_type": string("IFCREAL")}),
),
],
);
let model = PROPERTY_KINDS.replace(
"#8=IFCPROPERTYTABLEVALUE('Load',$,(IFCREAL(1.),IFCREAL(2.)),(IFCREAL(10.),IFCREAL(20.)),$,$,$,$);",
"#8=IFCPROPERTYTABLEVALUE('Load',$,(IFCLABEL('A'),IFCLABEL('B')),(IFCLENGTHMEASURE(1000.),IFCLENGTHMEASURE(2000.)),$,$,$,$);",
);
let (output, result) = check_packages(&case, &model, packages);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
rule_findings(&result),
[
(
"load-length".to_owned(),
"#1".to_owned(),
"property axioval:example.ifc.Load is [1 m, 2 m], and none of its values \
meets the constraints"
.to_owned()
),
(
"load-real".to_owned(),
"#1".to_owned(),
"property axioval:example.ifc.Load has no column of type IFCREAL".to_owned()
),
(
"load-real".to_owned(),
"#2".to_owned(),
"missing required property axioval:example.ifc.Load".to_owned()
),
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
#[test]
fn property_sets_and_properties_named_by_pattern_are_enumerated() {
let case = Case::new("property-patterns");
let string = |value: &str| json!({"type": "string", "value": value});
let packages = property_packages(
&case,
&[
(
"requirements",
"property-requirements",
json!({"requirements": {"type": "table", "value": [
{"property_set_pattern": string("Pset_Ch.*"),
"property_pattern": string("Check[A-Z]"),
"requirement": string("required")},
{"property_set_pattern": string("Pset_Draft"),
"requirement": string("forbidden")},
{"property_set": string("axioval:example.ifc.Pset_Kinds"),
"property_pattern": string("Sta.*"),
"requirement": string("required"),
"one_of": string("NEW|EXISTING|DEMOLISH")},
]}}),
),
(
"checks-ok",
"property-value",
json!({"property_set_pattern": string("Pset_.*"),
"property_pattern": string("Check.*"),
"values": {"type": "stringList", "value": ["ok"]},
"optional": {"type": "boolean", "value": true}}),
),
],
);
let (output, result) = check_packages(&case, PROPERTY_KINDS, packages);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let expected: Vec<(String, String, String)> = [
(
"checks-ok",
"#1",
"property Pset_Checks.CheckB is \"\", not one of the required values",
),
(
"checks-ok",
"#2",
"property Pset_Checks.CheckB is \"\", not one of the required values",
),
(
"requirements",
"#1",
"missing value: Pset_Checks.CheckB is `` (requirement row 0)",
),
(
"requirements",
"#2",
"forbidden property set present: /Pset_Draft/ is present with 1 property \
(requirement row 1)",
),
(
"requirements",
"#2",
"missing value: Pset_Checks.CheckB is `` (requirement row 0)",
),
]
.iter()
.map(|(rule, object, message)| {
(
(*rule).to_owned(),
(*object).to_owned(),
(*message).to_owned(),
)
})
.collect();
assert_eq!(rule_findings(&result), expected, "{result:#}");
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
fn swinging_door(
first: u32,
[x, y, z]: [f64; 3],
[ax, ay]: [f64; 2],
width: f64,
operation: &str,
panels: &[(&str, &str, &str)],
) -> String {
let at = |offset: u32| first + offset;
let door = at(10);
let mut records = format!(
"#{p}=IFCCARTESIANPOINT(({x:?},{y:?},{z:?}));\n\
#{d}=IFCDIRECTION(({ax:?},{ay:?},0.));\n\
#{a}=IFCAXIS2PLACEMENT3D(#{p},#4,#{d});\n\
#{l}=IFCLOCALPLACEMENT($,#{a});\n\
#{c}=IFCCARTESIANPOINT(({half:?},0.05));\n\
#{c2}=IFCAXIS2PLACEMENT2D(#{c},$);\n\
#{r}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{c2},{width:?},0.1);\n\
#{s}=IFCEXTRUDEDAREASOLID(#{r},#2,#4,2.1);\n\
#{sh}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{s}));\n\
#{pd}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{sh}));\n\
#{door}=IFCDOOR('{door:022}',$,$,$,$,#{l},#{pd},$,2.1,{width:?},.DOOR.,.{operation}.,$);\n\
#{lining}=IFCDOORLININGPROPERTIES('{lining:022}',$,$,$,0.1,0.05,$,0.02,$,$,$,$,$,$,$,$,$);\n\
#{rel}=IFCRELDEFINESBYPROPERTIES('{rel:022}',$,$,$,(#{door}),#{lining});\n",
p = at(0),
d = at(1),
a = at(2),
l = at(3),
c = at(4),
c2 = at(5),
r = at(6),
s = at(7),
sh = at(8),
pd = at(9),
half = width / 2.0,
lining = at(11),
rel = at(12),
);
for (index, (panel_operation, position, ratio)) in (0_u32..).zip(panels) {
let (set, rel) = (at(13 + 2 * index), at(14 + 2 * index));
let _ = write!(
records,
"#{set}=IFCDOORPANELPROPERTIES('{set:022}',$,$,$,0.04,.{panel_operation}.,{ratio},.{position}.,$);\n\
#{rel}=IFCRELDEFINESBYPROPERTIES('{rel:022}',$,$,$,(#{door}),#{set});\n"
);
}
records
}
fn doors_and_columns() -> String {
let column = "IFCCOLUMN('GID',$,$,$,$,PL,REP,$,$)";
model_with(&format!(
"{}{}{}{}",
swinging_door(
20,
[0.0, 0.0, 0.0],
[1.0, 0.0],
0.9,
"SINGLE_SWING_LEFT",
&[("SWINGING", "LEFT", "$")]
),
placed_box(50, [0.3, 1.3, 0.0], [0.2, 0.2, 2.0], column),
placed_box(60, [3.1, 0.5, 0.0], [0.2, 0.2, 2.0], column),
swinging_door(
80,
[5.0, 0.0, 0.0],
[1.0, 0.0],
0.9,
"SLIDING_TO_LEFT",
&[("SLIDING", "LEFT", "$")]
),
))
}
#[test]
fn with_geometry_a_column_within_a_door_swing_reach_is_found() {
let case = Case::new("geometry-door-swing-distance");
let (output, result) = case.geometry_rule(
&doors_and_columns(),
&[("door", "IfcDoor"), ("column", "IfcColumn")],
"axioval:capability.distance",
®istry_signature("axioval:capability.distance"),
entity("door"),
json!({
"counterparts": {"type": "selector", "value": entity("column")},
"subject_extent": {"type": "string", "value": "door_swing"},
"mode": {"type": "string", "value": "none_closer_than"},
"projection": {"type": "string", "value": "horizontal"},
"minimum_metres": {"type": "number", "value": 0.5},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
let [(door, message)] = &findings[..] else {
panic!("one finding: {result:#}")
};
assert_eq!(door, "#30");
assert!(
message.contains("/#59 at horizontal distance between 0.316"),
"{message}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn door_with_a_column_by_its_handle() -> String {
let column = "IFCCOLUMN('GID',$,$,$,$,PL,REP,$,$)";
model_with(&format!(
"{}{}{}",
swinging_door(
20,
[0.0, 0.0, 0.0],
[1.0, 0.0],
0.9,
"SINGLE_SWING_LEFT",
&[("SWINGING", "LEFT", "$")]
),
placed_box(50, [0.7, 0.4, 0.0], [0.1, 0.1, 2.0], column),
swinging_door(
80,
[5.0, 0.0, 0.0],
[1.0, 0.0],
0.9,
"SLIDING_TO_LEFT",
&[("SLIDING", "LEFT", "$")]
),
))
}
fn door_clearance(case: &Case, front: &str, align: &str, both: bool) -> (Output, Value) {
case.geometry_rule(
&door_with_a_column_by_its_handle(),
&[("door", "IfcDoor"), ("column", "IfcColumn")],
"axioval:capability.component-clearance",
®istry_signature("axioval:capability.component-clearance"),
entity("door"),
json!({
"side": {"type": "string", "value": "front"},
"both_sides": {"type": "boolean", "value": both},
"front_axis": {"type": "string", "value": front},
"width": {"type": "quantity", "value": 0.5, "unit": "m"},
"depth": {"type": "quantity", "value": 0.5, "unit": "m"},
"height": {"type": "quantity", "value": 2, "unit": "m"},
"align": {"type": "string", "value": align},
"height_reference": {"type": "string", "value": "bottom"},
"obstacles": {"type": "selector", "value": entity("column")},
}),
)
}
#[test]
fn with_geometry_door_clearances_follow_the_swing_side_and_the_handle() {
let case = Case::new("geometry-door-clearance");
let (output, result) = door_clearance(&case, "swing", "handle", true);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#30".to_owned(),
"front clearance (0.5 m wide, 0.5 m deep, 2 m high) is obstructed by \
ifc-step:model.ifc/#59"
.to_owned()
)],
"{result:#}"
);
let open = result["report"]["not_evaluated"].as_array().unwrap();
assert!(
open.len() == 2
&& open
.iter()
.all(|outcome| outcome["object_id"]["local_id"] == "#90"
&& outcome["message"]
.as_str()
.unwrap()
.contains("has no hinged leaf")),
"{result:#}"
);
for (front, align) in [("swing", "hinge"), ("-swing", "handle")] {
let (_, result) = door_clearance(&case, front, align, false);
assert!(
finding_messages(&result).is_empty(),
"{front} {align}: {result:#}"
);
}
}
fn office_and_corridor_doors() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let boundary = |id: u32, space: u32, door: u32| {
format!(
"#{id}=IFCRELSPACEBOUNDARY('{id:022}',$,$,$,#{space},#{door},$,.PHYSICAL.,.INTERNAL.);\n"
)
};
model_with(&format!(
"{}{}{}{}{}{}{}{}\
#200=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Corridor'),$);\n\
#201=IFCPROPERTYSET('0000000000000000000201',$,'Pset_SpaceCommon',$,(#200));\n\
#202=IFCRELDEFINESBYPROPERTIES('0000000000000000000202',$,$,$,(#29),#201);\n",
placed_box(10, [1.5, 2.0, 0.0], [5.0, 4.0, 3.0], space),
placed_box(20, [1.5, -1.0, 0.0], [5.0, 2.0, 3.0], space),
swinging_door(
40,
[0.0, 0.0, 0.0],
[1.0, 0.0],
0.9,
"SINGLE_SWING_LEFT",
&[("SWINGING", "LEFT", "$")]
),
swinging_door(
70,
[2.9, 0.0, 0.0],
[-1.0, 0.0],
0.9,
"SINGLE_SWING_LEFT",
&[("SWINGING", "LEFT", "$")]
),
boundary(300, 19, 50),
boundary(301, 29, 50),
boundary(302, 19, 80),
boundary(303, 29, 80),
))
}
fn corridor() -> Value {
json!({"kind": "allOf", "operands": [
entity("space"),
{"kind": "property", "propertySet": "axioval:example.ifc.pset-space-common",
"property": "axioval:example.ifc.reference", "operator": "equals",
"value": {"type": "string", "value": "Corridor"}},
]})
}
#[test]
fn with_geometry_a_door_swinging_into_the_corridor_is_found() {
let case = Case::new("geometry-door-swing");
let (output, result) = case.geometry_rule(
&office_and_corridor_doors(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.door-swing",
®istry_signature("axioval:capability.door-swing"),
entity("door"),
json!({
"space_path": {"type": "stringList", "value": ["IfcRelSpaceBoundary:backward"]},
"swing_not_into": {"type": "selector", "value": corridor()},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#80".to_owned(),
"swings into ifc-step:model.ifc/#29, which `swing_not_into` forbids".to_owned()
)],
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn office_behind_a_corridor() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let door = "IFCDOOR('GID',$,$,$,$,PL,REP,$,2.1,1.,$,$,$)";
let boundary = |id: u32, space: u32, door: u32| {
format!(
"#{id}=IFCRELSPACEBOUNDARY('{id:022}',$,$,$,#{space},#{door},$,.PHYSICAL.,.INTERNAL.);\n"
)
};
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}{}{}{}{}\
#200=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Corridor'),$);\n\
#201=IFCPROPERTYSET('0000000000000000000201',$,'Pset_SpaceCommon',$,(#200));\n\
#202=IFCRELDEFINESBYPROPERTIES('0000000000000000000202',$,$,$,(#29),#201);\n\
#210=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Exit'),$);\n\
#211=IFCPROPERTYSET('0000000000000000000211',$,'Pset_SpaceCommon',$,(#210));\n\
#212=IFCRELDEFINESBYPROPERTIES('0000000000000000000212',$,$,$,(#49),#211);\n\
#220=IFCPROPERTYSINGLEVALUE('ClearWidth',$,IFCPOSITIVELENGTHMEASURE(1.),$);\n\
#221=IFCPROPERTYSET('0000000000000000000221',$,'Access',$,(#220));\n\
#222=IFCRELDEFINESBYPROPERTIES('0000000000000000000222',$,$,$,(#49),#221);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [2.0, 2.0, 0.0], [4.0, 4.0, 3.0], space),
placed_box(20, [5.2, 5.0, 0.0], [2.0, 10.0, 3.0], space),
placed_box(30, [4.1, 2.0, 0.0], [0.2, 1.0, 2.1], door),
placed_box(40, [5.2, 10.1, 0.0], [1.0, 0.2, 2.1], door),
boundary(300, 19, 39),
boundary(301, 29, 39),
boundary(302, 29, 49),
)
}
#[test]
fn with_geometry_a_corridor_every_walk_crosses_carries_the_office_behind_it() {
let case = Case::new("geometry-escape-route-walked-passages");
let reference = |value: &str| {
json!({"kind": "property", "propertySet": "axioval:example.ifc.pset-space-common",
"property": "axioval:example.ifc.reference", "operator": "equals",
"value": {"type": "string", "value": value}})
};
let (output, result) = case.geometry_rule(
&office_behind_a_corridor(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.escape-route",
®istry_signature("axioval:capability.escape-route"),
entity("space"),
json!({
"uses": {"type": "table", "value": [
{"spaces": {"type": "selector", "value": entity("space")},
"area_per_occupant": {"type": "number", "value": 2.0}},
]},
"widths": {"type": "table", "value": [
{"occupants": {"type": "integer", "value": 10},
"width": {"type": "number", "value": 0.5},
"passage_width": {"type": "number", "value": 1.0}},
{"occupants": {"type": "integer", "value": 100},
"width": {"type": "number", "value": 0.5},
"passage_width": {"type": "number", "value": 3.0}},
]},
"clear_width_property": {"type": "propertyReference",
"property": "axioval:example.ifc.clear-width",
"propertySet": "axioval:example.ifc.pset-access"},
"exit_path": {"type": "stringList", "value": [
"IfcRelSpaceBoundary:forward", "IfcRelSpaceBoundary:backward",
"IfcRelSpaceBoundary:forward"]},
"exit_selector": {"type": "selector", "value":
{"kind": "allOf", "operands": [entity("door"), reference("Exit")]}},
"door_path": {"type": "stringList", "value": ["IfcRelSpaceBoundary:forward"]},
"door_selector": {"type": "selector", "value": entity("door")},
"walking_height": {"type": "number", "value": 2.0},
"walking_step": {"type": "number", "value": 0.02},
"passage_selector": {"type": "selector", "value":
{"kind": "allOf", "operands": [entity("space"), reference("Corridor")]}},
"walked_passages": {"type": "boolean", "value": true},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#29".to_owned(),
"passage ifc-step:model.ifc/#29 is at most 2 m wide (the shorter side of the \
rectangle enclosing its footprint); 18 occupant(s) relying on it (from \
ifc-step:model.ifc/#19, ifc-step:model.ifc/#29) require at least 3 m"
.to_owned()
)],
"{result:#}"
);
let text = result.to_string();
assert!(
text.contains("axiolid:metric-route:nearest:unreachable:")
&& text.contains(":avoided=[ifc-step:model.ifc/#29]:"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_escape_travel_ends_at_the_door_out_of_the_compartment() {
let compartments = office_behind_a_corridor().replace(
"ENDSEC;\nEND-ISO",
"#500=IFCZONE('0000000000000000000500',$,'A',$,$,$);\n\
#501=IFCRELASSIGNSTOGROUP('0000000000000000000501',$,$,$,(#19),$,#500);\n\
#510=IFCZONE('0000000000000000000510',$,'B',$,$,$);\n\
#511=IFCRELASSIGNSTOGROUP('0000000000000000000511',$,$,$,(#29),$,#510);\n\
ENDSEC;\nEND-ISO",
);
let run = |name: &str, compartments_declared: bool| {
let case = Case::new(name);
let mut parameters = json!({
"uses": {"type": "table", "value": [
{"spaces": {"type": "selector", "value": entity("space")},
"maximum_travel": {"type": "number", "value": 6.0}},
]},
"exit_path": {"type": "stringList", "value": ["IfcRelSpaceBoundary:forward"]},
"exit_selector": {"type": "selector", "value":
{"kind": "allOf", "operands": [entity("door"),
{"kind": "property",
"propertySet": "axioval:example.ifc.pset-space-common",
"property": "axioval:example.ifc.reference", "operator": "equals",
"value": {"type": "string", "value": "Exit"}}]}},
"door_path": {"type": "stringList", "value": ["IfcRelSpaceBoundary:forward"]},
"door_selector": {"type": "selector", "value": entity("door")},
"walking_height": {"type": "number", "value": 2.0},
"walking_step": {"type": "number", "value": 0.02},
});
if compartments_declared {
parameters["compartment_selector"] =
json!({"type": "selector", "value": entity("zone")});
parameters["compartment_path"] =
json!({"type": "stringList", "value": ["IfcRelAssignsToGroup:backward"]});
}
case.geometry_rule(
&compartments,
&[
("door", "IfcDoor"),
("space", "IfcSpace"),
("zone", "IfcZone"),
],
"axioval:capability.escape-route",
®istry_signature("axioval:capability.escape-route"),
entity("space"),
parameters,
)
};
let (output, result) = run("geometry-escape-route-no-compartments", false);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(
findings
.iter()
.map(|(object, _)| object.as_str())
.collect::<Vec<_>>(),
["#19", "#29"],
"{result:#}"
);
assert_eq!(
findings[0].1,
"has no exit via IfcRelSpaceBoundary to walk to; use 0 allows at most 6 m of travel",
"{result:#}"
);
assert!(
findings[1].1.contains("m from the nearest exit walking"),
"{result:#}"
);
let (output, result) = run("geometry-escape-route-compartments", true);
assert_eq!(
output.status.code(),
Some(0),
"{}{result:#}",
stderr(&output)
);
assert!(finding_messages(&result).is_empty(), "{result:#}");
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_an_office_behind_one_corridor_has_one_independent_route() {
let case = Case::new("geometry-escape-route-independent-routes");
let reference = |value: &str| {
json!({"kind": "property", "propertySet": "axioval:example.ifc.pset-space-common",
"property": "axioval:example.ifc.reference", "operator": "equals",
"value": {"type": "string", "value": value}})
};
let (output, result) = case.geometry_rule(
&office_behind_a_corridor(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.escape-route",
®istry_signature("axioval:capability.escape-route"),
json!({"kind": "allOf", "operands": [
entity("space"), {"kind": "not", "operand": reference("Corridor")}]}),
json!({
"uses": {"type": "table", "value": [
{"spaces": {"type": "selector", "value": entity("space")},
"exits": {"type": "integer", "value": 2}},
]},
"exit_path": {"type": "stringList", "value": [
"IfcRelSpaceBoundary:forward", "IfcRelSpaceBoundary:backward",
"IfcRelSpaceBoundary:forward"]},
"exit_selector": {"type": "selector", "value":
{"kind": "allOf", "operands": [entity("door"), reference("Exit")]}},
"exit_count": {"type": "string", "value": "routes"},
"walking_height": {"type": "number", "value": 2.0},
"walking_step": {"type": "number", "value": 0.02},
"passage_selector": {"type": "selector", "value":
{"kind": "allOf", "operands": [entity("space"), reference("Corridor")]}},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#19".to_owned(),
"every walk from it to an exit passes through ifc-step:model.ifc/#29, so it has at \
most 1 independent route(s); use 0 requires at least 2"
.to_owned()
)],
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn with_geometry_exit_doors_opening_against_the_escape_are_found() {
let case = Case::new("geometry-exit-door-direction");
let (output, result) = case.geometry_rule(
&office_and_corridor_doors(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.escape-route",
®istry_signature("axioval:capability.escape-route"),
entity("space"),
json!({
"uses": {"type": "table", "value": [
{"spaces": {"type": "selector", "value": entity("space")},
"exits": {"type": "integer", "value": 1}},
]},
"exit_path": {"type": "stringList", "value": ["IfcRelSpaceBoundary:forward"]},
"exit_selector": {"type": "selector", "value": entity("door")},
"exit_door_direction": {"type": "boolean", "value": true},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[
(
"#19".to_owned(),
"exit door ifc-step:model.ifc/#50 opens into the space, against the direction \
of escape"
.to_owned()
),
(
"#29".to_owned(),
"exit door ifc-step:model.ifc/#80 opens into the space, against the direction \
of escape"
.to_owned()
),
],
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn doors_at_a_ramp_landing() -> String {
let ramp = "IFCRAMPFLIGHT('GID',$,$,$,$,PL,REP,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}{}ENDSEC;\nEND-ISO-10303-21;\n",
profiled(400, [5.0, 0.0, 0.0], &ramp_profile(6.0), ramp),
swinging_door(
600,
[13.0, 0.15, 0.6],
[-1.0, 0.0],
0.9,
"SINGLE_SWING_LEFT",
&[("SWINGING", "LEFT", "$")]
),
swinging_door(
630,
[12.1, 0.15, 0.6],
[1.0, 0.0],
0.9,
"SINGLE_SWING_LEFT",
&[("SWINGING", "LEFT", "$")]
),
)
}
#[test]
fn with_geometry_a_door_swinging_over_a_ramp_landing_is_found() {
let case = Case::new("geometry-ramp-landing-door-swing");
let (output, result) = case.geometry_rule(
&doors_at_a_ramp_landing(),
&[("ramp", "IfcRampFlight"), ("door", "IfcDoor")],
"axioval:capability.ramp-geometry",
®istry_signature("axioval:capability.ramp-geometry"),
entity("ramp"),
json!({
"landing_objects": {"type": "selector", "value": entity("ramp")},
"landing_depth_minimum": {"type": "quantity", "value": 0.5, "unit": "m"},
"landing_doors": {"type": "selector", "value": entity("door")},
"landing_door_height": {"type": "quantity", "value": 2, "unit": "m"},
"landing_door_swing": {"type": "boolean", "value": true},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#408", "{result:#}");
assert!(
findings[0].1.ends_with(
"door ifc-step:model.ifc/#610 swings over the landing at the top of run 1 of 1"
),
"{result:#}"
);
}
fn declare_name(definitions: &mut Value) {
definitions["properties"]["axioval:example.ifc.name"] = json!({
"id": "axioval:example.ifc.name",
"name": {"default": "Name", "translations": {}},
"valueKind": "string",
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "Name"}],
"citations": [],
});
}
fn declare_wall_quantities(definitions: &mut Value) {
definitions["propertySets"]["axioval:example.ifc.qto-wall"] = json!({
"id": "axioval:example.ifc.qto-wall",
"name": {"default": "Qto_WallBaseQuantities", "translations": {}},
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "Qto_WallBaseQuantities"}],
"citations": [],
});
for (id, name) in [
("gross-side-area", "GrossSideArea"),
("net-side-area", "NetSideArea"),
] {
definitions["properties"][format!("axioval:example.ifc.{id}")] = json!({
"id": format!("axioval:example.ifc.{id}"),
"name": {"default": name, "translations": {}},
"valueKind": "quantity",
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": name}],
"citations": [],
});
}
}
fn extruded(id: u32, entity: &str, profile: &str, at: [f64; 3], axes: &str, depth: f64) -> String {
format!(
"#{a}={profile};\n#{b}=IFCCARTESIANPOINT(({},{},{}));\n\
#{c}=IFCAXIS2PLACEMENT3D(#{b},{axes});\n\
#{d}=IFCEXTRUDEDAREASOLID(#{a},#{c},#4,{depth});\n\
#{e}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{d}));\n\
#{f}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{e}));\n\
#{id}={};\n",
at[0],
at[1],
at[2],
entity
.replace("GID", &format!("{id:022}"))
.replace("REP", &format!("#{}", id + 6)),
a = id + 1,
b = id + 2,
c = id + 3,
d = id + 4,
e = id + 5,
f = id + 6,
)
}
fn wall_areas(id: u32, wall: u32, gross: f64, net: f64) -> String {
format!(
"#{a}=IFCQUANTITYAREA('GrossSideArea',$,$,{gross:?},$);\n\
#{b}=IFCQUANTITYAREA('NetSideArea',$,$,{net:?},$);\n\
#{c}=IFCELEMENTQUANTITY('{c:022}',$,'Qto_WallBaseQuantities',$,$,(#{a},#{b}));\n\
#{id}=IFCRELDEFINESBYPROPERTIES('{id:022}',$,$,$,(#{wall}),#{c});\n",
a = id + 1,
b = id + 2,
c = id + 3,
)
}
fn supported_beam_and_walls_with_areas() -> String {
let column = |id: u32, x: f64, profile: &str| {
extruded(
id,
"IFCCOLUMN('GID',$,$,$,$,#3,REP,$,.COLUMN.)",
profile,
[x, 0.0, 0.0],
"$,$",
2.85,
)
};
let wall = |id: u32, y: f64| {
format!(
"#{p}=IFCCARTESIANPOINT((2.5,0.));\n#{q}=IFCAXIS2PLACEMENT2D(#{p},$);\n{}",
extruded(
id,
"IFCWALL('GID',$,$,$,$,#3,REP,$,.STANDARD.)",
&format!("IFCRECTANGLEPROFILEDEF(.AREA.,$,#{},5.,0.2)", id + 9),
[0.0, y, 0.0],
"$,$",
3.0,
),
p = id + 8,
q = id + 9,
)
};
let voids = |id: u32, host: u32, opening: u32| {
format!("#{id}=IFCRELVOIDSELEMENT('{id:022}',$,$,$,#{host},#{opening});\n")
};
let window = |id: u32, host: u32, x: f64, y: f64| {
format!(
"{}{}",
extruded(
id,
"IFCOPENINGELEMENT('GID',$,$,$,$,#3,REP,$,.OPENING.)",
"IFCRECTANGLEPROFILEDEF(.AREA.,$,$,1.,1.2)",
[x, y + 0.1, 1.5],
"#6,#7",
0.2,
),
voids(id + 7, host, id),
)
};
let extra = [
"#24=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);\n".to_owned(),
column(700, 0.5, "IFCRECTANGLEPROFILEDEF(.AREA.,$,$,0.3,0.3)"),
"#710=IFCRELCONNECTSELEMENTS('0000000000000000000710',$,$,$,$,#50,#700);\n".to_owned(),
column(
800,
5.5,
"IFCISHAPEPROFILEDEF(.AREA.,'HEB300',$,0.3,0.3,0.011,0.019,$,$,$)",
),
"#810=IFCRELCONNECTSPATHELEMENTS('0000000000000000000810',$,$,$,$,#800,#50,(),(),\
.ATSTART.,.ATEND.);\n"
.to_owned(),
extruded(
900,
"IFCOPENINGELEMENT('GID',$,$,$,$,#3,REP,$,.OPENING.)",
"IFCCIRCLEPROFILEDEF(.AREA.,$,$,0.05)",
[5.0, -0.2, 3.0],
"#8,#7",
0.4,
),
voids(907, 50, 900),
wall(1000, 5.0),
window(1030, 1000, 1.0, 5.0),
window(1060, 1000, 3.0, 5.0),
wall_areas(1090, 1000, 15.0, 12.6),
wall(1100, 10.0),
window(1130, 1100, 2.0, 10.0),
wall_areas(1190, 1100, 15.0, 15.0),
wall_areas(1200, 10, 15.0, 11.4),
]
.concat();
hosts_with_openings()
.replace(
"#22=IFCUNITASSIGNMENT((#20,#21));",
"#22=IFCUNITASSIGNMENT((#20,#21,#24));",
)
.replace("ENDSEC;\nEND-ISO", &format!("{extra}ENDSEC;\nEND-ISO"))
}
#[test]
fn beam_holes_are_checked_against_the_beams_supports() {
let case = Case::new("opening-zone-supports");
let (output, result) = case.geometry_rule(
&supported_beam_and_walls_with_areas(),
&[
("opening", "IfcOpeningElement"),
("beam", "IfcBeam"),
("column", "IfcColumn"),
],
"axioval:capability.opening-zone",
®istry_signature("axioval:capability.opening-zone"),
entity("opening"),
json!({
"host_path": {"type": "stringList", "value": ["IfcRelVoidsElement:backward"]},
"host_selector": {"type": "selector", "value": entity("beam")},
"length_axis": {"type": "string", "value": "extrusion"},
"height_axis": {"type": "string", "value": "profile-y"},
"support_path": {"type": "stringList", "value": ["IfcRelConnectsElements:either"]},
"support_selector": {"type": "selector", "value": entity("column")},
"support_distance": {"type": "quantity", "value": 500.0, "unit": "mm"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[
(
"#500".to_owned(),
"opening is 0.1 m from support #700 along its host #50; 0.5 m required".to_owned()
),
(
"#900".to_owned(),
"opening is 0.3 m from support #800 along its host #50; 0.5 m required".to_owned()
),
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
#[test]
fn wall_openings_are_summed_against_gross_less_net_side_area() {
let case = Case::new("opening-area-walls");
let (output, result) = case.geometry_rule(
&supported_beam_and_walls_with_areas(),
&[("opening", "IfcOpeningElement"), ("wall", "IfcWall")],
"axioval:capability.opening-area",
®istry_signature("axioval:capability.opening-area"),
entity("wall"),
json!({
"opening_path": {"type": "stringList", "value": ["IfcRelVoidsElement:forward"]},
"opening_selector": {"type": "selector", "value": entity("opening")},
"length_axis": {"type": "string", "value": "profile-x"},
"height_axis": {"type": "string", "value": "extrusion"},
"gross_area": {"type": "propertyReference",
"property": "axioval:example.ifc.gross-side-area",
"propertySet": "axioval:example.ifc.qto-wall"},
"net_area": {"type": "propertyReference",
"property": "axioval:example.ifc.net-side-area",
"propertySet": "axioval:example.ifc.qto-wall"},
"area_tolerance": {"type": "quantity", "value": 0.01, "unit": "m2"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#1100".to_owned(),
"its openings (#1130) cover 1.2 m² of its face, but its gross side area 15 m² \
less its net side area 15 m² is 0 m²; they must agree within 0.01 m²"
.to_owned()
)],
"{result:#}"
);
let not_evaluated = result["report"]["not_evaluated"].as_array().unwrap();
assert_eq!(not_evaluated.len(), 1, "{result:#}");
assert_eq!(
not_evaluated[0]["object_id"]["local_id"],
json!("#10"),
"{result:#}"
);
}
#[test]
fn a_wall_with_a_window_is_not_empty() {
let run = |name: &str, minimum: f64| {
Case::new(name).geometry_rule(
&supported_beam_and_walls_with_areas(),
&[("opening", "IfcOpeningElement"), ("wall", "IfcWall")],
"axioval:capability.empty-host",
®istry_signature("axioval:capability.empty-host"),
entity("wall"),
json!({
"opening_path": {"type": "stringList", "value": ["IfcRelVoidsElement:forward"]},
"opening_selector": {"type": "selector", "value": entity("opening")},
"length_axis": {"type": "string", "value": "profile-x"},
"height_axis": {"type": "string", "value": "extrusion"},
"minimum_opening_area": {"type": "quantity", "value": minimum, "unit": "m2"},
}),
)
};
let (output, result) = run("empty-host-walls", 0.0);
assert_ne!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(finding_messages(&result), [], "{result:#}");
let walls = |result: &Value| -> Vec<Value> {
result["report"]["not_evaluated"]
.as_array()
.unwrap()
.iter()
.map(|outcome| outcome["object_id"]["local_id"].clone())
.collect()
};
assert!(!walls(&result).contains(&json!("#1100")), "{result:#}");
}
#[test]
fn beam_supports_are_found_by_contact_with_geometry() {
let model = supported_beam_and_walls_with_areas();
let case = Case::new("opening-zone-contact");
let (output, result) = case.geometry_rule(
&model,
&[
("opening", "IfcOpeningElement"),
("beam", "IfcBeam"),
("column", "IfcColumn"),
],
"axioval:capability.opening-zone",
®istry_signature("axioval:capability.opening-zone"),
entity("opening"),
json!({
"host_path": {"type": "stringList", "value": ["IfcRelVoidsElement:backward"]},
"host_selector": {"type": "selector", "value": entity("beam")},
"length_axis": {"type": "string", "value": "extrusion"},
"height_axis": {"type": "string", "value": "profile-y"},
"support_selector": {"type": "selector", "value": entity("column")},
"support_gap": {"type": "quantity", "value": 1.0, "unit": "mm"},
"support_distance": {"type": "quantity", "value": 500.0, "unit": "mm"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let geometry = &result["geometry"];
assert_eq!(geometry["unmeasured"], json!([]), "{geometry:#}");
assert_eq!(geometry["tessellated"], 1, "{geometry:#}");
assert_eq!(
finding_messages(&result),
[
(
"#500".to_owned(),
"opening is 0.1 m from support #700 along its host #50; 0.5 m required".to_owned()
),
(
"#900".to_owned(),
"opening is 0.3 m from support #800 along its host #50; 0.5 m required".to_owned()
),
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
fn doors_at_a_stair_landing() -> String {
let flight = "IFCSTAIRFLIGHT('GID',$,$,$,$,PL,REP,$,$,$,$,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}{}{}ENDSEC;\nEND-ISO-10303-21;\n",
profiled(100, [0.0, 0.0, 0.0], &stair_profile(&[0.17; 4]), flight),
placed_box(
200,
[1.52, -0.6, 0.48],
[0.8, 1.2, 0.2],
"IFCSLAB('GID',$,$,$,$,PL,REP,$,.LANDING.)"
),
swinging_door(
300,
[1.92, 0.15, 0.68],
[-1.0, 0.0],
0.9,
"SINGLE_SWING_LEFT",
&[("SWINGING", "LEFT", "$")]
),
swinging_door(
330,
[1.22, 0.15, 0.68],
[1.0, 0.0],
0.9,
"SINGLE_SWING_LEFT",
&[("SWINGING", "LEFT", "$")]
),
)
}
#[test]
fn with_geometry_a_door_swinging_over_a_stair_landing_is_found() {
let case = Case::new("geometry-stair-landing-door-swing");
let (output, result) = case.geometry_rule(
&doors_at_a_stair_landing(),
&[
("flight", "IfcStairFlight"),
("slab", "IfcSlab"),
("door", "IfcDoor"),
],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("flight"),
json!({
"landing_objects": {"type": "selector", "value": entity("slab")},
"landing_depth_minimum": {"type": "quantity", "value": 0.5, "unit": "m"},
"landing_doors": {"type": "selector", "value": entity("door")},
"landing_door_height": {"type": "quantity", "value": 2, "unit": "m"},
"landing_door_swing": {"type": "boolean", "value": true},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#108", "{result:#}");
assert!(
findings[0].1.ends_with(
"door ifc-step:model.ifc/#310 swings over the landing at the top of the flight"
),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn a_cupboard_before_a_flight() -> String {
let flight = "IFCSTAIRFLIGHT('GID',$,$,$,$,PL,REP,$,$,$,$,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}ENDSEC;\nEND-ISO-10303-21;\n",
profiled(100, [0.0, 0.0, 0.0], &stair_profile(&[0.17; 4]), flight),
placed_box(
200,
[-1.1, -0.6, 0.0],
[0.2, 0.6, 1.0],
"IFCFURNISHINGELEMENT('GID',$,$,$,$,PL,REP,$)"
),
)
}
#[test]
fn with_geometry_a_cupboard_in_a_flights_end_space_is_found() {
let case = Case::new("geometry-stair-end-space");
let (output, result) = case.geometry_rule(
&a_cupboard_before_a_flight(),
&[
("flight", "IfcStairFlight"),
("furniture", "IfcFurnishingElement"),
],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("flight"),
json!({
"end_space_depth": {"type": "quantity", "value": 1.5, "unit": "m"},
"end_space_width": {"type": "quantity", "value": 1.2, "unit": "m"},
"end_space_height": {"type": "quantity", "value": 2, "unit": "m"},
"end_space_obstacles": {"type": "selector", "value": entity("furniture")},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#108", "{result:#}");
assert_eq!(
findings[0].1,
"ifc-step:model.ifc/#209 obstructs the free space at the bottom of the flight (1.5 m \
deep, 1.2 m wide)",
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn a_stair_whose_rail_stops_at_its_landing() -> String {
let flight = "IFCSTAIRFLIGHT('GID',$,$,$,$,PL,REP,$,$,$,$,$,$)";
let rail = "IFCRAILING('GID',$,$,$,$,PL,REP,$,.HANDRAIL.)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}{}{}{}\
#700=IFCSTAIR('0000000000000000000700',$,$,$,$,#3,$,$,.STRAIGHT_RUN_STAIR.);\n\
#701=IFCRELAGGREGATES('0000000000000000000701',$,$,$,#700,(#108,#408,#609));\n\
ENDSEC;\nEND-ISO-10303-21;\n",
profiled(100, [0.0, 0.0, 0.0], &stair_profile(&[0.17; 4]), flight),
swept(200, [0.0, 0.05, 0.0], &rail_profile(0.9), 0.05, rail),
profiled(400, [2.12, 0.0, 0.68], &stair_profile(&[0.17; 4]), flight),
swept(500, [2.12, 0.05, 0.68], &rail_profile(0.9), 0.05, rail),
placed_box(
600,
[1.62, -0.6, 0.48],
[1.0, 1.2, 0.2],
"IFCSLAB('GID',$,$,$,$,PL,REP,$,.LANDING.)"
),
)
}
#[test]
fn with_geometry_a_stairs_rail_stopping_at_its_landing_is_found() {
let case = Case::new("geometry-whole-stair");
let (output, result) = case.geometry_rule(
&a_stair_whose_rail_stops_at_its_landing(),
&[
("stair", "IfcStair"),
("flight", "IfcStairFlight"),
("rail", "IfcRailing"),
],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("stair"),
json!({
"stair_path": {"type": "stringList", "value": ["IfcRelAggregates"]},
"stair_flights": {"type": "selector", "value": entity("flight")},
"maximum_total_rise": {"type": "quantity", "value": 1.2, "unit": "m"},
"handrail_objects": {"type": "selector", "value": entity("rail")},
"handrail_reach_across": {"type": "quantity", "value": 0.2, "unit": "m"},
"handrail_reach_above": {"type": "quantity", "value": 1.5, "unit": "m"},
"handrail_continuous_across_landings": {"type": "boolean", "value": true},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 2, "{result:#}");
assert!(
findings.iter().all(|(object, _)| object == "#700"),
"{result:#}"
);
assert!(
findings.iter().any(|(_, message)| message
== "the stair rises 1.36 m from its lowest flight's base to its highest flight's top; \
at most 1.2 m allowed"),
"{result:#}"
);
assert!(
findings.iter().any(|(_, message)| message
.starts_with("the handrail along the left side stops at the landing between ")
&& message.contains("#208 and ")
&& message.ends_with("#508 are not joined by selected rails within 0 m of each other")),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn a_narrow_tactile_strip_before_a_flight() -> String {
let flight = "IFCSTAIRFLIGHT('GID',$,$,$,$,PL,REP,$,$,$,$,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}ENDSEC;\nEND-ISO-10303-21;\n",
profiled(100, [0.0, 0.0, 0.0], &stair_profile(&[0.17; 4]), flight),
placed_box(
200,
[-0.45, -0.6, 0.0],
[0.3, 1.2, 0.01],
"IFCCOVERING('GID',$,$,$,$,PL,REP,$,.FLOORING.)"
),
)
}
#[test]
fn with_geometry_a_narrow_or_missing_tactile_strip_is_found() {
let case = Case::new("geometry-stair-tactile");
let (output, result) = case.geometry_rule(
&a_narrow_tactile_strip_before_a_flight(),
&[("flight", "IfcStairFlight"), ("covering", "IfcCovering")],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("flight"),
json!({
"tactile_objects": {"type": "selector", "value": entity("covering")},
"tactile_offset": {"type": "quantity", "value": 0.3, "unit": "m"},
"tactile_depth": {"type": "quantity", "value": 0.6, "unit": "m"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 2, "{result:#}");
assert!(
findings.iter().all(|(object, _)| object == "#108"),
"{result:#}"
);
assert!(
findings.iter().any(|(_, message)| message.starts_with(
"the tactile strip at the bottom of the flight (0.6 m deep, 0.3 m before the first \
riser, across the flight) is not covered: "
) && message.ends_with("#209 leave part of it bare")),
"{result:#}"
);
assert!(
findings.iter().any(|(_, message)| message
== "no selected tactile surface lies in the tactile strip at the top of the flight \
(0.6 m deep, 0.3 m beyond the last riser, across the flight)"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn a_flight_narrowed_by_its_rails() -> String {
let flight = "IFCSTAIRFLIGHT('GID',$,$,$,$,PL,REP,$,$,$,$,$,$)";
let rail = "IFCRAILING('GID',$,$,$,$,PL,REP,$,.HANDRAIL.)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
#9=IFCDIRECTION((0.,-1.,0.));\n\
#10=IFCDIRECTION((1.,0.,0.));\n\
{}{}{}ENDSEC;\nEND-ISO-10303-21;\n",
profiled(100, [0.0, 0.0, 0.0], &stair_profile(&[0.17; 4]), flight),
swept(200, [0.0, 0.0, 0.0], &rail_profile(0.9), 0.1, rail),
swept(300, [0.0, -1.1, 0.0], &rail_profile(0.9), 0.1, rail),
)
}
#[test]
fn with_geometry_a_flight_narrowed_by_its_rails_is_found() {
let case = Case::new("geometry-stair-clear-width");
let (output, result) = case.geometry_rule(
&a_flight_narrowed_by_its_rails(),
&[("flight", "IfcStairFlight"), ("rail", "IfcRailing")],
"axioval:capability.stair-geometry",
®istry_signature("axioval:capability.stair-geometry"),
entity("flight"),
json!({
"clear_width_minimum": {"type": "quantity", "value": 1.1, "unit": "m"},
"clear_width_obstacles": {"type": "selector", "value": entity("rail")},
"clear_width_band_from": {"type": "quantity", "value": 0.5, "unit": "m"},
"clear_width_band_to": {"type": "quantity", "value": 1.5, "unit": "m"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#108", "{result:#}");
assert!(
findings[0].1.starts_with(
"the clear width of the flight 0.5 m to 1.5 m above its pitch line is 1 m beside "
) && findings[0].1.ends_with("#308; at least 1.1 m required"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn mitred_wall_with_openings() -> String {
let opening = |id: u32, profile: &str, x: f64, z: f64| {
format!(
"#{a}={profile};\n#{b}=IFCCARTESIANPOINT(({x},0.3,{z}));\n\
#{c}=IFCAXIS2PLACEMENT3D(#{b},#6,#7);\n\
#{d}=IFCEXTRUDEDAREASOLID(#{a},#{c},#4,0.4);\n\
#{e}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{d}));\n\
#{f}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{e}));\n\
#{id}=IFCOPENINGELEMENT('{id:022}',$,$,$,$,#3,#{f},$,.OPENING.);\n\
#{g}=IFCRELVOIDSELEMENT('{g:022}',$,$,$,#10,#{id});\n",
a = id + 1,
b = id + 2,
c = id + 3,
d = id + 4,
e = id + 5,
f = id + 6,
g = id + 7,
)
};
let window = |id: u32, x: f64| opening(id, "IFCRECTANGLEPROFILEDEF(.AREA.,$,$,1.,1.2)", x, 1.5);
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCDIRECTION((0.,-1.,0.));\n\
#7=IFCDIRECTION((1.,0.,0.));\n\
#20=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#21=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);\n\
#22=IFCUNITASSIGNMENT((#20,#21));\n\
#23=IFCPROJECT('0000000000000000000023',$,'P',$,$,$,$,(#5),#22);\n\
#30=IFCCARTESIANPOINT((0.,0.));\n\
#31=IFCCARTESIANPOINT((5.,0.));\n\
#32=IFCCARTESIANPOINT((5.2,0.2));\n\
#33=IFCCARTESIANPOINT((0.,0.2));\n\
#34=IFCPOLYLINE((#30,#31,#32,#33,#30));\n\
#13=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#34);\n\
#14=IFCEXTRUDEDAREASOLID(#13,#2,#4,3.);\n\
#15=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#14));\n\
#16=IFCPRODUCTDEFINITIONSHAPE($,$,(#15));\n\
#10=IFCWALL('0000000000000000000010',$,$,$,$,#3,#16,$,.STANDARD.);\n\
#40=IFCCARTESIANPOINTLIST2D(((0.,0.),(1.,0.),(1.,0.5),(0.5,0.5),(0.5,1.5),(0.,1.5)));\n\
#41=IFCINDEXEDPOLYCURVE(#40,$,$);\n\
{}{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
window(100, 2.5),
window(200, 4.0),
window(300, 4.6),
opening(400, "IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#41)", 0.7, 0.5),
)
}
#[test]
fn openings_near_a_mitred_wall_end_are_checked_against_its_plan_outline() {
let case = Case::new("opening-zone-mitre");
let metres = |value: f64| json!({"type": "quantity", "value": value, "unit": "m"});
let (output, result) = case.geometry_rule(
&mitred_wall_with_openings(),
&[("opening", "IfcOpeningElement"), ("wall", "IfcWall")],
"axioval:capability.opening-zone",
®istry_signature("axioval:capability.opening-zone"),
entity("opening"),
json!({
"host_path": {"type": "stringList", "value": ["IfcRelVoidsElement:backward"]},
"host_selector": {"type": "selector", "value": entity("wall")},
"length_axis": {"type": "string", "value": "profile-x"},
"height_axis": {"type": "string", "value": "extrusion"},
"end_distance": metres(0.6),
"edge_distance": metres(0.6),
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[
(
"#200".to_owned(),
"opening is 0.5 m from an end of its host #10; 0.6 m required".to_owned()
),
(
"#300".to_owned(),
"opening lies partly outside its host #10: it crosses the edge of the host's \
outline"
.to_owned()
),
(
"#400".to_owned(),
"opening is 0.5 m from an edge of its host #10; 0.6 m clear required".to_owned()
),
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
fn room_with_a_door_swinging_in() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
model_with(&format!(
"{}{}",
placed_box(10, [1.2, 0.8, 0.0], [2.4, 1.6, 3.0], space),
swinging_door(
40,
[1.2, 1.7, 0.0],
[-1.0, 0.0],
0.9,
"SINGLE_SWING_LEFT",
&[("SWINGING", "LEFT", "$")]
),
))
}
#[test]
fn with_geometry_a_turning_circle_needs_the_floor_a_door_swings_over() {
let case = Case::new("geometry-free-floor-door-swing");
let check = |extra: Value| {
let mut parameters = json!({
"diameter_metres": {"type": "number", "value": 1.5},
"height_metres": {"type": "number", "value": 2.0},
});
for (name, value) in extra.as_object().unwrap() {
parameters[name] = value.clone();
}
case.geometry_rule(
&room_with_a_door_swinging_in(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.free-floor-circle",
®istry_signature("axioval:capability.free-floor-circle"),
entity("space"),
parameters,
)
};
let (output, result) = check(json!({}));
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert_eq!(finding_messages(&result), [], "{result:#}");
let (output, result) = check(json!({
"subtract_door_swings": {"type": "selector", "value": entity("door")},
}));
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#19".to_owned(),
"NO_FREE_FLOOR_SPACE_FOR_CIRCLE".to_owned()
)],
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn bcf_viewpoints(path: &Path) -> Vec<String> {
use std::io::Read as _;
let mut archive = zip::ZipArchive::new(std::fs::File::open(path).unwrap()).unwrap();
let mut texts = Vec::new();
for index in 0..archive.len() {
let mut entry = archive.by_index(index).unwrap();
if Path::new(entry.name())
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("bcfv"))
{
let mut text = String::new();
entry.read_to_string(&mut text).unwrap();
texts.push(text);
}
}
texts
}
#[test]
fn with_geometry_bcf_viewpoints_frame_the_clashing_pair() {
for (version, expected) in [
("2.1", openbim_bcf::BcfVersion::V2_1),
("3.0", openbim_bcf::BcfVersion::V3_0),
] {
let case = Case::new(&format!("geometry-bcf-{version}"));
let bcf = case.path("issues.bcfzip");
let output = case.clash_check(&[
"--geometry",
"--summary",
"--bcf",
bcf.to_str().unwrap(),
"--bcf-date",
"2026-09-26T10:00:00Z",
"--bcf-version",
version,
]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let archive = openbim_bcf::read_path(&bcf).unwrap();
assert_eq!(archive.version().resolved(), Some(expected));
assert!(
archive.diagnostics().is_empty(),
"{:?}",
archive.diagnostics()
);
let topic = &archive.topics().next().unwrap().topic;
assert_eq!(topic.priority.as_deref(), Some("High"));
let views = bcf_viewpoints(&bcf);
assert_eq!(views.len(), 2, "{views:?}");
for view in &views {
assert!(
view.contains("0000000000000000000016") && view.contains("0000000000000000000026"),
"{view}"
);
let red = view.find("Color=\"FFFF0000\"").expect(view);
let blue = view.find("Color=\"FF0000FF\"").expect(view);
let coloring = view.find("</Coloring>").expect(view);
assert!(red < blue, "{view}");
let (subject, related) = (&view[red..blue], &view[blue..coloring]);
let walls = ["0000000000000000000016", "0000000000000000000026"];
let in_subject: Vec<_> = walls.iter().filter(|w| subject.contains(*w)).collect();
let in_related: Vec<_> = walls.iter().filter(|w| related.contains(*w)).collect();
assert!(
in_subject.len() == 1 && in_related.len() == 1 && in_subject != in_related,
"{view}"
);
}
let perspective = views
.iter()
.find(|view| view.contains("<PerspectiveCamera>"))
.unwrap();
assert!(
views.iter().any(|view| view.contains("<OrthogonalCamera>")),
"{views:?}"
);
let number = |element: &str| -> f64 {
let start = perspective.find(&format!("<{element}>")).unwrap() + element.len() + 2;
let end = start + perspective[start..].find('<').unwrap();
perspective[start..end].parse().unwrap()
};
let viewpoint = perspective.find("<CameraViewPoint>").unwrap();
let (x, z) = {
let tail = &perspective[viewpoint..];
let read = |axis: &str| -> f64 {
let start = tail.find(&format!("<{axis}>")).unwrap() + 3;
let end = start + tail[start..].find('<').unwrap();
tail[start..end].parse().unwrap()
};
(read("X"), read("Z"))
};
assert!(x > 2.0 && z > 1.5, "{perspective}");
assert!((number("FieldOfView") - 60.0).abs() < 1e-9, "{perspective}");
}
}
#[test]
fn bcf_colours_are_configurable_and_can_be_left_out() {
let case = Case::new("bcf-colours");
let views = |name: &str, extra: &[&str]| {
let bcf = case.path(name);
let mut args = vec!["--bcf", bcf.to_str().unwrap()];
args.extend(extra);
let output = case.check(&ifc("0000000000000000000002", false), true, &args);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let views = bcf_viewpoints(&bcf);
assert!(!views.is_empty());
views
};
let plain = views("plain.bcfzip", &[]);
assert!(
plain.iter().all(|view| !view.contains("Coloring")),
"{plain:?}"
);
let colored = views("colored.bcfzip", &["--bcf-subject-color", "00ff00"]);
assert!(
colored
.iter()
.all(|view| view.contains("Color=\"FF00FF00\"")),
"{colored:?}"
);
let bcf = case.path("uncolored.bcfzip");
let output = case.clash_check(&[
"--geometry",
"--bcf",
bcf.to_str().unwrap(),
"--bcf-no-color",
]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let uncolored = bcf_viewpoints(&bcf);
assert!(
!uncolored.is_empty() && uncolored.iter().all(|view| !view.contains("Coloring")),
"{uncolored:?}"
);
let bcf = case.path("bad.bcfzip");
let output = case.check(
&ifc("0000000000000000000002", false),
true,
&["--bcf", bcf.to_str().unwrap(), "--bcf-related-color", "red"],
);
assert_eq!(output.status.code(), Some(2), "{}", stderr(&output));
}
#[test]
fn bcf_isolate_hides_everything_but_the_clashing_pair() {
let case = Case::new("bcf-isolate");
let bcf = case.path("issues.bcfzip");
let output = case.clash_check(&[
"--geometry",
"--bcf",
bcf.to_str().unwrap(),
"--bcf-isolate",
]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let views = bcf_viewpoints(&bcf);
assert_eq!(views.len(), 2, "{views:?}");
for view in &views {
let exceptions = &view[view.find("<Exceptions>").expect(view)..];
assert!(
view.contains("DefaultVisibility=\"false\"")
&& exceptions.contains("0000000000000000000016")
&& exceptions.contains("0000000000000000000026"),
"{view}"
);
}
}
#[test]
fn bcf_section_box_cuts_framed_viewpoints_only() {
let case = Case::new("bcf-section-box");
let bcf = case.path("boxed.bcfzip");
let output = case.clash_check(&[
"--geometry",
"--bcf",
bcf.to_str().unwrap(),
"--bcf-version",
"3.0",
"--bcf-section-box",
]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let views = bcf_viewpoints(&bcf);
assert_eq!(views.len(), 2, "{views:?}");
for view in &views {
assert_eq!(view.matches("<ClippingPlane>").count(), 6, "{view}");
}
let bcf = case.path("unmeasured.bcfzip");
let output = case.check(
&ifc("0000000000000000000002", false),
true,
&["--bcf", bcf.to_str().unwrap(), "--bcf-section-box"],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let views = bcf_viewpoints(&bcf);
assert!(
!views.is_empty() && views.iter().all(|view| !view.contains("ClippingPlane")),
"{views:?}"
);
}
#[test]
fn bcf_3_without_geometry_writes_nothing_and_fails_with_status_1() {
let case = Case::new("bcf-3-without-bounds");
let bcf = case.path("issues.bcfzip");
let output = case.check(
&ifc("0000000000000000000002", false),
true,
&["--bcf", bcf.to_str().unwrap(), "--bcf-version", "3.0"],
);
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(
stderr(&output).contains("BCF 3.0 needs a camera"),
"{}",
stderr(&output)
);
assert!(!bcf.exists(), "nothing is written when 3.0 is refused");
}
fn bedroom_behind_a_bed() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let door = "IFCDOOR('GID',$,$,$,$,PL,REP,$,2.1,0.9,$,$,$)";
let bed = "IFCFURNITURE('GID',$,$,$,$,PL,REP,$,$)";
model_with(&format!(
"{}{}{}\
#300=IFCRELSPACEBOUNDARY('0000000000000000000300',$,$,$,#19,#29,$,.PHYSICAL.,.INTERNAL.);\n",
placed_box(10, [3.0, 1.5, 0.0], [6.0, 3.0, 3.0], space),
placed_box(20, [0.75, -0.1, 0.0], [0.9, 0.2, 2.1], door),
placed_box(30, [2.4, 1.1, 0.0], [2.0, 2.2, 0.5], bed),
))
}
#[test]
fn with_geometry_a_turning_circle_behind_a_bed_is_not_reached_from_the_door() {
let case = Case::new("geometry-free-floor-entrance-path");
let check = |width: f64| {
case.geometry_rule(
&bedroom_behind_a_bed(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.free-floor-circle",
®istry_signature("axioval:capability.free-floor-circle"),
entity("space"),
json!({
"diameter_metres": {"type": "number", "value": 1.5},
"height_metres": {"type": "number", "value": 2.0},
"entrance_path_width": {"type": "number", "value": width},
"access_path": {"type": "stringList", "value": ["IfcRelSpaceBoundary:backward"]},
"door_selector": {"type": "selector", "value": entity("door")},
}),
)
};
let (output, result) = check(1.2);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#19".to_owned(),
"NO_FREE_FLOOR_SPACE_FOR_CIRCLE: the shape fits only where no path 1.2 m wide \
from an entrance reaches it"
.to_owned()
)],
"{result:#}"
);
let (output, result) = check(0.7);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert_eq!(finding_messages(&result), [], "{result:#}");
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn room_with_a_skirting() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let door = "IFCDOOR('GID',$,$,$,$,PL,REP,$,2.1,1.,$,$,$)";
let wc = "IFCFURNITURE('GID',$,$,$,$,PL,REP,$,$)";
let skirting = "IFCBUILDINGELEMENTPROXY('GID',$,$,$,$,PL,REP,$,$)";
model_with(&format!(
"{}{}{}{}\
#300=IFCRELSPACEBOUNDARY('0000000000000000000300',$,$,$,#19,#29,$,.PHYSICAL.,.INTERNAL.);\n",
placed_box(10, [3.0, 2.0, 0.0], [6.0, 4.0, 3.0], space),
placed_box(20, [1.0, -0.1, 0.0], [1.0, 0.2, 2.1], door),
placed_box(30, [5.6, 0.55, 0.0], [0.6, 0.7, 0.8], wc),
placed_box(40, [3.05, 2.0, 0.0], [0.1, 4.0, 0.1], skirting),
))
}
#[test]
fn with_geometry_local_circulation_ignores_a_skirting_below_the_band() {
let case = Case::new("geometry-local-circulation-band");
let check = |extra: Value| {
let mut parameters = json!({
"component_selector": {"type": "selector", "value": entity("furniture")},
"space_path": {"type": "stringList", "value": ["axioval:derived.contained-in-space"]},
"access_path": {"type": "stringList", "value": ["IfcRelSpaceBoundary:backward"]},
"door_selector": {"type": "selector", "value": entity("door")},
"width_metres": {"type": "number", "value": 0.9},
"clear_height_metres": {"type": "number", "value": 2.0},
});
for (name, value) in extra.as_object().unwrap() {
parameters[name] = value.clone();
}
case.geometry_rule(
&room_with_a_skirting(),
&[
("door", "IfcDoor"),
("space", "IfcSpace"),
("furniture", "IfcFurniture"),
],
"axioval:capability.local-circulation",
®istry_signature("axioval:capability.local-circulation"),
entity("space"),
parameters,
)
};
let (output, result) = check(json!({}));
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#39".to_owned(),
"no entrance of ifc-step:model.ifc/#19 reaches it on a path 0.9 m wide".to_owned()
)],
"{result:#}"
);
let (output, result) = check(json!({
"band_from_metres": {"type": "number", "value": 0.2},
}));
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert_eq!(finding_messages(&result), [], "{result:#}");
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
fn doors_on_sills() -> String {
let space = "IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)";
let door = "IFCDOOR('GID',$,$,$,$,PL,REP,$,2.1,1.,$,$,$)";
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{}{}{}{}\
#200=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Office'),$);\n\
#201=IFCPROPERTYSET('0000000000000000000201',$,'Pset_SpaceCommon',$,(#200));\n\
#202=IFCRELDEFINESBYPROPERTIES('0000000000000000000202',$,$,$,(#19,#29),#201);\n\
ENDSEC;\nEND-ISO-10303-21;\n",
placed_box(10, [2.0, 2.0, 0.0], [4.0, 4.0, 3.0], space),
placed_box(20, [6.2, 2.0, 0.0], [4.0, 4.0, 3.0], space),
placed_box(30, [4.1, 1.0, 0.04], [0.1, 1.0, 2.1], door),
placed_box(40, [4.1, 3.0, 0.0], [0.1, 1.0, 2.1], door),
)
}
#[test]
fn with_geometry_a_door_sill_above_the_floor_is_found() {
let case = Case::new("geometry-threshold-step");
let (output, result) = case.geometry_rule(
&doors_on_sills(),
&[("door", "IfcDoor"), ("space", "IfcSpace")],
"axioval:capability.keyed-limit",
®istry_signature("axioval:capability.keyed-limit"),
entity("door"),
json!({
"limits": {"type": "table", "value": [
{"key_1": {"type": "string", "value": "*"},
"maximum": {"type": "number", "value": 0.02}},
]},
"quantity": {"type": "string", "value": "threshold-step"},
"floor_path": {"type": "stringList", "value": ["axioval:derived.adjacent-space"]},
"key_1": {"type": "propertyReference",
"property": "axioval:example.ifc.reference",
"propertySet": "axioval:example.ifc.pset-space-common"},
"key_1_path": {"type": "stringList", "value": ["axioval:derived.adjacent-space"]},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = finding_messages(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#39", "{result:#}");
assert!(
findings[0].1.starts_with("the step from the floor of ")
&& findings[0]
.1
.contains("to the door's bottom is 0.04 m; required at most 0.02 m"),
"{result:#}"
);
assert!(
result["report"]["not_evaluated"]
.as_array()
.is_none_or(Vec::is_empty),
"{result:#}"
);
}
#[test]
fn bcf_topics_are_labelled_by_folder_and_tags_of_each_ruleset() {
let case = Case::new("bcf-rule-labels");
let nested = |package: &str, folder: &str| {
let path = case.ruleset_as(package);
let mut ruleset: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let rule = ruleset["root"]["rules"][0].take();
ruleset["root"]["rules"] = json!([]);
ruleset["root"]["folders"] = json!([{
"id": "f", "name": {"default": folder, "translations": {}},
"rules": [rule], "folders": [],
}]);
case.write(&format!("{package}.json"), &ruleset.to_string())
};
let rulesets = [
nested("org.example.client", "Handover"),
nested("org.example.discipline", "Walls"),
];
let model = case.write("model.ifc", &ifc("0000000000000000000002", false));
let bcf = case.path("issues.bcfzip");
let mut command = Command::new(env!("CARGO_BIN_EXE_axioval"));
command
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(case.definitions(true))
.args(["--bcf", bcf.to_str().unwrap()])
.env("SOURCE_DATE_EPOCH", "1790416800");
for ruleset in &rulesets {
command.arg("--ruleset").arg(ruleset);
}
let output = command.output().unwrap();
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let archive = openbim_bcf::read_path(&bcf).unwrap();
let mut labels: Vec<Vec<String>> = archive
.topics()
.map(|markup| markup.topic.labels.clone())
.collect();
labels.sort();
assert_eq!(
labels,
[
["org.example.client/r1", "Folder: Handover", "example"],
["org.example.discipline/r1", "Folder: Walls", "example"],
]
);
}
fn space_in_furniture() -> String {
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
{}{}{}\
ENDSEC;\nEND-ISO-10303-21;\n",
body(
10,
2.0,
4.0,
3.0,
"IFCSPACE('0000000000000000000016',$,$,$,$,#3,REP,$,.ELEMENT.,$,$)"
),
body(
20,
2.0,
6.0,
1.0,
"IFCFURNITURE('0000000000000000000026',$,$,$,$,#3,REP,$,$)"
),
body(
30,
22.0,
4.0,
3.0,
"IFCWALL('0000000000000000000036',$,$,$,$,#3,REP,$,$)"
),
)
}
#[test]
fn with_geometry_space_validation_selects_its_bounding_elements() {
let run = |name: &str, boundary: Value| {
Case::new(name).geometry_rule(
&space_in_furniture(),
&[("space", "IfcSpace"), ("furniture", "IfcFurniture")],
"axioval:capability.space-validation",
®istry_signature("axioval:capability.space-validation"),
entity("space"),
json!({
"required_height_metres": {"type": "number", "value": 2.5},
"uncovered_segment_length_metres": {"type": "number", "value": 0.5},
"check_top_cap": {"type": "boolean", "value": false},
"check_bottom_cap": {"type": "boolean", "value": false},
"check_unallocated_area": {"type": "boolean", "value": false},
"maximum_unallocated_area_square_metres": {"type": "number", "value": 1.0},
"intersection_elements": {"type": "selector", "value": entity("wall")},
"boundary_elements": {"type": "selector", "value": boundary},
}),
)
};
let (output, result) = run("geometry-space-walls", entity("wall"));
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = sorted_findings(&result);
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0].0, "#16");
assert!(
findings[0].1.starts_with("uncovered_boundary"),
"{result:#}"
);
let furniture = json!({"kind": "anyOf", "operands": [entity("wall"), entity("furniture")]});
let (output, result) = run("geometry-space-furniture", furniture);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert!(sorted_findings(&result).is_empty(), "{result:#}");
}
fn envelope_model_all_internal() -> String {
envelope_model()
.replace("IFCBOOLEAN(.T.)", "IFCBOOLEAN(.F.)")
.replace(
"#90=IFCZONE",
"#100=IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.F.),$);\n\
#101=IFCPROPERTYSET('0000000000000000000101',$,'Pset_WallCommon',$,(#100));\n\
#102=IFCRELDEFINESBYPROPERTIES('0000000000000000000102',$,$,$,(#56),#101);\n\
#90=IFCZONE",
)
}
#[test]
fn with_geometry_a_model_declaring_nothing_external_is_one_source_finding() {
let case = Case::new("geometry-envelope-all-internal");
let (output, result) = case.geometry_rule(
&envelope_model_all_internal(),
&[("space", "IfcSpace"), ("zone", "IfcZone")],
"axioval:capability.external-wall-validation",
®istry_signature("axioval:capability.external-wall-validation"),
entity("wall"),
json!({
"derivations": {"type": "stringList", "value": ["all-spaces", "gross-area-groups"]},
"bounding_selector": {"type": "selector", "value": entity("space")},
"gross_area_group_selector": {"type": "selector", "value": entity("zone")},
"gross_area_group_path": {"type": "stringList",
"value": ["IfcRelAssignsToGroup:forward"]},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert!(findings[0]["object_id"].is_null(), "{result:#}");
assert_eq!(findings[0]["severity"], "error", "{result:#}");
assert!(
findings[0]["message"]
.as_str()
.unwrap()
.contains("no selected object is declared external"),
"{result:#}"
);
}
#[test]
fn wall_opening_heads_too_far_below_the_wall_top_are_found() {
let metres = |value: f64| json!({"type": "quantity", "value": value, "unit": "m"});
let run = |name: &str, maximum: f64| {
opening_zone(
name,
"wall",
json!({
"length_axis": {"type": "string", "value": "profile-x"},
"height_axis": {"type": "string", "value": "extrusion"},
"edge_distance_maximum": metres(maximum),
"maximum_edges": {"type": "string", "value": "top"},
}),
)
};
let (output, result) = run("opening-zone-head", 0.5);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let messages = finding_messages(&result);
for opening in ["#100", "#200", "#300"] {
assert!(
messages.contains(&(
opening.to_owned(),
"opening is 0.9 m from the top edge of its host #10; at most 0.5 m allowed"
.to_owned()
)),
"{result:#}"
);
}
let (_, result) = run("opening-zone-head-lenient", 1.0);
assert!(
finding_messages(&result)
.iter()
.all(|(_, message)| !message.contains("top edge")),
"{result:#}"
);
}
fn walls_stating_side_areas() -> String {
let mut data = String::new();
for (first, y, length) in [(10u32, 0.0, 5.0), (20, 5.0, 4.0)] {
let [p, pos, profile, solid, shape, product, wall] =
[0, 1, 2, 3, 4, 5, 6].map(|offset| first + offset);
let _ = write!(
data,
"#{p}=IFCCARTESIANPOINT(({x},{y}));\n\
#{pos}=IFCAXIS2PLACEMENT2D(#{p},$);\n\
#{profile}=IFCRECTANGLEPROFILEDEF(.AREA.,$,#{pos},{length:?},0.2);\n\
#{solid}=IFCEXTRUDEDAREASOLID(#{profile},#2,#4,3.);\n\
#{shape}=IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#{solid}));\n\
#{product}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{shape}));\n\
#{wall}=IFCWALL('{wall:022}',$,$,$,$,#3,#{product},$,$);\n{}",
wall_areas(first + 100, wall, 15.0, 15.0),
x = length / 2.0,
);
}
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);\n\
#8=IFCUNITASSIGNMENT((#6,#7));\n\
#9=IFCPROJECT('0000000000000000000009',$,'P',$,$,$,$,(#5),#8);\n\
{data}ENDSEC;\nEND-ISO-10303-21;\n"
)
}
#[test]
fn with_geometry_a_stated_side_area_is_divided_by_the_measured_face() {
let case = Case::new("property-requirements-face-area");
let (output, result) = case.geometry_rule(
&walls_stating_side_areas(),
&[],
"axioval:capability.property-requirements",
®istry_signature("axioval:capability.property-requirements"),
entity("wall"),
json!({"requirements": {"type": "table", "value": [{
"property_set": {"type": "string", "value": "axioval:example.ifc.qto-wall"},
"property": {"type": "string", "value": "axioval:example.ifc.net-side-area"},
"requirement": {"type": "string", "value": "required"},
"minimum": {"type": "number", "value": 0.99},
"maximum": {"type": "number", "value": 1.01},
"unit": {"type": "string", "value": "m2"},
"per": {"type": "string", "value": "measured-face-area"},
}]}}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
assert_eq!(
finding_messages(&result),
[(
"#26".to_owned(),
"wrong value: axioval:example.ifc.qto-wall.axioval:example.ifc.net-side-area is \
15 m² (1.25 m² per m² of measured face area); required between 0.99 and 1.01 m2 per m² of measured face \
area (requirement row 0)"
.to_owned()
)],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
fn storey_rule(name: &str, capability: &str, applies_to: &str, parameters: Value) -> Value {
let case = Case::new(name);
let text = |value: &str| json!({"default": value, "translations": {}});
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(storey_metric_definitions(&case)).unwrap())
.unwrap();
definitions["properties"]["axioval:example.ifc.Name"] = json!({
"id": "axioval:example.ifc.Name", "name": text("Name"), "valueKind": "string",
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "Name"}], "citations": [],
});
let capability = format!("axioval:capability.{capability}");
definitions["definitions"]["axioval:example.under-test"] = json!({
"id": "axioval:example.under-test", "name": text("under test"),
"description": text("under test"), "capability": capability,
"parameters": registry_signature(&capability), "citations": [], "tags": [],
});
let text_file = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text_file).unwrap();
let rule = &mut ruleset["root"]["rules"][0];
rule["id"] = json!("under-test");
rule["definitionId"] = json!("axioval:example.under-test");
rule["parameters"] = parameters;
rule["applicability"]["groups"]["walls"]["selector"] = entity(applies_to);
let model = case.write("model.ifc", &storeys_with_facades());
let definitions = case.write("definitions.json", &definitions.to_string());
let ruleset = case.write("ruleset.json", &ruleset.to_string());
let saved = case.path("result.json");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.output()
.unwrap();
let result: Value = serde_json::from_str(&std::fs::read_to_string(&saved).unwrap())
.unwrap_or_else(|_| panic!("{}", stderr(&output)));
assert_eq!(
output.status.code(),
Some(
if result["report"]["findings"]
.as_array()
.is_none_or(Vec::is_empty)
{
0
} else {
3
}
),
"{}",
stderr(&output)
);
result
}
fn name_attribute() -> Value {
json!({"type": "propertyReference", "property": "axioval:example.ifc.Name",
"propertySet": "axioval:attributes"})
}
#[test]
fn table_allocation_rows_are_keyed_per_storey() {
let row = |anchor: &str, count: i64| {
json!({"anchor": {"type": "string", "value": anchor},
"count": {"type": "integer", "value": count}})
};
let result = storey_rule(
"table-allocation-per-storey",
"table-allocation",
"IfcSpace",
json!({
"rows": {"type": "table", "value": [row("EG", 2), row("OG", 1)]},
"anchor_key": name_attribute(),
"anchor_selector": {"type": "selector", "value": entity("IfcBuildingStorey")},
"relationship": {"type": "string", "value": "IfcRelAggregates"},
}),
);
assert_eq!(
finding_messages(&result),
[(
"#101".to_owned(),
"row 1 (any object) in anchors like `EG` has 1 object(s); required exactly 2"
.to_owned()
)],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
#[test]
fn group_composition_reports_a_required_storey_missing_from_the_model() {
let row = |group: &str| {
json!({"group": {"type": "string", "value": group},
"count": {"type": "integer", "value": 1}})
};
let result = storey_rule(
"group-composition-absent-storey",
"group-composition",
"IfcBuildingStorey",
json!({
"requirements": {"type": "table", "value": [row("EG"), row("OG"), row("UG")]},
"group_key_1": name_attribute(),
"member_selector": {"type": "selector", "value": entity("IfcSpace")},
"relationship": {"type": "string", "value": "IfcRelAggregates"},
"report_absent_groups": {"type": "boolean", "value": true},
}),
);
let findings: Vec<&str> = result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| finding["message"].as_str().unwrap())
.collect();
assert_eq!(
findings,
[
"not in model: no group matches row 3 (axioval:attributes.axioval:example.ifc.Name \
like `UG`)"
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
#[test]
fn keyed_limits_bound_each_storeys_summed_space_area() {
let limit = |pattern: &str, minimum: f64, maximum: f64| {
json!({"key_1": {"type": "string", "value": pattern},
"minimum": {"type": "number", "value": minimum},
"maximum": {"type": "number", "value": maximum}})
};
let result = storey_rule(
"keyed-limit-storey-areas",
"keyed-limit",
"IfcBuildingStorey",
json!({
"limits": {"type": "table", "value": [limit("EG*", 30.0, 50.0), limit("OG*", 50.0, 60.0)]},
"quantity": {"type": "string", "value": "member-plan-area"},
"key_1": name_attribute(),
"member_selector": {"type": "selector", "value": entity("IfcSpace")},
"relationship": {"type": "string", "value": "IfcRelAggregates"},
}),
);
assert_eq!(
finding_messages(&result),
[(
"#102".to_owned(),
"summed plan area of the members via IfcRelAggregates is 40 m²; required at least \
50 m² (limit row 1: axioval:attributes.axioval:example.ifc.Name `OG`)"
.to_owned()
)],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
fn storeys_with_an_atrium() -> String {
storeys_with_facades()
.replace(
"#100=IFCBUILDING(",
&format!(
"{}#100=IFCBUILDING(",
placed_box(
80,
[20.0, 2.0, 0.0],
[4.0, 4.0, 6.0],
"IFCSPACE('GID',$,$,$,$,PL,REP,$,.ELEMENT.,$,$)"
)
),
)
.replace("#101,(#49));", "#101,(#49,#89));")
.replace("$,'OG',$,$,#3,", "$,'OG',$,$,#122,")
.replace(
"#100=IFCBUILDING(",
"#120=IFCCARTESIANPOINT((0.,0.,3.));\n\
#121=IFCAXIS2PLACEMENT3D(#120,$,$);\n\
#122=IFCLOCALPLACEMENT($,#121);\n\
#100=IFCBUILDING(",
)
}
#[test]
fn an_atrium_counts_in_every_storey_its_height_spans() {
let case = Case::new("spans-level-atrium");
let (output, result) = case.geometry_rule(
&storeys_with_an_atrium(),
&[("storey", "IfcBuildingStorey"), ("space", "IfcSpace")],
"axioval:capability.plan-area",
®istry_signature("axioval:capability.plan-area"),
entity("storey"),
json!({
"maximum": {"type": "number", "value": 50.0},
"member_selector": {"type": "selector", "value": entity("space")},
"relationship": {"type": "string", "value": "axioval:derived.spans-level;overlap=1"},
"direction": {"type": "string", "value": "backward"},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let expected = |storey: &str| {
(
storey.to_owned(),
"summed plan area of the members is 56 m²; required at most 50 m²".to_owned(),
)
};
assert_eq!(
finding_messages(&result),
[expected("#101"), expected("#102")],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
#[test]
fn area_ratio_measures_its_numerator_and_denominator_differently() {
let result = storey_rule(
"area-ratio-separate-measures",
"area-ratio",
"IfcBuildingStorey",
json!({
"numerator_measure": {"type": "string", "value": "facade"},
"denominator_measure": {"type": "string", "value": "footprint"},
"numerator_selector": {"type": "selector", "value": entity("wall")},
"denominator_selector": {"type": "selector", "value": entity("wall")},
"maximum": {"type": "number", "value": 5.0},
"relationship": {"type": "string", "value": "IfcRelContainedInSpatialStructure"},
}),
);
let findings = finding_messages(&result);
assert!(
findings.contains(&(
"#101".to_owned(),
"facade area to plan area ratio is 9.6 (28.8 m² of 3 m²); required at most 5"
.to_owned()
)),
"{result:#}"
);
}
fn revision(first: u64, referenced: &[&str]) -> String {
let n = |offset: u64| first + offset;
let walls = [
("0000000000000000000011", n(0)),
("0000000000000000000012", n(1)),
];
let mut related: Vec<String> = walls
.iter()
.filter(|(global_id, _)| referenced.contains(global_id))
.map(|(_, number)| format!("#{number}"))
.collect();
related.push(format!("#{}", n(2)));
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','t',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#{}=IFCWALL('{}',$,$,$,$,$,$,$,$);\n\
#{}=IFCWALL('{}',$,$,$,$,$,$,$,$);\n\
#{}=IFCSLAB('0000000000000000000013',$,$,$,$,$,$,$,$);\n\
#{}=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('W-1'),$);\n\
#{}=IFCPROPERTYSET('0000000000000000000015',$,'Pset_WallCommon',$,(#{}));\n\
#{}=IFCRELDEFINESBYPROPERTIES('0000000000000000000016',$,$,$,({}),#{});\n\
ENDSEC;\nEND-ISO-10303-21;\n",
walls[0].1,
walls[0].0,
walls[1].1,
walls[1].0,
n(2),
n(3),
n(4),
n(3),
n(5),
related.join(","),
n(4),
)
}
#[test]
#[allow(clippy::too_many_lines)]
fn revision_two_carries_revision_ones_decisions_and_lists_stale_ones() {
let case = Case::new("decisions");
let axioval = |args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_axioval"))
.current_dir(case.path("."))
.args(args)
.output()
.unwrap()
};
let read = |name: &str| -> Value {
serde_json::from_str(&std::fs::read_to_string(case.path(name)).unwrap()).unwrap()
};
let text = |output: Output| String::from_utf8(output.stdout).unwrap();
let report = case.path("r1.json");
let output = case.check(
&revision(1, &[]),
true,
&["--report", report.to_str().unwrap()],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let first = read("r1.json");
let findings = first["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 2, "{first:#}");
let id = |finding: &Value| finding["id"].as_str().unwrap().to_owned();
let (kept, fixed) = (id(&findings[0]), id(&findings[1]));
let decide = |finding: &str, status: &str, comment: &str| {
axioval(&[
"decide",
"r1.json",
"--decisions",
"decisions.json",
"--finding",
finding,
"--status",
status,
"--author",
"A. Reviewer",
"--comment",
comment,
"--date",
"2026-09-27T08:00:00Z",
])
};
let output = decide(&kept, "accepted", "agreed with the architect");
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
let output = decide(&fixed, "rejected", "");
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert_eq!(
read("decisions.json")["decisions"]
.as_array()
.unwrap()
.len(),
2
);
let before = std::fs::read(case.path("decisions.json")).unwrap();
let output = decide("00000000-0000-5000-8000-000000000000", "accepted", "");
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(
stderr(&output).contains("no finding has id"),
"{}",
stderr(&output)
);
assert_eq!(std::fs::read(case.path("decisions.json")).unwrap(), before);
let bcf = case.path("r2.bcfzip");
let output = case.check(
&revision(101, &["0000000000000000000012"]),
true,
&[
"--report",
case.path("r2.json").to_str().unwrap(),
"--decisions",
case.path("decisions.json").to_str().unwrap(),
"--bcf",
bcf.to_str().unwrap(),
],
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let second = read("r2.json");
let findings = second["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{second:#}");
assert_eq!(findings[0]["object_id"]["local_id"], "#101");
assert_eq!(id(&findings[0]), kept);
assert_eq!(
findings[0]["decision"],
json!({"status": "accepted", "author": "A. Reviewer", "date": "2026-09-27T08:00:00Z",
"comment": "agreed with the architect", "evidence": "unchanged"})
);
let stale = second["report"]["stale_decisions"].as_array().unwrap();
assert_eq!(stale.len(), 1, "{second:#}");
assert_eq!(stale[0]["finding"], json!(fixed));
assert_eq!(stale[0]["status"], "rejected");
let archive = openbim_bcf::read_path(&bcf).unwrap();
assert!(
archive.diagnostics().is_empty(),
"{:?}",
archive.diagnostics()
);
let markup = archive.topics().next().unwrap();
assert_eq!(markup.topic.guid.as_deref(), Some(kept.as_str()));
assert_eq!(markup.topic.topic_status.as_deref(), Some("Accepted"));
assert_eq!(
markup.comments[0].comment.as_deref(),
Some("Accepted: agreed with the architect")
);
let summary = text(axioval(&["report", "r2.json"]));
assert!(
summary.contains(
"decisions: 1 accepted · 0 rejected · 0 open · 0 undecided · 0 changed · 1 stale"
),
"{summary}"
);
assert!(
summary.contains("axioval report r2.json --section stale-decisions"),
"{summary}"
);
let listing = text(axioval(&[
"report",
"r2.json",
"--section",
"stale-decisions",
]));
assert!(listing.contains(&format!("id: {fixed}")), "{listing}");
assert!(
listing.contains("rejected by A. Reviewer on 2026-09-27T08:00:00Z"),
"{listing}"
);
let listing = text(axioval(&["report", "r2.json", "--decision", "accepted"]));
assert!(listing.contains("showing 1–1 of 1"), "{listing}");
assert!(
listing.contains("decision: accepted by A. Reviewer"),
"{listing}"
);
let listing = text(axioval(&["report", "r2.json", "--decision", "undecided"]));
assert!(listing.contains("no matching entries"), "{listing}");
let output = case.check(&revision(101, &["0000000000000000000012"]), true, &[]);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let result = json(&output);
assert_eq!(id(&result["report"]["findings"][0]), kept);
assert!(result["report"]["findings"][0].get("decision").is_none());
assert!(result["report"].get("stale_decisions").is_none());
}
fn door_export(first: u32, doors: &[(&str, &str)]) -> String {
door_export_at(first, doors, "t")
}
fn door_export_at(first: u32, doors: &[(&str, &str)], stamp: &str) -> String {
let door = "IFCDOOR('GID',$,$,$,$,PL,REP,$,2.1,1.,$,$,$)";
let mut data = placed_box(
first,
[0.0, -1.0, 0.0],
[8.0, 0.2, 3.0],
"IFCWALL('GID',$,$,$,$,PL,REP,$,$)",
);
for (index, (number, rating)) in doors.iter().enumerate() {
let index = u32::try_from(index).unwrap();
let base = first + 20 * (index + 1);
data.push_str(&placed_box(
base,
[2.0 * f64::from(index), 0.0, 0.0],
[1.0, 0.1, 2.1],
door,
));
let (object, reference, fire, set, relation) =
(base + 9, base + 10, base + 11, base + 12, base + 13);
let _ = write!(
data,
"#{reference}=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('{number}'),$);\n\
#{fire}=IFCPROPERTYSINGLEVALUE('FireRating',$,IFCLABEL('{rating}'),$);\n\
#{set}=IFCPROPERTYSET('{set:022}',$,'Pset_DoorCommon',$,(#{reference},#{fire}));\n\
#{relation}=IFCRELDEFINESBYPROPERTIES('{relation:022}',$,$,$,(#{object}),#{set});\n"
);
}
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('n','{stamp}',(''),(''),'p','o','a');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n\
#1=IFCCARTESIANPOINT((0.,0.,0.));\n\
#2=IFCAXIS2PLACEMENT3D(#1,$,$);\n\
#3=IFCLOCALPLACEMENT($,#2);\n\
#4=IFCDIRECTION((0.,0.,1.));\n\
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$);\n\
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n\
#7=IFCUNITASSIGNMENT((#6));\n\
#8=IFCPROJECT('0000000000000000000008',$,'P',$,$,$,$,(#5),#7);\n\
{data}ENDSEC;\nEND-ISO-10303-21;\n"
)
}
#[test]
fn a_model_comparison_rule_matches_regenerated_doors_by_number() {
let case = Case::new("model-comparison-rule");
case.write(
"base.ifc",
&door_export(100, &[("D1", "EI30"), ("D2", "EI30"), ("D3", "EI30")]),
);
case.write(
"revised.ifc",
&door_export(500, &[("D1", "EI60"), ("D2", "EI30"), ("D4", "EI30")]),
);
let (output, result) = case.geometry_rule_over(
&["base.ifc:base", "revised.ifc:revised"],
&[("door", "IfcDoor")],
"axioval:capability.model-comparison",
®istry_signature("axioval:capability.model-comparison"),
entity("door"),
json!({
"base": {"type": "string", "value": "base"},
"revised": {"type": "string", "value": "revised"},
"identity_property": {"type": "propertyReference",
"property": "axioval:example.ifc.reference"},
"all_property_sets": {"type": "boolean", "value": true},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let mut findings: Vec<(String, String)> = result["report"]["findings"]
.as_array()
.unwrap()
.iter()
.map(|finding| {
(
finding["object_id"]["source"]["document"]
.as_str()
.unwrap_or_default()
.to_owned(),
finding["message"].as_str().unwrap().to_owned(),
)
})
.collect();
findings.sort();
assert_eq!(
findings,
vec![
(
"base.ifc".to_owned(),
"removed (property axioval:example.ifc.reference:D3)".to_owned()
),
(
"revised.ifc".to_owned(),
"added (property axioval:example.ifc.reference:D4)".to_owned()
),
(
"revised.ifc".to_owned(),
"property changed: property Pset_DoorCommon.FireRating \"EI30\" -> \"EI60\""
.to_owned()
),
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
fn door_comparison(case: &Case, parameters: Value) -> (Output, Value) {
let mut parameters = parameters;
parameters["base"] = json!({"type": "string", "value": "base"});
parameters["revised"] = json!({"type": "string", "value": "revised"});
case.geometry_rule_over(
&["base.ifc:base", "revised.ifc:revised"],
&[("door", "IfcDoor")],
"axioval:capability.model-comparison",
®istry_signature("axioval:capability.model-comparison"),
entity("door"),
parameters,
)
}
#[test]
fn with_geometry_doors_with_fresh_identities_match_by_their_bodies() {
let case = Case::new("model-comparison-geometry");
case.write(
"base.ifc",
&door_export(100, &[("D1", "EI30"), ("D2", "EI30"), ("D3", "EI30")]),
);
case.write(
"revised.ifc",
&door_export(500, &[("D1", "EI60"), ("D2", "EI30"), ("D4", "EI30")]),
);
let (output, result) = door_comparison(
&case,
json!({
"match_by": {"type": "stringList", "value": ["geometry"]},
"all_property_sets": {"type": "boolean", "value": true},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let messages: Vec<String> = finding_messages(&result)
.into_iter()
.map(|(_, message)| message)
.collect();
assert_eq!(
messages,
vec![
"property changed: property Pset_DoorCommon.FireRating \"EI30\" -> \"EI60\"",
"property changed: property Pset_DoorCommon.Reference \"D3\" -> \"D4\"",
],
"{result:#}"
);
assert_eq!(result["report"]["not_evaluated"], json!([]), "{result:#}");
}
#[test]
fn a_revised_model_older_than_its_base_is_an_error_finding() {
let case = Case::new("model-comparison-timestamps");
let doors = [("D1", "EI30")];
case.write(
"base.ifc",
&door_export_at(100, &doors, "2024-05-01T10:00:00"),
);
case.write(
"revised.ifc",
&door_export_at(500, &doors, "2024-01-01T10:00:00"),
);
let (output, result) = door_comparison(
&case,
json!({
"identity_property": {"type": "propertyReference",
"property": "axioval:example.ifc.reference"},
"compare_timestamps": {"type": "boolean", "value": true},
}),
);
assert_eq!(output.status.code(), Some(3), "{}", stderr(&output));
let findings = result["report"]["findings"].as_array().unwrap();
assert_eq!(findings.len(), 1, "{result:#}");
assert_eq!(findings[0]["severity"], "error", "{result:#}");
assert!(
findings[0]["message"]
.as_str()
.unwrap()
.starts_with("timestamp changed from `ifc-step:base.ifc`: the revised file is older"),
"{result:#}"
);
}
#[test]
fn an_auxiliary_rule_chooses_the_walls_another_checks_and_reports_nothing() {
let case = Case::new("auxiliary-rule");
let text = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text).unwrap();
let mut parent = ruleset["root"]["rules"][0].clone();
parent["auxiliary"] = json!(true);
let mut passed = parent.clone();
passed["id"] = json!("a-recheck-passed-walls");
passed["auxiliary"] = json!(false);
passed["gate"] = json!({"rule": "wall-reference-required", "condition": "passedObjects"});
ruleset["root"]["rules"] = json!([parent, passed]);
let ruleset = case.write("auxiliary.json", &ruleset.to_string());
let model = case.write("model.ifc", &ten_walls_two_without_reference());
let definitions = case.definitions(true);
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.arg("--rule-status")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
let result = json(&output);
assert_eq!(
result["report"]["rules"],
json!([{"rule_id": "a-recheck-passed-walls", "checked": 8, "failed": 0,
"not_evaluated": 0, "status": "passed"}]),
"{result:#}"
);
assert_eq!(result["report"]["findings"], json!([]), "{result:#}");
}
fn takeoff_definitions(case: &Case) -> PathBuf {
let text = |value: &str| json!({"default": value, "translations": {}});
let mut definitions: Value =
serde_json::from_str(&std::fs::read_to_string(storey_metric_definitions(case)).unwrap())
.unwrap();
definitions["properties"]["axioval:example.ifc.Name"] = json!({
"id": "axioval:example.ifc.Name", "name": text("Name"), "valueKind": "string",
"externalNames": [{"typeSystem": IFC4_TYPE_SYSTEM, "name": "Name"}], "citations": []});
let mut parameters = serde_json::Map::new();
let mut declare = |id: String, kind: &str| {
let declared = json!({"id": id, "name": text(&id), "kind": kind, "required": false,
"allowedValues": [], "citations": []});
parameters.insert(id, declared);
};
for n in 1..=3 {
declare(format!("group_{n}"), "propertyReference");
declare(format!("group_{n}_path"), "stringList");
declare(format!("group_{n}_name"), "string");
}
for n in 1..=4 {
declare(format!("measure_{n}"), "propertyReference");
declare(format!("measure_{n}_aggregates"), "stringList");
declare(format!("measure_{n}_name"), "string");
}
declare("across_sources".to_owned(), "boolean");
definitions["definitions"]["axioval:example.takeoff"] = json!({
"id": "axioval:example.takeoff", "name": text("takeoff"), "description": text("takeoff"),
"capability": "axioval:capability.quantity-takeoff", "parameters": parameters,
"citations": [], "tags": []});
case.write("definitions.json", &definitions.to_string())
}
fn takeoff_ruleset(case: &Case) -> PathBuf {
let text_file = std::fs::read_to_string(format!("{FIXTURES}/ruleset.json")).unwrap();
let mut ruleset: Value = serde_json::from_str(&text_file).unwrap();
let mut rule = ruleset["root"]["rules"][0].clone();
rule["id"] = json!("wall-takeoff");
rule["definitionId"] = json!("axioval:example.takeoff");
rule["applicability"]["groups"]["walls"]["selector"] = json!({
"kind": "entityType", "objectType": "axioval:example.ifc.wall", "includeSubtypes": true});
rule["parameters"] = json!({
"group_1": {"type": "propertyReference", "propertySet": "axioval:attributes",
"property": "axioval:example.ifc.Name"},
"group_1_path": {"type": "stringList",
"value": ["IfcRelContainedInSpatialStructure:backward"]},
"group_1_name": {"type": "string", "value": "storey"},
"measure_1": {"type": "propertyReference", "propertySet": "axioval:measured",
"property": "area"},
"measure_1_name": {"type": "string", "value": "footprint"},
});
ruleset["root"]["rules"] = json!([rule]);
case.write("ruleset.json", &ruleset.to_string())
}
#[test]
fn a_quantity_takeoff_is_listed_by_group_and_exported_as_csv() {
let case = Case::new("quantity-takeoff");
let definitions = takeoff_definitions(&case);
let ruleset = takeoff_ruleset(&case);
let model = case.write("model.ifc", &storeys_with_facades());
let saved = case.path("result.json");
let output = Command::new(env!("CARGO_BIN_EXE_axioval"))
.arg("check")
.arg("--model")
.arg(model)
.arg("--definitions")
.arg(definitions)
.arg("--ruleset")
.arg(ruleset)
.args(["--geometry", "--report", saved.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
let result: Value = serde_json::from_str(&std::fs::read_to_string(&saved).unwrap()).unwrap();
let table = &result["report"]["tables"][0];
assert_eq!(table["name"], "takeoff", "{result:#}");
assert_eq!(table["group_by"], json!(["storey"]), "{result:#}");
let groups: Vec<&Value> = table["rows"]
.as_array()
.unwrap()
.iter()
.map(|row| &row["group"])
.collect();
assert_eq!(groups, [&json!(["EG"]), &json!(["OG"])], "{result:#}");
for row in table["rows"].as_array().unwrap() {
assert_eq!(row["values"][0], json!({"type": "exact", "value": 1.0}));
let footprint = &row["values"][1];
let (lower, upper) = match footprint["type"].as_str().unwrap() {
"exact" => (footprint["value"].as_f64(), footprint["value"].as_f64()),
_ => (footprint["lower"].as_f64(), footprint["upper"].as_f64()),
};
assert!(
lower.unwrap() <= 3.0 + 1e-6 && upper.unwrap() >= 3.0 - 1e-6,
"{row}"
);
}
let saved = saved.to_str().unwrap();
let summary = stdout(&report(&[saved]));
assert!(
summary.contains("grouped by storey; columns: count, sum_footprint (m²)"),
"{summary}"
);
let listing = stdout(&report(&[
saved,
"--section",
"tables",
"--rule",
"wall-takeoff",
]));
assert!(
listing.contains("[EG] count 1 · sum_footprint 3"),
"{listing}"
);
assert!(
listing.contains("[OG] count 1 · sum_footprint 3"),
"{listing}"
);
let csv = stdout(&report(&[
saved,
"--csv",
"--rule",
"wall-takeoff",
"--table",
"takeoff",
]));
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(
lines[0],
"scope,storey,count_lower,count_upper,sum_footprint_lower [m²],sum_footprint_upper [m²]",
"{csv}"
);
assert_eq!(lines.len(), 3, "{csv}");
assert!(
lines[1].starts_with("source ") && lines[1].contains(",EG,1,1,"),
"{csv}"
);
assert!(lines[2].contains(",OG,1,1,"), "{csv}");
let missing = report(&[saved, "--csv", "--table", "levels"]);
assert_eq!(missing.status.code(), Some(1));
assert!(
stderr(&missing).contains("--rule wall-takeoff --table takeoff"),
"{}",
stderr(&missing)
);
}