ifc_lite_server/services/data_model/
relationships.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//! Relationship extraction.
6
7use super::generated::{relationship_slots, RelationshipSlots};
8use super::types::{EntityJob, Relationship};
9use ifc_lite_core::{DecodedEntity, EntityDecoder};
10use rayon::prelude::*;
11use std::sync::Arc;
12
13/// Extract all relationships.
14pub(super) fn extract_relationships(
15    jobs: &[EntityJob],
16    content: &Arc<Vec<u8>>,
17    entity_index: &Arc<ifc_lite_core::EntityIndex>,
18) -> Vec<Relationship> {
19    // The schema-derived gate (issue #4205): a type is a relationship job iff
20    // the generated table (apps/server/.../generated/relationship_slots.rs,
21    // derived from @ifc-lite/parser's relationship-schema-slots.ts) has a
22    // slot plan for it. This replaces a hand-written 13-entry array that
23    // silently dropped every `IfcRelationship` subtype outside it — using the
24    // slot lookup itself as the gate means there is exactly one table to keep
25    // in sync with the schema, not two that can drift apart.
26    let rel_jobs: Vec<_> = jobs
27        .iter()
28        .filter(|job| {
29            let type_upper = job.type_name.to_uppercase();
30            relationship_slots(&type_upper).is_some()
31        })
32        .collect();
33
34    tracing::debug!(count = rel_jobs.len(), "Extracting relationships");
35
36    let mut rels: Vec<Relationship> = rel_jobs
37        .par_iter()
38        .filter_map(|job| {
39            let mut local_decoder =
40                EntityDecoder::with_arc_index(content.as_slice(), entity_index.clone());
41            let entity = local_decoder.decode_at(job.start, job.end).ok()?;
42
43            extract_relationship(&entity, &job.type_name, job.id)
44        })
45        .flatten()
46        .collect();
47
48    // Attach each type's own property/quantity sets (issue #1751). Type sets in
49    // IFC live on `IfcTypeObject.HasPropertySets` (attr 5), NOT via
50    // IfcRelDefinesByProperties, so they carry no relationship the client can
51    // follow. Emit a synthetic `TYPEHASPROPERTYSETS` edge (set -> type) per
52    // member so the viewer converter can key type-owned sets by the type id and
53    // resolve the WASM path's type fallback — mirroring
54    // `extractTypeEntityOwnProperties`. A distinct rel_type keeps these out of
55    // the DefinesByProperties graph (no phantom edges for inspector/IDS).
56    let type_links = extract_type_property_links(&rels, content, entity_index);
57    rels.extend(type_links);
58    rels
59}
60
61/// For every type referenced by an `IfcRelDefinesByType`, read its
62/// `HasPropertySets` (attr 5) and emit `{rel_type:"TYPEHASPROPERTYSETS",
63/// relating_id: setId, related_id: typeId}` for each member. Types are
64/// discovered from the DefinesByType `relating_id`s (never by name suffix,
65/// which would catch IfcSurfaceStyle / the rel itself).
66fn extract_type_property_links(
67    rels: &[Relationship],
68    content: &Arc<Vec<u8>>,
69    entity_index: &Arc<ifc_lite_core::EntityIndex>,
70) -> Vec<Relationship> {
71    use std::collections::BTreeSet;
72
73    let type_ids: BTreeSet<u32> = rels
74        .iter()
75        .filter(|r| r.rel_type.eq_ignore_ascii_case("IFCRELDEFINESBYTYPE"))
76        .map(|r| r.relating_id)
77        .collect();
78
79    if type_ids.is_empty() {
80        return Vec::new();
81    }
82
83    type_ids
84        .par_iter()
85        .flat_map_iter(|&type_id| {
86            let mut decoder =
87                EntityDecoder::with_arc_index(content.as_slice(), entity_index.clone());
88            let mut out = Vec::new();
89            if let Ok(entity) = decoder.decode_by_id(type_id) {
90                // IfcTypeObject.HasPropertySets is at index 5 across IFC2X3/4/4X3.
91                if let Some(set_list) = entity.get_list(5) {
92                    for set_ref in set_list.iter() {
93                        if let Some(set_id) = set_ref.as_entity_ref() {
94                            out.push(Relationship {
95                                rel_type: "TYPEHASPROPERTYSETS".to_string(),
96                                // Synthetic: read off IfcTypeObject.HasPropertySets,
97                                // so there is no IfcRel entity to name here.
98                                rel_id: 0,
99                                relating_id: set_id,
100                                related_id: type_id,
101                            });
102                        }
103                    }
104                }
105            }
106            out
107        })
108        .collect()
109}
110
111/// Extract relationship from entity (may return multiple if related[] has multiple items).
112fn extract_relationship(
113    entity: &DecodedEntity,
114    type_name: &str,
115    rel_id: u32,
116) -> Option<Vec<Relationship>> {
117    let type_upper = type_name.to_uppercase();
118
119    // Schema-derived slot plan (issue #4205): the generated table already
120    // encodes ABSOLUTE attribute positions and whether `related` is a
121    // LIST/SET or a single reference — derived from the same
122    // relationship-schema-slots.ts the TS/WASM columnar parser uses, so this
123    // stays correct for every concrete `IfcRelationship` subtype without a
124    // per-type match here. `extract_relationships` already filtered `jobs`
125    // down to types this resolves, but re-check defensively (`?` below) in
126    // case of a future caller.
127    let slots: RelationshipSlots = relationship_slots(&type_upper)?;
128
129    // `relating` is schema-legally a grouped aggregate for exactly one
130    // concrete subtype (`IFCRELDEFINESBYPROPERTIES.RelatingPropertyDefinition`
131    // — see `relating_is_list`'s doc comment in the generated table).
132    // `get_ref` returns `None` for a list-valued attribute (it delegates to
133    // `AttributeValue::as_entity_ref`, which only recognises a bare
134    // reference), so calling it unconditionally silently dropped the WHOLE
135    // relationship for a grouped `IfcPropertySetDefinitionSet` — every
136    // related object lost all properties/quantities from that pset group.
137    // `get_refs` accepts both a bare ref and a list, mirroring the TS/WASM
138    // path's `readRefList` (`columnar-parser-relationships.ts`), which reads
139    // `RelatingPropertyDefinition` as a ref list unconditionally and applies
140    // EVERY entry in the group to EVERY related object — reproduced below as
141    // the same cross product, not just the first entry.
142    let relating_ids: Vec<u32> = if slots.relating_is_list {
143        entity.get_refs(slots.relating_idx as usize)?
144    } else {
145        vec![entity.get_ref(slots.relating_idx as usize)?]
146    };
147
148    let related_ids: Vec<u32> = if slots.related_is_list {
149        let related_list = entity.get_list(slots.related_idx as usize)?;
150        related_list
151            .iter()
152            .filter_map(|v| v.as_entity_ref())
153            .collect()
154    } else {
155        vec![entity.get_ref(slots.related_idx as usize)?]
156    };
157
158    if relating_ids.is_empty() || related_ids.is_empty() {
159        return None;
160    }
161
162    Some(
163        relating_ids
164            .into_iter()
165            .flat_map(|relating_id| {
166                related_ids.iter().map(move |&related_id| Relationship {
167                    rel_type: type_name.to_string(),
168                    rel_id,
169                    relating_id,
170                    related_id,
171                })
172            })
173            .collect(),
174    )
175}