ifc_geometry/lower/net.rs
1//! Net product geometry: the Body minus every opening that voids it (#44).
2//!
3//! # Why this is a separate entry point
4//!
5//! A product's Body representation is its GROSS shape. `IfcRelVoidsElement`
6//! says an opening's body is to be removed from it, but the file never authors
7//! the result. Quantity takeoff wants gross; clearances, net areas, ratios and
8//! clash tests through an opening want net. Both must stay reachable, so gross
9//! keeps its existing entry point unchanged and net is asked for explicitly.
10//!
11//! # What the graph looks like
12//!
13//! The host body is lowered as usual, then each opening's Body is lowered
14//! through ITS OWN placement chain (exporters place openings relative to the
15//! host, and the chain resolves that). Exact `Boolean { Difference }` nodes
16//! are appended; nothing is evaluated here.
17//!
18//! A boolean operand must be a solid, and a multi-item Body lowers to a
19//! `Collection` (possibly under an `Instance`), which the graph rejects as an
20//! operand. So both sides are first split into their solid PARTS, with any
21//! enclosing instance transforms carried onto each part. Then, because
22//! `(A u B) - O = (A - O) u (B - O)`, each host part is cut on its own:
23//!
24//! ```text
25//! part_i -> (part_i - o1_a - o1_b) -> (... - o2_a) -> ... for every host part i
26//! step k = Collection(every part's head after opening k) (or the head itself)
27//! root = step of the last opening
28//! ```
29//!
30//! Openings go in ascending id, so the graph is a function of the model's
31//! content.
32//!
33//! # Refusal, not a quiet gross body
34//!
35//! Every per-opening failure becomes [`GeometryError::OpeningNotSubtracted`]
36//! naming the opening. Skipping it would hand back geometry that claims to be
37//! net and is not -- the silent failure this entry point exists to prevent.
38
39use axiolid_core::{BooleanOperator, Transform3};
40use axiolid_model::{GeometryNode, Instance, NodeId, SolidOperation};
41use ifc_model::EntityId;
42
43use crate::error::{GeometryError, GeometryResult};
44use crate::input::openings::voidings_of;
45use crate::lower::session::NodeShape;
46use crate::lower::{lower_product_representation, LoweringSession, RepresentationPurpose};
47
48/// One opening removed from a host, with the nodes that express it.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[non_exhaustive]
51pub struct Subtraction {
52 /// The voiding element (`IfcOpeningElement`, `IfcVoidingFeature`, ...).
53 pub opening: EntityId,
54 /// The `IfcRelVoidsElement` that authored the subtraction.
55 pub relation: EntityId,
56 /// The opening's lowered Body, placed in world space.
57 pub body: NodeId,
58 /// The host with this opening and every earlier one removed.
59 pub result: NodeId,
60}
61
62/// A host lowered as net geometry.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct NetLowering {
66 /// The host's gross Body, before any subtraction.
67 pub gross: NodeId,
68 /// One entry per opening, in the order they are subtracted.
69 pub subtractions: Vec<Subtraction>,
70 /// The net result: the last subtraction, or `gross` when there are none.
71 pub root: NodeId,
72}
73
74impl NetLowering {
75 /// The subtracted openings, in subtraction order.
76 pub fn openings(&self) -> Vec<EntityId> {
77 self.subtractions.iter().map(|s| s.opening).collect()
78 }
79}
80
81/// How deep `Collection`/`Instance` nesting may go while splitting a body.
82///
83/// Lowering has already bounded recursion, so this only guards against a
84/// graph built by some future caller that nests without limit.
85const MAX_PART_DEPTH: usize = 64;
86
87/// Lower `product`'s Body with every voiding opening subtracted.
88///
89/// Returns `Ok(None)` when the product has no Body, exactly as the gross path
90/// does; openings of a body-less host have nothing to cut.
91///
92/// # Errors
93///
94/// Any error lowering the host's own Body is returned as is, and so is a host
95/// Body that contains no solid when it has openings to cut. A failure on an
96/// opening -- its Body does not lower, it has no Body, it holds non-solid
97/// geometry, or it names the host itself -- is
98/// [`GeometryError::OpeningNotSubtracted`] naming the opening.
99pub fn lower_product_net(
100 session: &mut LoweringSession<'_>,
101 product: EntityId,
102) -> GeometryResult<Option<NetLowering>> {
103 let Some(gross) = lower_product_representation(session, product, RepresentationPurpose::Body)?
104 else {
105 return Ok(None);
106 };
107 let voidings = voidings_of(session.model(), product);
108 if voidings.is_empty() {
109 return Ok(Some(NetLowering {
110 gross,
111 subtractions: Vec::new(),
112 root: gross,
113 }));
114 }
115
116 let mut heads = solid_parts(session, gross, product)?;
117 let mut subtractions = Vec::with_capacity(voidings.len());
118 for voiding in voidings {
119 let refuse = |cause: GeometryError| GeometryError::OpeningNotSubtracted {
120 host: product,
121 opening: voiding.opening,
122 cause: Box::new(cause),
123 };
124 if voiding.opening == product {
125 // A host voiding itself would subtract to nothing. That is a broken
126 // relation, not a request for an empty solid.
127 return Err(refuse(degenerate(
128 session,
129 voiding.relation,
130 "IfcRelVoidsElement names the same element as host and opening",
131 )));
132 }
133 let body = match lower_product_representation(
134 session,
135 voiding.opening,
136 RepresentationPurpose::Body,
137 ) {
138 Ok(Some(body)) => body,
139 Ok(None) => {
140 return Err(refuse(degenerate(
141 session,
142 voiding.opening,
143 "the opening has no Body representation, so there is nothing to subtract",
144 )))
145 }
146 Err(error) => return Err(refuse(error)),
147 };
148 let tools = solid_parts(session, body, voiding.opening).map_err(refuse)?;
149 for head in &mut heads {
150 for &tool in &tools {
151 // Attributed to the relation: it is the entity that authored the cut.
152 *head = session
153 .node_for(
154 voiding.relation,
155 GeometryNode::SolidOperation(SolidOperation::Boolean {
156 left: *head,
157 right: tool,
158 operator: BooleanOperator::Difference,
159 }),
160 )
161 .map_err(refuse)?;
162 }
163 }
164 let result = match heads.as_slice() {
165 [single] => *single,
166 _ => session
167 .node_for(voiding.relation, GeometryNode::Collection(heads.clone()))
168 .map_err(refuse)?,
169 };
170 subtractions.push(Subtraction {
171 opening: voiding.opening,
172 relation: voiding.relation,
173 body,
174 result,
175 });
176 }
177
178 let root = subtractions.last().map_or(gross, |step| step.result);
179 Ok(Some(NetLowering {
180 gross,
181 subtractions,
182 root,
183 }))
184}
185
186/// Split a lowered body into nodes the graph accepts as boolean operands.
187///
188/// A solid node is its own single part. A `Collection` contributes each
189/// member's parts. An `Instance` whose chain ends in a solid is already a
190/// valid operand and is kept as is; an `Instance` of a collection is pushed
191/// down, so each part is re-instanced under the composed transform.
192///
193/// Anything else (a curve, a surface, an open shell lowered as a surface
194/// model) bounds no volume, so it is refused naming `owner` rather than
195/// dropped: dropping part of a body would under-cut or over-report.
196fn solid_parts(
197 session: &mut LoweringSession<'_>,
198 root: NodeId,
199 owner: EntityId,
200) -> GeometryResult<Vec<NodeId>> {
201 let mut parts = Vec::new();
202 collect_parts(session, root, None, owner, 0, &mut parts)?;
203 if parts.is_empty() {
204 return Err(degenerate(
205 session,
206 owner,
207 "the Body holds no solid, so there is nothing to subtract with or from",
208 ));
209 }
210 Ok(parts)
211}
212
213fn collect_parts(
214 session: &mut LoweringSession<'_>,
215 node: NodeId,
216 outer: Option<Transform3>,
217 owner: EntityId,
218 depth: usize,
219 parts: &mut Vec<NodeId>,
220) -> GeometryResult<()> {
221 if depth > MAX_PART_DEPTH {
222 return Err(GeometryError::ChainTooDeep {
223 entity: owner,
224 kind: "body part",
225 limit: MAX_PART_DEPTH,
226 });
227 }
228 let shape = session.shape(node).cloned();
229 match shape {
230 Some(NodeShape::Solid) => parts.push(place(session, node, outer, owner)?),
231 Some(NodeShape::Instance { source, transform }) => {
232 if ends_in_solid(session, source) {
233 parts.push(place(session, node, outer, owner)?);
234 } else {
235 // `outer` is applied after this instance's own transform.
236 let composed = outer.map_or(transform, |outer| outer * transform);
237 collect_parts(session, source, Some(composed), owner, depth + 1, parts)?;
238 }
239 }
240 Some(NodeShape::Collection(members)) => {
241 for member in members {
242 collect_parts(session, member, outer, owner, depth + 1, parts)?;
243 }
244 }
245 Some(NodeShape::Other) | None => {
246 return Err(degenerate(
247 session,
248 owner,
249 "the Body holds geometry that bounds no volume, so it cannot be \
250 used in a boolean subtraction",
251 ));
252 }
253 }
254 Ok(())
255}
256
257/// Does this node's instance chain end in a solid?
258fn ends_in_solid(session: &LoweringSession<'_>, mut node: NodeId) -> bool {
259 for _ in 0..=MAX_PART_DEPTH {
260 match session.shape(node) {
261 Some(NodeShape::Solid) => return true,
262 Some(NodeShape::Instance { source, .. }) => node = *source,
263 _ => return false,
264 }
265 }
266 false
267}
268
269/// `node` under `outer`, or `node` itself when there is no outer transform.
270fn place(
271 session: &mut LoweringSession<'_>,
272 node: NodeId,
273 outer: Option<Transform3>,
274 owner: EntityId,
275) -> GeometryResult<NodeId> {
276 match outer {
277 None => Ok(node),
278 Some(transform) => session.node_for(
279 owner,
280 GeometryNode::Instance(Instance {
281 source: node,
282 transform,
283 }),
284 ),
285 }
286}
287
288fn degenerate(session: &LoweringSession<'_>, entity: EntityId, detail: &str) -> GeometryError {
289 let type_name = session
290 .model()
291 .get(entity)
292 .map_or_else(String::new, |e| e.type_name.to_string());
293 GeometryError::Degenerate {
294 entity,
295 type_name,
296 detail: detail.to_string(),
297 }
298}