use super::item_walk::{
extract_symbolic_item, extract_symbolic_item_with_revisit_budget, MAX_ITEM_DEPTH,
MAX_ITEM_REVISITS,
};
use super::primitives::SymbolicData;
use super::transform::Transform2D;
use ifc_lite_core::{build_entity_index, EntityDecoder};
use std::collections::HashMap;
fn run(step: &str, start_id: u32) -> SymbolicData {
let content = step.as_bytes();
let index = build_entity_index(content);
let mut decoder = EntityDecoder::with_index(content, index);
let item = decoder.decode_by_id(start_id).expect("fixture entity decodes");
let styled: HashMap<u32, Vec<u32>> = HashMap::new();
let mut out = SymbolicData::default();
extract_symbolic_item(
&item,
&mut decoder,
1,
"IfcAnnotation",
"Annotation",
1.0,
&Transform2D::identity(),
0.0,
0.0,
&styled,
&mut out,
);
out
}
fn run_with_budget(step: &str, start_id: u32, budget: u32) -> SymbolicData {
let content = step.as_bytes();
let index = build_entity_index(content);
let mut decoder = EntityDecoder::with_index(content, index);
let item = decoder.decode_by_id(start_id).expect("fixture entity decodes");
let styled: HashMap<u32, Vec<u32>> = HashMap::new();
let mut out = SymbolicData::default();
extract_symbolic_item_with_revisit_budget(
&item,
&mut decoder,
1,
"IfcAnnotation",
"Annotation",
1.0,
&Transform2D::identity(),
0.0,
0.0,
&styled,
&mut out,
budget,
);
out
}
fn run_with_timeout(step: String, start_id: u32, secs: u64) -> SymbolicData {
let (tx, rx) = std::sync::mpsc::channel();
let handle = std::thread::spawn(move || {
let _ = tx.send(run(&step, start_id));
});
let outcome = rx.recv_timeout(std::time::Duration::from_secs(secs));
assert!(
outcome.is_ok(),
"extract_symbolic_item did not terminate within {secs}s -- the breadth bound \
(MAX_ITEM_REVISITS) is gone; a depth cap and a path guard alone allow k! paths"
);
let _ = handle.join();
outcome.unwrap()
}
fn wrap(body: &str) -> String {
format!("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n{body}ENDSEC;\nEND-ISO-10303-21;\n")
}
#[test]
fn cyclic_geometric_curve_set_terminates() {
let out = run(&wrap("#10=IFCGEOMETRICCURVESET((#10));\n"), 10);
assert!(out.polylines.is_empty(), "a self-referential set must emit nothing, not recurse forever");
}
#[test]
fn cyclic_mapped_item_terminates() {
let out = run(
&wrap("#30=IFCMAPPEDITEM(#40,$);\n#40=IFCREPRESENTATIONMAP($,#50);\n#50=IFCSHAPEREPRESENTATION($,$,$,(#30));\n"),
30,
);
assert!(out.polylines.is_empty(), "a cyclic representation map must emit nothing");
}
#[test]
fn cyclic_composite_curve_terminates() {
let out = run(
&wrap("#60=IFCCOMPOSITECURVE((#61),.F.);\n#61=IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#60);\n"),
60,
);
assert!(out.polylines.is_empty(), "a self-referential composite curve must emit nothing");
}
#[test]
fn fanout_cycle_does_not_blow_up() {
for k in [1usize, 2, 4, 8, 16] {
let mut lines = String::new();
let mut items = String::new();
for i in 0..k {
let id = 100 + i;
if i > 0 {
items.push(',');
}
items.push_str(&format!("#{id}"));
lines.push_str(&format!("#{id}=IFCMAPPEDITEM(#40,$);\n"));
}
lines.push_str("#40=IFCREPRESENTATIONMAP($,#50);\n");
lines.push_str(&format!("#50=IFCSHAPEREPRESENTATION($,$,$,({items}));\n"));
let out = run_with_timeout(wrap(&lines), 100, 20);
assert!(out.polylines.is_empty(), "k={k}: a fan-out cycle must emit nothing");
}
}
#[test]
fn the_same_polyline_under_two_mapped_items_is_emitted_twice() {
let body = "#10=IFCGEOMETRICCURVESET((#20,#21));\n\
#20=IFCMAPPEDITEM(#30,$);\n\
#21=IFCMAPPEDITEM(#31,$);\n\
#30=IFCREPRESENTATIONMAP($,#40);\n\
#31=IFCREPRESENTATIONMAP($,#40);\n\
#40=IFCSHAPEREPRESENTATION($,$,$,(#50));\n\
#50=IFCPOLYLINE((#60,#61));\n\
#60=IFCCARTESIANPOINT((0.,0.));\n\
#61=IFCCARTESIANPOINT((1.,1.));\n";
let out = run(&wrap(body), 10);
assert_eq!(
out.polylines.len(),
2,
"a shared polyline reached through two mapped items must be emitted twice; \
a GLOBAL visited set would drop the second and silently lose geometry"
);
}
#[test]
fn depth_cap_stops_a_chain_longer_than_the_cap() {
let hops = 60usize;
let mut lines = String::new();
for i in 0..hops {
let item = 100 + i * 3;
let map = item + 1;
let repr = item + 2;
let next = if i + 1 < hops { 100 + (i + 1) * 3 } else { 9000 };
lines.push_str(&format!("#{item}=IFCMAPPEDITEM(#{map},$);\n"));
lines.push_str(&format!("#{map}=IFCREPRESENTATIONMAP($,#{repr});\n"));
lines.push_str(&format!("#{repr}=IFCSHAPEREPRESENTATION($,$,$,(#{next}));\n"));
}
lines.push_str("#9000=IFCPOLYLINE((#9001,#9002));\n#9001=IFCCARTESIANPOINT((0.,0.));\n#9002=IFCCARTESIANPOINT((1.,1.));\n");
let out = run(&wrap(&lines), 100);
assert!(
out.polylines.is_empty(),
"a 60-hop chain must be cut off by MAX_ITEM_DEPTH=32 before reaching its leaf"
);
}
const SHARED_TWICE: &str = "#70=IFCMAPPEDITEM(#72,$);\n\
#71=IFCMAPPEDITEM(#72,$);\n\
#72=IFCREPRESENTATIONMAP($,#73);\n\
#73=IFCSHAPEREPRESENTATION($,$,$,(#50));\n\
#50=IFCPOLYLINE((#60,#61));\n\
#60=IFCCARTESIANPOINT((0.,0.));\n\
#61=IFCCARTESIANPOINT((1.,1.));\n";
#[test]
fn a_cycle_must_not_starve_the_geometry_that_follows_it() {
let cycle = "#20=IFCMAPPEDITEM(#30,$);\n #30=IFCREPRESENTATIONMAP($,#40);\n #40=IFCSHAPEREPRESENTATION($,$,$,(#21,#22,#23,#24,#25,#26,#27,#28));\n #21=IFCMAPPEDITEM(#30,$);\n#22=IFCMAPPEDITEM(#30,$);\n #23=IFCMAPPEDITEM(#30,$);\n#24=IFCMAPPEDITEM(#30,$);\n #25=IFCMAPPEDITEM(#30,$);\n#26=IFCMAPPEDITEM(#30,$);\n #27=IFCMAPPEDITEM(#30,$);\n#28=IFCMAPPEDITEM(#30,$);\n";
let control = format!("#10=IFCGEOMETRICCURVESET((#70,#71));\n{SHARED_TWICE}");
let baseline = run(&wrap(&control), 10);
assert_eq!(
baseline.polylines.len(),
2,
"control: a representation shared by two mapped items emits twice"
);
let with_cycle = format!("#10=IFCGEOMETRICCURVESET((#20,#70,#71));\n{cycle}{SHARED_TWICE}");
let out = run_with_timeout(wrap(&with_cycle), 10, 60);
assert_eq!(
out.polylines.len(),
2,
"a cycle must not consume the budget that legitimate shared geometry \
after it needs; got {} polylines instead of 2",
out.polylines.len()
);
}
#[test]
fn a_set_cycle_must_not_starve_the_geometry_that_follows_it() {
let cycle = "#20=IFCGEOMETRICCURVESET((#21,#22,#23,#24,#25,#26,#27,#28));\n #21=IFCGEOMETRICCURVESET((#20));\n#22=IFCGEOMETRICCURVESET((#20));\n #23=IFCGEOMETRICCURVESET((#20));\n#24=IFCGEOMETRICCURVESET((#20));\n #25=IFCGEOMETRICCURVESET((#20));\n#26=IFCGEOMETRICCURVESET((#20));\n #27=IFCGEOMETRICCURVESET((#20));\n#28=IFCGEOMETRICCURVESET((#20));\n";
let body = format!("#10=IFCGEOMETRICCURVESET((#20,#70,#71));\n{cycle}{SHARED_TWICE}");
let out = run_with_timeout(wrap(&body), 10, 60);
assert_eq!(
out.polylines.len(),
2,
"a set cycle must not consume the budget the shared geometry after it \
needs; got {} polylines instead of 2",
out.polylines.len()
);
}
#[test]
fn a_long_acyclic_chain_does_not_abort() {
let hops = 200_000usize;
let mut lines = String::with_capacity(hops * 40);
for i in 0..hops {
let item = 100 + i * 3;
let map = item + 1;
let repr = item + 2;
let next = if i + 1 < hops { 100 + (i + 1) * 3 } else { 90_000_000 };
lines.push_str(&format!("#{item}=IFCMAPPEDITEM(#{map},$);\n"));
lines.push_str(&format!("#{map}=IFCREPRESENTATIONMAP($,#{repr});\n"));
lines.push_str(&format!("#{repr}=IFCSHAPEREPRESENTATION($,$,$,(#{next}));\n"));
}
lines.push_str("#90000000=IFCPOLYLINE((#90000001,#90000002));\n#90000001=IFCCARTESIANPOINT((0.,0.));\n#90000002=IFCCARTESIANPOINT((1.,1.));\n");
let out = run_with_timeout(wrap(&lines), 100, 60);
assert!(
out.polylines.is_empty(),
"a 200k-hop acyclic chain must be cut off by MAX_ITEM_DEPTH, not walked"
);
}
#[test]
fn a_shared_item_reached_deep_then_shallow_is_still_emitted() {
let mut lines = String::new();
let hops = 30usize;
for i in 0..hops {
let item = 100 + i * 3;
let map = item + 1;
let repr = item + 2;
let next = if i + 1 < hops { 100 + (i + 1) * 3 } else { 7000 };
lines.push_str(&format!("#{item}=IFCMAPPEDITEM(#{map},$);\n"));
lines.push_str(&format!("#{map}=IFCREPRESENTATIONMAP($,#{repr});\n"));
lines.push_str(&format!("#{repr}=IFCSHAPEREPRESENTATION($,$,$,(#{next}));\n"));
}
lines.push_str("#7000=IFCMAPPEDITEM(#7001,$);\n");
lines.push_str("#7001=IFCREPRESENTATIONMAP($,#7002);\n");
lines.push_str("#7002=IFCSHAPEREPRESENTATION($,$,$,(#7010));\n");
lines.push_str("#7010=IFCPOLYLINE((#7011,#7012));\n");
lines.push_str("#7011=IFCCARTESIANPOINT((0.,0.));\n#7012=IFCCARTESIANPOINT((1.,1.));\n");
lines.push_str("#10=IFCGEOMETRICCURVESET((#100,#7000));\n");
let out = run(&wrap(&lines), 10);
assert!(
!out.polylines.is_empty(),
"the shared item reached by a SHORT branch must still emit after a deep branch \
walked through it; a global visited set would mark it explored and drop this"
);
}
#[test]
fn an_acyclic_dag_is_bounded_by_total_work_not_by_depth() {
let levels = 24usize;
let mut lines = String::new();
for i in 0..levels {
let map = 1000 + i * 10;
let repr = map + 1;
let a = map + 2;
let b = map + 3;
let next_map = if i + 1 < levels { 1000 + (i + 1) * 10 } else { 90_000 };
lines.push_str(&format!("#{map}=IFCREPRESENTATIONMAP($,#{repr});\n"));
lines.push_str(&format!("#{repr}=IFCSHAPEREPRESENTATION($,$,$,(#{a},#{b}));\n"));
lines.push_str(&format!("#{a}=IFCMAPPEDITEM(#{next_map},$);\n"));
lines.push_str(&format!("#{b}=IFCMAPPEDITEM(#{next_map},$);\n"));
}
lines.push_str("#90000=IFCREPRESENTATIONMAP($,#90001);\n");
lines.push_str("#90001=IFCSHAPEREPRESENTATION($,$,$,(#90010));\n");
lines.push_str("#90010=IFCPOLYLINE((#90011,#90012));\n");
lines.push_str("#90011=IFCCARTESIANPOINT((0.,0.));\n#90012=IFCCARTESIANPOINT((1.,1.));\n");
lines.push_str("#5=IFCMAPPEDITEM(#1000,$);\n");
let out = run_with_timeout(wrap(&lines), 5, 30);
let emitted = out.polylines.len();
assert!(
emitted <= MAX_ITEM_REVISITS as usize,
"an acyclic DAG must be bounded by MAX_ITEM_REVISITS ({MAX_ITEM_REVISITS}), \
got {emitted} polylines"
);
assert!(
emitted < 100_000,
"2^24 paths must collapse to the measured ~66,675, not merely to something \
under the budget; got {emitted}"
);
}
#[test]
fn a_large_flat_set_is_not_truncated() {
const BUDGET: u32 = 50;
let n = BUDGET as usize + 10;
let mut lines = String::new();
let mut items = String::new();
for i in 0..n {
let pl = 1000 + i * 3;
if i > 0 {
items.push(',');
}
items.push_str(&format!("#{pl}"));
lines.push_str(&format!("#{pl}=IFCPOLYLINE((#{},#{}));\n", pl + 1, pl + 2));
lines.push_str(&format!("#{}=IFCCARTESIANPOINT((0.,0.));\n", pl + 1));
lines.push_str(&format!("#{}=IFCCARTESIANPOINT((1.,1.));\n", pl + 2));
}
lines.push_str(&format!("#10=IFCGEOMETRICCURVESET(({items}));\n"));
let out = run_with_budget(&wrap(&lines), 10, BUDGET);
assert_eq!(
out.polylines.len(),
n,
"a well-formed flat set must emit every element; charging first visits truncates it"
);
}
#[test]
fn depth_cap_matches_the_mapped_item_family_value() {
assert_eq!(
MAX_ITEM_DEPTH, 32,
"must equal MAX_MAPPED_ITEM_DEPTH in element.rs, router/processing.rs and color.rs; \
note this cap charges set elements and curve segments too, so equal values do not \
mean equal reach"
);
}