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