ifc_systems/connectivity/relation.rs
1//! `IfcRelConnectsPorts` and the port connection graph.
2//!
3//! # Slots
4//!
5//! ```text
6//! IfcRelConnectsPorts 4 = RelatingPort 5 = RelatedPort 6 = RealizingElement
7//! ```
8//!
9//! # Direction is not connectivity
10//!
11//! `RelatingPort` and `RelatedPort` record which port the exporter wrote
12//! first, NOT which way anything flows. Flow is stated separately by each
13//! port's `FlowDirection`. Treating the relationship as directed would invent
14//! a direction: a pipe connecting an outlet to an inlet is the same physical
15//! connection whichever end the exporter happened to name first.
16//!
17//! The graph is therefore UNDIRECTED, and direction is applied on top from
18//! flow directions when a caller asks for it.
19
20use std::collections::{BTreeMap, BTreeSet};
21
22use ifc_model::{EntityId, Model, Value};
23
24use crate::error::SystemAnomaly;
25use crate::release;
26
27pub(crate) mod slot {
28 /// `IfcRelConnectsPorts.RelatingPort`.
29 pub const RELATING: usize = 4;
30 /// `IfcRelConnectsPorts.RelatedPort`.
31 pub const RELATED: usize = 5;
32 /// `IfcRelConnectsPorts.RealizingElement` -- the pipe/duct that realises
33 /// the connection, when the file names one.
34 pub const REALIZING: usize = 6;
35}
36
37/// One stated port-to-port connection.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Connection {
40 /// The `IfcRelConnectsPorts` entity.
41 pub id: EntityId,
42 /// The port named first. NOT an upstream/downstream claim.
43 pub relating: EntityId,
44 /// The port named second.
45 pub related: EntityId,
46 /// The element realising this connection, when stated.
47 pub realizing: Option<EntityId>,
48}
49
50/// An undirected port connection graph.
51///
52/// Built once and queried many times: a traversal that re-scanned the model
53/// for each step would be quadratic on files with thousands of ports.
54#[derive(Debug, Default, Clone)]
55pub struct ConnectionGraph {
56 connections: Vec<Connection>,
57 adjacency: BTreeMap<EntityId, BTreeSet<EntityId>>,
58}
59
60impl ConnectionGraph {
61 /// Read every `IfcRelConnectsPorts` in the file.
62 ///
63 /// A connection naming a non-port, or an entity not in the file, is
64 /// reported and skipped: one malformed relationship must not cost the
65 /// caller the rest of the network.
66 ///
67 /// Port ancestry is read against the release the model declares.
68 pub fn build(model: &Model) -> (Self, Vec<SystemAnomaly>) {
69 let schema = release::resolve_or_ifc4(model);
70 let mut anomalies = Vec::new();
71 let mut graph = Self::default();
72
73 for &id in model.ids_of_type("IFCRELCONNECTSPORTS") {
74 let Some(entity) = model.get(id) else {
75 continue;
76 };
77 let relating = match entity.attributes.get(slot::RELATING) {
78 Some(Value::Ref(port)) => *port,
79 _ => continue,
80 };
81 let related = match entity.attributes.get(slot::RELATED) {
82 Some(Value::Ref(port)) => *port,
83 _ => continue,
84 };
85
86 let mut ok = true;
87 for port in [relating, related] {
88 match model.get(port) {
89 None => {
90 anomalies.push(SystemAnomaly::Dangling {
91 relation: id,
92 missing: port,
93 });
94 ok = false;
95 }
96 Some(e) if !schema.is_a(&e.type_name.to_ascii_uppercase(), "IFCPORT") => {
97 anomalies.push(SystemAnomaly::NotAPort {
98 relation: id,
99 entity: port,
100 type_name: e.type_name.to_ascii_uppercase(),
101 });
102 ok = false;
103 }
104 Some(_) => {}
105 }
106 }
107 if !ok {
108 continue;
109 }
110
111 let realizing = match entity.attributes.get(slot::REALIZING) {
112 Some(Value::Ref(element)) => Some(*element),
113 _ => None,
114 };
115 graph.connections.push(Connection {
116 id,
117 relating,
118 related,
119 realizing,
120 });
121 // Undirected: both directions are inserted, because the schema
122 // order records authoring order and not flow.
123 graph.adjacency.entry(relating).or_default().insert(related);
124 graph.adjacency.entry(related).or_default().insert(relating);
125 }
126 (graph, anomalies)
127 }
128
129 /// Every stated connection, in file order.
130 pub fn connections(&self) -> &[Connection] {
131 &self.connections
132 }
133
134 /// Ports directly connected to `port`, ascending by id.
135 pub fn neighbours(&self, port: EntityId) -> Vec<EntityId> {
136 self.adjacency
137 .get(&port)
138 .map(|set| set.iter().copied().collect())
139 .unwrap_or_default()
140 }
141
142 /// Every port reachable from `start`, including `start` itself.
143 ///
144 /// Breadth-first with a visited set, so a network containing a LOOP -- a
145 /// ring main, a recirculating circuit -- terminates instead of running
146 /// forever. Loops are normal in real distribution systems, not corrupt
147 /// data, so this must not be a refusal.
148 pub fn reachable_from(&self, start: EntityId) -> Vec<EntityId> {
149 let mut seen = BTreeSet::new();
150 let mut queue = std::collections::VecDeque::new();
151 seen.insert(start);
152 queue.push_back(start);
153 while let Some(port) = queue.pop_front() {
154 for next in self.adjacency.get(&port).into_iter().flatten() {
155 if seen.insert(*next) {
156 queue.push_back(*next);
157 }
158 }
159 }
160 seen.into_iter().collect()
161 }
162
163 /// Connected components of the graph, each sorted, components ascending.
164 ///
165 /// A distribution system that splits into two components is usually an
166 /// authoring error -- a missing connection -- and is worth surfacing.
167 pub fn components(&self) -> Vec<Vec<EntityId>> {
168 let mut seen = BTreeSet::new();
169 let mut out = Vec::new();
170 for &port in self.adjacency.keys() {
171 if seen.contains(&port) {
172 continue;
173 }
174 let component = self.reachable_from(port);
175 seen.extend(component.iter().copied());
176 out.push(component);
177 }
178 out
179 }
180}
181
182/// A network view that also steps THROUGH elements, not just between them.
183///
184/// `IfcRelConnectsPorts` joins one element's port to another's. It never
185/// joins an element's OWN ports to each other: the fact that fluid entering
186/// a pipe's inlet leaves by its outlet is implied by the element, not stated
187/// by any relationship.
188///
189/// So the raw connection graph of a real chain
190///
191/// ```text
192/// [seg0] out --- in [seg1] out --- in [fitting]
193/// ```
194///
195/// has NO path from seg0's inlet to seg1 at all: every connection is an
196/// isolated pair. Answering "what is downstream of this pipe" needs both
197/// kinds of edge -- across connections AND through elements.
198///
199/// This is the distinction between the two, made explicit rather than
200/// silently folded into [`ConnectionGraph`].
201#[derive(Debug, Default, Clone)]
202pub struct NetworkGraph {
203 adjacency: BTreeMap<EntityId, BTreeSet<EntityId>>,
204}
205
206impl NetworkGraph {
207 /// Combine stated connections with through-element port pairing.
208 ///
209 /// `ports` supplies each port's owning element, which is what makes the
210 /// through-element edges knowable.
211 pub fn build(graph: &ConnectionGraph, ports: &[crate::port::Port]) -> Self {
212 let mut adjacency: BTreeMap<EntityId, BTreeSet<EntityId>> = BTreeMap::new();
213
214 // Stated port-to-port connections.
215 for connection in &graph.connections {
216 adjacency
217 .entry(connection.relating)
218 .or_default()
219 .insert(connection.related);
220 adjacency
221 .entry(connection.related)
222 .or_default()
223 .insert(connection.relating);
224 }
225
226 // Through-element edges: every pair of ports on the same element.
227 let mut by_element: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
228 for port in ports {
229 if let Some(element) = port.element {
230 by_element.entry(element).or_default().push(port.id);
231 }
232 }
233 for members in by_element.values() {
234 for (i, &a) in members.iter().enumerate() {
235 for &b in &members[i + 1..] {
236 adjacency.entry(a).or_default().insert(b);
237 adjacency.entry(b).or_default().insert(a);
238 }
239 }
240 }
241
242 Self { adjacency }
243 }
244
245 /// Every port reachable from `start`, including `start`.
246 ///
247 /// Cycle-safe: ring mains are normal, so a visited set is required, not
248 /// an optimisation.
249 pub fn reachable_from(&self, start: EntityId) -> Vec<EntityId> {
250 let mut seen = BTreeSet::new();
251 let mut queue = std::collections::VecDeque::new();
252 seen.insert(start);
253 queue.push_back(start);
254 while let Some(port) = queue.pop_front() {
255 for next in self.adjacency.get(&port).into_iter().flatten() {
256 if seen.insert(*next) {
257 queue.push_back(*next);
258 }
259 }
260 }
261 seen.into_iter().collect()
262 }
263
264 /// Connected components, each sorted, components ascending.
265 pub fn components(&self) -> Vec<Vec<EntityId>> {
266 let mut seen = BTreeSet::new();
267 let mut out = Vec::new();
268 for &port in self.adjacency.keys() {
269 if seen.contains(&port) {
270 continue;
271 }
272 let component = self.reachable_from(port);
273 seen.extend(component.iter().copied());
274 out.push(component);
275 }
276 out
277 }
278}