ifc_lite_geometry/
void_index.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Void Index Module
6//!
7//! Builds and manages the mapping between host elements (walls, slabs, etc.)
8//! and their associated voids (openings, penetrations).
9//!
10//! In IFC, voids are related to their host elements via `IfcRelVoidsElement`:
11//! - RelatingBuildingElement: The host (wall, slab, beam, etc.)
12//! - RelatedOpeningElement: The opening (IfcOpeningElement)
13
14use ifc_lite_core::{EntityDecoder, EntityScanner, IfcType};
15use rustc_hash::{FxHashMap, FxHashSet};
16
17/// Propagate openings from hosts that aggregate parts to every aggregated
18/// descendant — any depth, any type (IfcWallElementedCase panels,
19/// IfcRoof → IfcSlab skylights, nested assemblies, …).
20///
21/// The IFC4 spec allows an opening on a host whose geometry is distributed
22/// across aggregated parts; without propagation the cut runs against an empty
23/// host mesh and produces a "silent no-op" while the parts cover what should
24/// be the window/door hole.
25///
26/// Single shared kernel for all three pipelines (server `process_geometry`,
27/// wasm `buildPrePassOnce`, wasm `buildPrePassStreaming`) so they cannot
28/// drift on which descendants receive the cut.
29///
30/// Propagation is an iterative worklist walk with a per-host visited set
31/// (AGENTS.md "Bounding walks over file-supplied references": no recursion
32/// to overflow, and a cycle or fan-in in `IfcRelAggregates` is visited once
33/// per host). Existing void entries for a part are extended (deduplicated)
34/// so an authored direct void is never overwritten.
35pub fn propagate_voids_via_aggregates(
36    void_index: &mut FxHashMap<u32, Vec<u32>>,
37    aggregate_children: &FxHashMap<u32, Vec<u32>>,
38) {
39    if void_index.is_empty() || aggregate_children.is_empty() {
40        return;
41    }
42
43    // Snapshot host ids first — we mutate void_index inside the loop.
44    let hosts: Vec<u32> = void_index.keys().copied().collect();
45    let mut seen: FxHashSet<u32> = FxHashSet::default();
46
47    for host in hosts {
48        let openings = match void_index.get(&host) {
49            Some(list) if !list.is_empty() => list.clone(),
50            _ => continue,
51        };
52
53        // Mirror the openings onto every descendant, deduplicated.
54        walk_aggregate_descendants(host, aggregate_children, &mut seen, |part| {
55            let entry = void_index.entry(part).or_default();
56            for opening in &openings {
57                if !entry.contains(opening) {
58                    entry.push(*opening);
59                }
60            }
61            true
62        });
63    }
64}
65
66/// Visit every aggregated descendant of `root` (not `root` itself) once, with
67/// an explicit stack and the `seen` set (cleared here) as the cycle and
68/// fan-in guard (AGENTS.md "Bounding walks over file-supplied references").
69/// `visit` returns whether to descend into that node's own children.
70fn walk_aggregate_descendants(
71    root: u32,
72    aggregate_children: &FxHashMap<u32, Vec<u32>>,
73    seen: &mut FxHashSet<u32>,
74    mut visit: impl FnMut(u32) -> bool,
75) {
76    let Some(kids) = aggregate_children.get(&root) else {
77        return;
78    };
79    seen.clear();
80    seen.insert(root);
81    let mut stack = kids.clone();
82    while let Some(id) = stack.pop() {
83        if seen.insert(id) && visit(id) {
84            if let Some(kids) = aggregate_children.get(&id) {
85                stack.extend(kids.iter().filter(|k| !seen.contains(k)));
86            }
87        }
88    }
89}
90
91/// Scan `content` for `IfcRelAggregates` and build the full (unfiltered)
92/// parent → children map used by [`propagate_voids_via_aggregates`].
93pub fn build_aggregate_children_index<T>(
94    content: &T,
95    decoder: &mut EntityDecoder,
96) -> FxHashMap<u32, Vec<u32>>
97where
98    T: AsRef<[u8]> + ?Sized,
99{
100    let mut scanner = EntityScanner::new(content.as_ref());
101    let mut aggregate_children: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
102    while let Some((id, type_name, start, end)) = scanner.next_entity() {
103        if !ifc_lite_core::keyword_eq(type_name, "IFCRELAGGREGATES") {
104            continue;
105        }
106        let entity = match decoder.decode_at_with_id(id, start, end) {
107            Ok(e) => e,
108            Err(_) => continue,
109        };
110        // IfcRelAggregates: attr 4 = RelatingObject, attr 5 = RelatedObjects
111        let parent_id = match entity.get_ref(4) {
112            Some(id) => id,
113            None => continue,
114        };
115        let children: Vec<u32> = match entity.get(5).and_then(|a| a.as_list()) {
116            Some(list) => list
117                .iter()
118                .filter_map(|item| item.as_entity_ref())
119                .collect(),
120            None => continue,
121        };
122        if !children.is_empty() {
123            aggregate_children
124                .entry(parent_id)
125                .or_default()
126                .extend(children);
127        }
128    }
129    aggregate_children
130}
131
132/// Propagate void (opening) relationships from aggregate parents to their children
133/// and return a child-part → parent-element map covering every emitted aggregate
134/// `IfcWall` → `IfcBuildingElementPart` pair.
135///
136/// In IFC, multilayer walls use `IfcRelAggregates` to decompose a parent `IfcWall`
137/// into child `IfcBuildingElementPart` entities (one per material layer). The
138/// `IfcRelVoidsElement` relationships reference the parent wall, but the individual
139/// layer parts also need void subtraction to cut windows/doors through each layer.
140///
141/// This function scans for `IfcRelAggregates` and, in the same pass:
142///
143/// 1. Copies the host's void relationships to EVERY aggregated descendant,
144///    any depth and any type, whether or not it has a `Representation`
145///    ([`propagate_voids_via_aggregates`]; an entry for a geometry-less part
146///    is never looked up). Each layer slice still gets its window/door cutouts.
147/// 2. Returns a [`FxHashMap`] mapping every `IfcBuildingElementPart` that has
148///    its own `Representation` to its NEAREST aggregating ancestor that also
149///    has one. Callers use this to skip per-part geometry emission when the
150///    "merge multilayer wall as a single solid" toggle is on (issue #540): the
151///    ancestor's merged solid is drawn instead, which is only possible when
152///    that ancestor has geometry of its own.
153///
154/// The map walk descends to the same depth as the void propagation, but only
155/// through representation-less intermediates (an `IfcElementAssembly` that
156/// merely groups), so `IfcWall -> IfcElementAssembly -> IfcBuildingElementPart`
157/// lands the part on the wall. A child with its own `Representation` ends the
158/// descent: it is the parent of its own subtree in this same loop
159/// (`tests/void_index_nested_aggregates.rs`). Parents without their own
160/// geometry never appear as a map value, so the caller can never "skip" the
161/// only geometry available for the assembly.
162#[must_use = "the returned part → parent map is needed to honour the merge-layers toggle"]
163pub fn propagate_voids_to_parts<T>(
164    void_index: &mut FxHashMap<u32, Vec<u32>>,
165    content: &T,
166    decoder: &mut EntityDecoder,
167) -> FxHashMap<u32, u32>
168where
169    T: AsRef<[u8]> + ?Sized,
170{
171    let content = content.as_ref();
172    let aggregate_children = build_aggregate_children_index(content, decoder);
173
174    // Void propagation: every descendant, any depth, any type (shared kernel,
175    // same behaviour as the server pipeline). The BEP/representation filters
176    // below apply only to the part → parent map.
177    propagate_voids_via_aggregates(void_index, &aggregate_children);
178
179    // (type, has own Representation); `decode_by_id` caches the decode.
180    let info = |id: u32, decoder: &mut EntityDecoder| -> Option<(IfcType, bool)> {
181        let e = decoder.decode_by_id(id).ok()?;
182        Some((e.ifc_type.clone(), e.get(6).is_some_and(|a| !a.is_null())))
183    };
184
185    // part → parent map: each IfcBuildingElementPart with its own
186    // Representation maps to its nearest ancestor that has one. The descent
187    // passes only through representation-less nodes, so a child with geometry
188    // ends it (it is a parent in this loop).
189    let mut part_to_parent: FxHashMap<u32, u32> = FxHashMap::default();
190    let mut seen: FxHashSet<u32> = FxHashSet::default();
191    for &parent_id in aggregate_children.keys() {
192        if !matches!(info(parent_id, decoder), Some((_, true))) {
193            continue;
194        }
195        walk_aggregate_descendants(parent_id, &aggregate_children, &mut seen, |id| {
196            match info(id, decoder) {
197                Some((IfcType::IfcBuildingElementPart, true)) => {
198                    part_to_parent.insert(id, parent_id);
199                    false
200                }
201                Some((_, has_repr)) => !has_repr,
202                None => false,
203            }
204        });
205    }
206
207    part_to_parent
208}
209
210/// Compute the set of aggregated `IfcBuildingElementPart` ids to skip when the
211/// "merge multilayer wall as a single solid" toggle is on (issue #540): a part
212/// is skipped when its parent's layered build-up is *sliceable*, so the parent's
213/// merged-layer geometry is drawn instead of the individual parts.
214///
215/// This is the layer/void **driver** — it composes the two shared geometry
216/// kernels ([`propagate_voids_to_parts`] for part→parent + void propagation, and
217/// [`MaterialLayerIndex::is_sliceable`]) so the driver lives in the geometry
218/// crate next to its kernels rather than inline in a consumer (#913 Phase 4 /
219/// §2.6). The browser's `merge_layers` path calls it. The `void_index` it hands
220/// to [`propagate_voids_to_parts`] is EMPTY and stays empty: propagation is a
221/// no-op on an empty index, and only the part → parent map is wanted here.
222/// Callers that also need the propagated voids call
223/// [`propagate_voids_to_parts`] directly with their own seeded `void_index`.
224#[must_use]
225pub fn compute_parts_to_skip<T>(
226    content: &T,
227    decoder: &mut EntityDecoder,
228) -> rustc_hash::FxHashSet<u32>
229where
230    T: AsRef<[u8]> + ?Sized,
231{
232    let content = content.as_ref();
233    let material_layer_index = crate::MaterialLayerIndex::from_content(content, decoder);
234    let mut void_index_scratch: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
235    let part_to_parent = propagate_voids_to_parts(&mut void_index_scratch, content, decoder);
236    part_to_parent
237        .into_iter()
238        .filter(|(_, parent_id)| material_layer_index.is_sliceable(*parent_id))
239        .map(|(part_id, _)| part_id)
240        .collect()
241}
242
243/// Index mapping host elements to their voids
244///
245/// Provides efficient lookup of void entity IDs for any host element,
246/// enabling void-aware geometry processing.
247#[derive(Debug, Clone)]
248pub struct VoidIndex {
249    /// Map from host entity ID to list of void entity IDs
250    host_to_voids: FxHashMap<u32, Vec<u32>>,
251    /// Map from void entity ID to host entity ID (reverse lookup)
252    void_to_host: FxHashMap<u32, u32>,
253    /// Total number of void relationships
254    relationship_count: usize,
255}
256
257impl VoidIndex {
258    /// Create an empty void index
259    pub fn new() -> Self {
260        Self {
261            host_to_voids: FxHashMap::default(),
262            void_to_host: FxHashMap::default(),
263            relationship_count: 0,
264        }
265    }
266
267    /// Build void index from IFC content
268    ///
269    /// Scans the content for `IfcRelVoidsElement` entities and builds
270    /// the host-to-void mapping.
271    ///
272    /// # Arguments
273    /// * `content` - The raw IFC file content
274    /// * `decoder` - Entity decoder for parsing
275    ///
276    /// # Returns
277    /// A populated VoidIndex
278    pub fn from_content<T>(content: &T, decoder: &mut EntityDecoder) -> Self
279    where
280        T: AsRef<[u8]> + ?Sized,
281    {
282        let content = content.as_ref();
283        let mut index = Self::new();
284        let mut scanner = EntityScanner::new(content);
285
286        while let Some((_id, type_name, start, end)) = scanner.next_entity() {
287            // Look for IfcRelVoidsElement relationships
288            if ifc_lite_core::keyword_eq(type_name, "IFCRELVOIDSELEMENT") {
289                if let Ok(entity) = decoder.decode_at(start, end) {
290                    // IfcRelVoidsElement structure:
291                    // #id = IFCRELVOIDSELEMENT(GlobalId, OwnerHistory, Name, Description,
292                    //                          RelatingBuildingElement, RelatedOpeningElement);
293                    // Indices: 0=GlobalId, 1=OwnerHistory, 2=Name, 3=Description,
294                    //          4=RelatingBuildingElement, 5=RelatedOpeningElement
295
296                    if let (Some(host_id), Some(void_id)) = (entity.get_ref(4), entity.get_ref(5)) {
297                        index.add_relationship(host_id, void_id);
298                    }
299                }
300            }
301        }
302
303        index
304    }
305
306    /// Add a void relationship
307    pub fn add_relationship(&mut self, host_id: u32, void_id: u32) {
308        self.host_to_voids.entry(host_id).or_default().push(void_id);
309        self.void_to_host.insert(void_id, host_id);
310        self.relationship_count += 1;
311    }
312
313    /// Get void IDs for a host element
314    ///
315    /// # Arguments
316    /// * `host_id` - The entity ID of the host element
317    ///
318    /// # Returns
319    /// Slice of void entity IDs, or empty slice if no voids
320    pub fn get_voids(&self, host_id: u32) -> &[u32] {
321        self.host_to_voids
322            .get(&host_id)
323            .map(|v| v.as_slice())
324            .unwrap_or(&[])
325    }
326
327    /// Get the host ID for a void element
328    ///
329    /// # Arguments
330    /// * `void_id` - The entity ID of the void/opening
331    ///
332    /// # Returns
333    /// The host entity ID, if found
334    pub fn get_host(&self, void_id: u32) -> Option<u32> {
335        self.void_to_host.get(&void_id).copied()
336    }
337
338    /// Check if an element has any voids
339    pub fn has_voids(&self, host_id: u32) -> bool {
340        self.host_to_voids
341            .get(&host_id)
342            .map(|v| !v.is_empty())
343            .unwrap_or(false)
344    }
345
346    /// Get number of voids for a host element
347    pub fn void_count(&self, host_id: u32) -> usize {
348        self.host_to_voids
349            .get(&host_id)
350            .map(|v| v.len())
351            .unwrap_or(0)
352    }
353
354    /// Get total number of host elements with voids
355    pub fn host_count(&self) -> usize {
356        self.host_to_voids.len()
357    }
358
359    /// Get total number of void relationships
360    pub fn total_relationships(&self) -> usize {
361        self.relationship_count
362    }
363
364    /// Iterate over all host elements and their voids
365    pub fn iter(&self) -> impl Iterator<Item = (u32, &[u32])> {
366        self.host_to_voids.iter().map(|(k, v)| (*k, v.as_slice()))
367    }
368
369    /// Get all host IDs that have voids
370    pub fn hosts_with_voids(&self) -> Vec<u32> {
371        self.host_to_voids.keys().copied().collect()
372    }
373
374    /// Check if an entity is a void/opening
375    pub fn is_void(&self, entity_id: u32) -> bool {
376        self.void_to_host.contains_key(&entity_id)
377    }
378
379    /// Check if an entity is a host with voids
380    pub fn is_host_with_voids(&self, entity_id: u32) -> bool {
381        self.host_to_voids.contains_key(&entity_id)
382    }
383}
384
385impl Default for VoidIndex {
386    fn default() -> Self {
387        Self::new()
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_void_index_basic() {
397        let mut index = VoidIndex::new();
398
399        // Add some relationships
400        index.add_relationship(100, 200);
401        index.add_relationship(100, 201);
402        index.add_relationship(101, 202);
403
404        // Test lookups
405        assert_eq!(index.get_voids(100), &[200, 201]);
406        assert_eq!(index.get_voids(101), &[202]);
407        assert!(index.get_voids(999).is_empty());
408
409        // Test reverse lookup
410        assert_eq!(index.get_host(200), Some(100));
411        assert_eq!(index.get_host(202), Some(101));
412        assert_eq!(index.get_host(999), None);
413
414        // Test counts
415        assert_eq!(index.void_count(100), 2);
416        assert_eq!(index.void_count(101), 1);
417        assert_eq!(index.host_count(), 2);
418        assert_eq!(index.total_relationships(), 3);
419    }
420
421    #[test]
422    fn test_void_index_has_voids() {
423        let mut index = VoidIndex::new();
424        index.add_relationship(100, 200);
425
426        assert!(index.has_voids(100));
427        assert!(!index.has_voids(999));
428    }
429
430    #[test]
431    fn test_void_index_is_void() {
432        let mut index = VoidIndex::new();
433        index.add_relationship(100, 200);
434
435        assert!(index.is_void(200));
436        assert!(!index.is_void(100));
437        assert!(!index.is_void(999));
438    }
439
440    #[test]
441    fn test_void_index_hosts_with_voids() {
442        let mut index = VoidIndex::new();
443        index.add_relationship(100, 200);
444        index.add_relationship(101, 201);
445        index.add_relationship(102, 202);
446
447        let hosts = index.hosts_with_voids();
448        assert_eq!(hosts.len(), 3);
449        assert!(hosts.contains(&100));
450        assert!(hosts.contains(&101));
451        assert!(hosts.contains(&102));
452    }
453
454    // ── propagate_voids_to_parts ─────────────────────────────────────────
455    //
456    // The synthetic IFC strings below are deliberately minimal — they
457    // only carry the entities `propagate_voids_to_parts` actually looks
458    // at (`IFCRELAGGREGATES`, the parent `IFCWALL`/`IFCBUILDINGELEMENTPART`
459    // entries, and an `IFCRELVOIDSELEMENT` for the parent). The geometry
460    // attributes don't matter to the index — only that the parent and
461    // the parts carry a non-null `Representation`.
462
463    use ifc_lite_core::EntityDecoder;
464
465    /// Three-layer wall with one window opening and a parent representation.
466    /// All three parts and the parent each carry a `#51` representation ref so
467    /// every emitted child appears in the returned part→parent map.
468    fn three_layer_wall_with_voids_ifc() -> String {
469        r#"ISO-10303-21;
470HEADER;
471FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
472FILE_NAME('test.ifc','2024-01-01T00:00:00',(''),(''),'','','');
473FILE_SCHEMA(('IFC4'));
474ENDSEC;
475DATA;
476#51=IFCPRODUCTDEFINITIONSHAPE($,$,(#50));
477#50=IFCSHAPEREPRESENTATION($,'Body','SweptSolid',(#40));
478#40=IFCEXTRUDEDAREASOLID($,$,$,3.0);
479#100=IFCWALL('0001wall',$,'Parent',$,$,$,#51,$,$);
480#101=IFCBUILDINGELEMENTPART('0001p01',$,'L0',$,$,$,#51,$,$);
481#102=IFCBUILDINGELEMENTPART('0001p02',$,'L1',$,$,$,#51,$,$);
482#103=IFCBUILDINGELEMENTPART('0001p03',$,'L2',$,$,$,#51,$,$);
483#200=IFCOPENINGELEMENT('0001op',$,'Opening',$,$,$,#51,$,$);
484#210=IFCRELVOIDSELEMENT('0001rv',$,$,$,#100,#200);
485#300=IFCRELAGGREGATES('0001ra',$,$,$,#100,(#101,#102,#103));
486ENDSEC;
487END-ISO-10303-21;
488"#
489        .to_string()
490    }
491
492    /// Aggregate where the parent wall has NO representation (null `#51`).
493    /// The parts ARE the only geometry — the map must NOT contain them.
494    fn parts_only_aggregate_ifc() -> String {
495        r#"ISO-10303-21;
496HEADER;
497FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
498FILE_NAME('test.ifc','2024-01-01T00:00:00',(''),(''),'','','');
499FILE_SCHEMA(('IFC4'));
500ENDSEC;
501DATA;
502#51=IFCPRODUCTDEFINITIONSHAPE($,$,(#50));
503#50=IFCSHAPEREPRESENTATION($,'Body','SweptSolid',(#40));
504#40=IFCEXTRUDEDAREASOLID($,$,$,3.0);
505#100=IFCWALL('0001wall',$,'Parent',$,$,$,$,$,$);
506#101=IFCBUILDINGELEMENTPART('0001p01',$,'L0',$,$,$,#51,$,$);
507#102=IFCBUILDINGELEMENTPART('0001p02',$,'L1',$,$,$,#51,$,$);
508#103=IFCBUILDINGELEMENTPART('0001p03',$,'L2',$,$,$,#51,$,$);
509#300=IFCRELAGGREGATES('0001ra',$,$,$,#100,(#101,#102,#103));
510ENDSEC;
511END-ISO-10303-21;
512"#
513        .to_string()
514    }
515
516    #[test]
517    fn propagate_voids_returns_part_to_parent_map() {
518        let content = three_layer_wall_with_voids_ifc();
519        let mut decoder = EntityDecoder::new(&content);
520
521        // Seed the index with the parent's voids (caller normally does this
522        // from the IFCRELVOIDSELEMENT pre-scan).
523        let mut void_index: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
524        void_index.insert(100, vec![200]);
525
526        let part_to_parent = propagate_voids_to_parts(&mut void_index, &content, &mut decoder);
527
528        // Three parts, all mapped to the same parent.
529        assert_eq!(part_to_parent.len(), 3);
530        assert_eq!(part_to_parent.get(&101).copied(), Some(100));
531        assert_eq!(part_to_parent.get(&102).copied(), Some(100));
532        assert_eq!(part_to_parent.get(&103).copied(), Some(100));
533
534        // Voids were propagated to every child.
535        assert_eq!(void_index.get(&101).map(Vec::as_slice), Some(&[200u32][..]));
536        assert_eq!(void_index.get(&102).map(Vec::as_slice), Some(&[200u32][..]));
537        assert_eq!(void_index.get(&103).map(Vec::as_slice), Some(&[200u32][..]));
538    }
539
540    #[test]
541    fn propagate_voids_skips_parents_without_representation() {
542        let content = parts_only_aggregate_ifc();
543        let mut decoder = EntityDecoder::new(&content);
544
545        // No voids on the parent for this case — we only care about the map.
546        let mut void_index: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
547
548        let part_to_parent = propagate_voids_to_parts(&mut void_index, &content, &mut decoder);
549
550        // Parent #100 has null Representation, so the parts are the only
551        // geometry — none of them should appear in the skip-eligible map.
552        assert!(
553            part_to_parent.is_empty(),
554            "expected empty map when parent has no representation, got {:?}",
555            part_to_parent
556        );
557    }
558
559    #[test]
560    fn propagate_voids_returns_empty_map_when_no_aggregates() {
561        let empty = r#"ISO-10303-21;
562HEADER;
563FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
564FILE_NAME('t.ifc','2024-01-01T00:00:00',(''),(''),'','','');
565FILE_SCHEMA(('IFC4'));
566ENDSEC;
567DATA;
568#1=IFCWALL('0001w',$,'L',$,$,$,$,$,$);
569ENDSEC;
570END-ISO-10303-21;
571"#
572        .to_string();
573        let mut decoder = EntityDecoder::new(&empty);
574        let mut void_index: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
575        let part_to_parent = propagate_voids_to_parts(&mut void_index, &empty, &mut decoder);
576        assert!(part_to_parent.is_empty());
577        assert!(void_index.is_empty());
578    }
579
580    // ── propagate_voids_via_aggregates (shared BFS kernel) ───────────────
581
582    fn agg_map(pairs: &[(u32, &[u32])]) -> FxHashMap<u32, Vec<u32>> {
583        pairs.iter().map(|(k, v)| (*k, v.to_vec())).collect()
584    }
585
586    #[test]
587    fn propagate_voids_walks_full_aggregate_tree() {
588        // Host #100 voided by openings #200 and #201. The host aggregates
589        // parts #110 and #111; #110 further aggregates #120 (a grand-part).
590        // Every leaf in the aggregate sub-tree must inherit both openings.
591        let mut void_index = agg_map(&[(100, &[200, 201])]);
592        let aggregate_children = agg_map(&[(100, &[110, 111]), (110, &[120])]);
593
594        propagate_voids_via_aggregates(&mut void_index, &aggregate_children);
595
596        let expected = [200, 201];
597        for part in &[110, 111, 120] {
598            let got = void_index.get(part).expect("part should have voids");
599            assert_eq!(
600                got.iter().copied().collect::<std::collections::HashSet<_>>(),
601                expected.iter().copied().collect::<std::collections::HashSet<_>>(),
602                "part #{part} should receive both openings",
603            );
604        }
605        // Host entry is preserved untouched.
606        assert_eq!(void_index.get(&100), Some(&vec![200, 201]));
607    }
608
609    #[test]
610    fn propagate_voids_deduplicates_existing_part_voids() {
611        // Authored: part #110 already voided by opening #999 directly.
612        // After propagation it must have #200 and #999, not #200 twice.
613        let mut void_index = agg_map(&[(100, &[200]), (110, &[999])]);
614        let aggregate_children = agg_map(&[(100, &[110])]);
615
616        propagate_voids_via_aggregates(&mut void_index, &aggregate_children);
617
618        let mut part_voids = void_index.get(&110).unwrap().clone();
619        part_voids.sort();
620        assert_eq!(part_voids, vec![200, 999]);
621    }
622
623    #[test]
624    fn propagate_voids_handles_aggregate_cycles() {
625        // Cyclic IfcRelAggregates: #110 -> #120 -> #110. Without the visited
626        // guard this loops forever. With it the walk terminates and both
627        // parts get the openings exactly once.
628        let mut void_index = agg_map(&[(100, &[200])]);
629        let aggregate_children = agg_map(&[(100, &[110]), (110, &[120]), (120, &[110])]);
630
631        propagate_voids_via_aggregates(&mut void_index, &aggregate_children);
632
633        assert_eq!(void_index.get(&110), Some(&vec![200]));
634        assert_eq!(void_index.get(&120), Some(&vec![200]));
635    }
636
637    #[test]
638    fn propagate_voids_no_op_when_host_has_no_parts() {
639        let mut void_index = agg_map(&[(100, &[200])]);
640        let aggregate_children = agg_map(&[(101, &[110])]); // different host
641        let before = void_index.clone();
642
643        propagate_voids_via_aggregates(&mut void_index, &aggregate_children);
644
645        assert_eq!(void_index, before);
646    }
647
648    #[test]
649    fn propagate_voids_to_parts_covers_non_bep_descendants() {
650        // Parity with the server pipeline: an IfcRoof aggregating an IfcSlab
651        // (skylight pattern) must propagate the roof's opening to the slab
652        // even though the child is not an IfcBuildingElementPart.
653        let content = r#"ISO-10303-21;
654HEADER;
655FILE_DESCRIPTION((''),'2;1');
656FILE_NAME('t.ifc','2024-01-01T00:00:00',(''),(''),'','','');
657FILE_SCHEMA(('IFC4'));
658ENDSEC;
659DATA;
660#51=IFCPRODUCTDEFINITIONSHAPE($,$,(#50));
661#50=IFCSHAPEREPRESENTATION($,'Body','SweptSolid',(#40));
662#40=IFCEXTRUDEDAREASOLID($,$,$,3.0);
663#100=IFCROOF('0001roof',$,'Roof',$,$,$,$,$,$);
664#101=IFCSLAB('0001slab',$,'Pitch',$,$,$,#51,$,$);
665#200=IFCOPENINGELEMENT('0001op',$,'Skylight',$,$,$,#51,$,$);
666#210=IFCRELVOIDSELEMENT('0001rv',$,$,$,#100,#200);
667#300=IFCRELAGGREGATES('0001ra',$,$,$,#100,(#101));
668ENDSEC;
669END-ISO-10303-21;
670"#;
671        let mut decoder = EntityDecoder::new(content);
672        let mut void_index: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
673        void_index.insert(100, vec![200]);
674
675        let part_to_parent = propagate_voids_to_parts(&mut void_index, content, &mut decoder);
676
677        // The slab inherits the skylight opening …
678        assert_eq!(void_index.get(&101), Some(&vec![200]));
679        // … but is NOT in the merge-layers map (not an IfcBuildingElementPart).
680        assert!(part_to_parent.is_empty());
681    }
682}