Skip to main content

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