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//!
39//! # Reference View openings: taken as applied (#351)
40//!
41//! IFC4 Reference View exports author an `IfcOpeningElement` with only a
42//! `Reference` representation, usually tessellated, against a host whose
43//! `Body` already has the hole in it. The schema says so in the opening's own
44//! definition (IFC4 ADD2 TC1, `IfcOpeningElement`, entity definition): with
45//! a `'Body'` representation the voiding relationship is an implicit Boolean
46//! difference, but "if the IfcShapeRepresentation.RepresentationIdentifier =
47//! 'Reference', then the Reference shape representation of the opening is
48//! not subtracted, it is provided in addition to the hole in the Body shape
49//! representation of the voided element". Its Reference View concept
50//! (*Reference Geometry*, same page) repeats it: "Since there are no Boolean
51//! operations, either as IfcBooleanResult or implicitly by
52//! IfcRelVoidsElement the geometry of the IfcOpeningElement shall not be used
53//! to subtract the opening from the 'Body' shape representation of the voided
54//! element." IFC4.3 keeps both readings (`IfcOpeningElement`, concepts
55//! *Reference Geometry* and *Reference Tessellation Geometry*), and its
56//! *Element Openings* template states that the element's representation
57//! "does account for voids at export".
58//!
59//! Such an opening is therefore neither subtractable nor missing: the host's
60//! gross Body already IS its net Body. [`lower_product_net`] still refuses
61//! it, as it always has, because a caller asking for net geometry has not
62//! said it accepts the exporter's word for the hole. ADR 0014 makes net
63//! geometry an explicit request, so accepting it is explicit too:
64//! [`lower_product_net_with`] with [`NetOptions`] set to
65//! [`ReferenceOnlyOpenings::TakeAsApplied`]. Then the opening is listed in
66//! [`NetLowering::taken_as_applied`] with its relation and the reason, and
67//! nothing is subtracted for it. Nothing is guessed or dropped: the caller
68//! sees every opening either subtracted or taken as applied.
69//!
70//! Only an `IfcOpeningElement` (or IFC4's `IfcOpeningStandardCase`) whose
71//! representations are all, and only, `Reference` qualifies; the clause is
72//! about that entity and that identifier. An opening with no representation
73//! at all, one with any other representation beside `Reference`, a
74//! `Reference`-only `IfcVoidingFeature`, and an opening whose Body cannot be
75//! lowered are refused exactly as without the option.
76
77use axiolid_core::{BooleanOperator, Transform3};
78use axiolid_model::{GeometryNode, Instance, NodeId, SolidOperation};
79use ifc_model::{EntityId, Model};
80
81use crate::error::{GeometryError, GeometryResult};
82use crate::input::openings::voidings_of;
83use crate::input::product::Product;
84use crate::input::representation::{ProductShape, Representation};
85use crate::lower::session::NodeShape;
86use crate::lower::{lower_product_representation, LoweringSession, RepresentationPurpose};
87
88/// One opening removed from a host, with the nodes that express it.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[non_exhaustive]
91pub struct Subtraction {
92 /// The voiding element (`IfcOpeningElement`, `IfcVoidingFeature`, ...).
93 pub opening: EntityId,
94 /// The `IfcRelVoidsElement` that authored the subtraction.
95 pub relation: EntityId,
96 /// The opening's lowered Body, placed in world space.
97 pub body: NodeId,
98 /// The host with this opening and every earlier one removed.
99 pub result: NodeId,
100}
101
102/// An opening whose void the host's Body already carries (#351).
103///
104/// Nothing is subtracted for it; see the module documentation for the IFC
105/// clauses. Only reported under [`ReferenceOnlyOpenings::TakeAsApplied`].
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[non_exhaustive]
108pub struct TakenAsApplied {
109 /// The opening element.
110 pub opening: EntityId,
111 /// The `IfcRelVoidsElement` that names it.
112 pub relation: EntityId,
113 /// Why the opening counts as already applied.
114 pub reason: AppliedReason,
115}
116
117/// Why an opening is taken as already applied to its host.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119#[non_exhaustive]
120pub enum AppliedReason {
121 /// Every representation of the opening is `Reference`, which IFC says is
122 /// "not subtracted" but "provided in addition to the hole in the Body".
123 ReferenceRepresentationOnly,
124}
125
126/// What [`lower_product_net_with`] does with an opening that has only a
127/// `Reference` representation (#351).
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
129#[non_exhaustive]
130pub enum ReferenceOnlyOpenings {
131 /// Refuse the host with [`GeometryError::OpeningNotSubtracted`], as
132 /// [`lower_product_net`] does. The default.
133 #[default]
134 Refuse,
135 /// Report the opening in [`NetLowering::taken_as_applied`] and subtract
136 /// nothing for it: the host's Body is taken to carry the void already.
137 TakeAsApplied,
138}
139
140/// Options for a net request; [`Default`] is [`lower_product_net`]'s
141/// behaviour.
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
143#[non_exhaustive]
144pub struct NetOptions {
145 /// What to do with a `Reference`-only opening.
146 pub reference_only_openings: ReferenceOnlyOpenings,
147}
148
149impl NetOptions {
150 /// Set what to do with a `Reference`-only opening.
151 ///
152 /// Builder-style, because the struct is `#[non_exhaustive]`:
153 /// `NetOptions::default().with_reference_only_openings(policy)`.
154 #[must_use]
155 pub fn with_reference_only_openings(mut self, policy: ReferenceOnlyOpenings) -> Self {
156 self.reference_only_openings = policy;
157 self
158 }
159}
160
161/// A host lowered as net geometry.
162#[derive(Debug, Clone, PartialEq, Eq)]
163#[non_exhaustive]
164pub struct NetLowering {
165 /// The host's gross Body, before any subtraction.
166 pub gross: NodeId,
167 /// One entry per opening, in the order they are subtracted.
168 pub subtractions: Vec<Subtraction>,
169 /// Openings the host's Body already carries, in ascending opening id.
170 ///
171 /// Always empty unless the caller chose
172 /// [`ReferenceOnlyOpenings::TakeAsApplied`]. Every voiding opening is in
173 /// exactly one of this list and [`Self::subtractions`].
174 pub taken_as_applied: Vec<TakenAsApplied>,
175 /// The net result: the last subtraction, or `gross` when there are none.
176 pub root: NodeId,
177}
178
179impl NetLowering {
180 /// The subtracted openings, in subtraction order.
181 pub fn openings(&self) -> Vec<EntityId> {
182 self.subtractions.iter().map(|s| s.opening).collect()
183 }
184}
185
186/// How deep `Collection`/`Instance` nesting may go while splitting a body.
187///
188/// Lowering has already bounded recursion, so this only guards against a
189/// graph built by some future caller that nests without limit.
190const MAX_PART_DEPTH: usize = 64;
191
192/// Lower `product`'s Body with every voiding opening subtracted.
193///
194/// Returns `Ok(None)` when the product has no Body, exactly as the gross path
195/// does; openings of a body-less host have nothing to cut. The same as
196/// [`lower_product_net_with`] with [`NetOptions::default`].
197///
198/// # Errors
199///
200/// Any error lowering the host's own Body is returned as is, and so is a host
201/// Body that contains no solid when it has openings to cut. A failure on an
202/// opening -- its Body does not lower, it has no Body, it holds non-solid
203/// geometry, or it names the host itself -- is
204/// [`GeometryError::OpeningNotSubtracted`] naming the opening.
205pub fn lower_product_net(
206 session: &mut LoweringSession<'_>,
207 product: EntityId,
208) -> GeometryResult<Option<NetLowering>> {
209 lower_product_net_with(session, product, NetOptions::default())
210}
211
212/// [`lower_product_net`] with explicit [`NetOptions`].
213///
214/// Under [`ReferenceOnlyOpenings::TakeAsApplied`], an `IfcOpeningElement`
215/// whose representations are all `Reference` is listed in
216/// [`NetLowering::taken_as_applied`] instead of refusing the host (#351; see
217/// the module documentation). A host whose openings are all taken as applied
218/// lowers to its gross Body, and `root == gross`.
219///
220/// # Errors
221///
222/// As [`lower_product_net`]. An opening with no representation, one with a
223/// representation other than `Reference`, or one whose Body does not lower
224/// is still [`GeometryError::OpeningNotSubtracted`] whatever the options.
225pub fn lower_product_net_with(
226 session: &mut LoweringSession<'_>,
227 product: EntityId,
228 options: NetOptions,
229) -> GeometryResult<Option<NetLowering>> {
230 let Some(gross) = lower_product_representation(session, product, RepresentationPurpose::Body)?
231 else {
232 return Ok(None);
233 };
234 let voidings = voidings_of(session.model(), product);
235 if voidings.is_empty() {
236 return Ok(Some(NetLowering {
237 gross,
238 subtractions: Vec::new(),
239 taken_as_applied: Vec::new(),
240 root: gross,
241 }));
242 }
243
244 // Split lazily: a host whose openings are all taken as applied has
245 // nothing cut from it, so it needs no solid parts.
246 let mut heads: Option<Vec<NodeId>> = None;
247 let mut subtractions = Vec::with_capacity(voidings.len());
248 let mut taken_as_applied = Vec::new();
249 for voiding in voidings {
250 let refuse = |cause: GeometryError| GeometryError::OpeningNotSubtracted {
251 host: product,
252 opening: voiding.opening,
253 cause: Box::new(cause),
254 };
255 if voiding.opening == product {
256 // A host voiding itself would subtract to nothing. That is a broken
257 // relation, not a request for an empty solid.
258 return Err(refuse(degenerate(
259 session,
260 voiding.relation,
261 "IfcRelVoidsElement names the same element as host and opening",
262 )));
263 }
264 let body = match lower_product_representation(
265 session,
266 voiding.opening,
267 RepresentationPurpose::Body,
268 ) {
269 Ok(Some(body)) => body,
270 Ok(None) => {
271 if options.reference_only_openings == ReferenceOnlyOpenings::TakeAsApplied
272 && reference_only_opening(session.model(), voiding.opening).map_err(refuse)?
273 {
274 taken_as_applied.push(TakenAsApplied {
275 opening: voiding.opening,
276 relation: voiding.relation,
277 reason: AppliedReason::ReferenceRepresentationOnly,
278 });
279 continue;
280 }
281 return Err(refuse(degenerate(
282 session,
283 voiding.opening,
284 "the opening has no Body representation, so there is nothing to subtract",
285 )));
286 }
287 Err(error) => return Err(refuse(error)),
288 };
289 let tools = solid_parts(session, body, voiding.opening).map_err(refuse)?;
290 let heads = match &mut heads {
291 Some(heads) => heads,
292 None => heads.insert(solid_parts(session, gross, product)?),
293 };
294 for head in heads.iter_mut() {
295 for &tool in &tools {
296 // Attributed to the relation: it is the entity that authored the cut.
297 *head = session
298 .node_for(
299 voiding.relation,
300 GeometryNode::SolidOperation(SolidOperation::Boolean {
301 left: *head,
302 right: tool,
303 operator: BooleanOperator::Difference,
304 }),
305 )
306 .map_err(refuse)?;
307 }
308 }
309 let result = match heads.as_slice() {
310 [single] => *single,
311 _ => session
312 .node_for(voiding.relation, GeometryNode::Collection(heads.clone()))
313 .map_err(refuse)?,
314 };
315 subtractions.push(Subtraction {
316 opening: voiding.opening,
317 relation: voiding.relation,
318 body,
319 result,
320 });
321 }
322
323 let root = subtractions.last().map_or(gross, |step| step.result);
324 Ok(Some(NetLowering {
325 gross,
326 subtractions,
327 taken_as_applied,
328 root,
329 }))
330}
331
332/// Is `opening` an `IfcOpeningElement` whose every representation is
333/// `Reference` (#351)?
334///
335/// `false` for an opening with no representation at all: that one carries
336/// no statement that the host is already voided, so it stays refused. A
337/// dangling representation reference is a missing entity, not a `false`.
338fn reference_only_opening(model: &Model, opening: EntityId) -> GeometryResult<bool> {
339 let entity = model.get(opening).ok_or(GeometryError::MissingEntity {
340 referrer: opening,
341 missing: opening,
342 })?;
343 // The clause is IfcOpeningElement's own; IFC4's deprecated
344 // IfcOpeningStandardCase is its subtype. Other feature subtractions
345 // (IfcVoidingFeature) state no such thing.
346 if !(entity.is_type("IFCOPENINGELEMENT") || entity.is_type("IFCOPENINGSTANDARDCASE")) {
347 return Ok(false);
348 }
349 let Some(shape) = Product::new(opening, entity).representation() else {
350 return Ok(false);
351 };
352 let shape_entity = model.get(shape).ok_or(GeometryError::MissingEntity {
353 referrer: opening,
354 missing: shape,
355 })?;
356 let representations = ProductShape::new(shape, shape_entity).representations()?;
357 if representations.is_empty() {
358 return Ok(false);
359 }
360 for representation in representations {
361 let entity = model
362 .get(representation)
363 .ok_or(GeometryError::MissingEntity {
364 referrer: shape,
365 missing: representation,
366 })?;
367 if Representation::new(representation, entity)
368 .identifier()
369 .as_deref()
370 != Some(REFERENCE)
371 {
372 return Ok(false);
373 }
374 }
375 Ok(true)
376}
377
378/// The representation identifier IFC says is "not subtracted".
379const REFERENCE: &str = "Reference";
380
381/// Split a lowered body into nodes the graph accepts as boolean operands.
382///
383/// A solid node is its own single part. A `Collection` contributes each
384/// member's parts. An `Instance` whose chain ends in a solid is already a
385/// valid operand and is kept as is; an `Instance` of a collection is pushed
386/// down, so each part is re-instanced under the composed transform.
387///
388/// Anything else (a curve, a surface, an open shell lowered as a surface
389/// model) bounds no volume, so it is refused naming `owner` rather than
390/// dropped: dropping part of a body would under-cut or over-report.
391fn solid_parts(
392 session: &mut LoweringSession<'_>,
393 root: NodeId,
394 owner: EntityId,
395) -> GeometryResult<Vec<NodeId>> {
396 let mut parts = Vec::new();
397 collect_parts(session, root, None, owner, 0, &mut parts)?;
398 if parts.is_empty() {
399 return Err(degenerate(
400 session,
401 owner,
402 "the Body holds no solid, so there is nothing to subtract with or from",
403 ));
404 }
405 Ok(parts)
406}
407
408fn collect_parts(
409 session: &mut LoweringSession<'_>,
410 node: NodeId,
411 outer: Option<Transform3>,
412 owner: EntityId,
413 depth: usize,
414 parts: &mut Vec<NodeId>,
415) -> GeometryResult<()> {
416 if depth > MAX_PART_DEPTH {
417 return Err(GeometryError::ChainTooDeep {
418 entity: owner,
419 kind: "body part",
420 limit: MAX_PART_DEPTH,
421 });
422 }
423 let shape = session.shape(node).cloned();
424 match shape {
425 Some(NodeShape::Solid) => parts.push(place(session, node, outer, owner)?),
426 Some(NodeShape::Instance { source, transform }) => {
427 if ends_in_solid(session, source) {
428 parts.push(place(session, node, outer, owner)?);
429 } else {
430 // `outer` is applied after this instance's own transform.
431 let composed = outer.map_or(transform, |outer| outer * transform);
432 collect_parts(session, source, Some(composed), owner, depth + 1, parts)?;
433 }
434 }
435 Some(NodeShape::Collection(members)) => {
436 for member in members {
437 collect_parts(session, member, outer, owner, depth + 1, parts)?;
438 }
439 }
440 Some(NodeShape::Other) | None => {
441 return Err(degenerate(
442 session,
443 owner,
444 "the Body holds geometry that bounds no volume, so it cannot be \
445 used in a boolean subtraction",
446 ));
447 }
448 }
449 Ok(())
450}
451
452/// Does this node's instance chain end in a solid?
453fn ends_in_solid(session: &LoweringSession<'_>, mut node: NodeId) -> bool {
454 for _ in 0..=MAX_PART_DEPTH {
455 match session.shape(node) {
456 Some(NodeShape::Solid) => return true,
457 Some(NodeShape::Instance { source, .. }) => node = *source,
458 _ => return false,
459 }
460 }
461 false
462}
463
464/// `node` under `outer`, or `node` itself when there is no outer transform.
465fn place(
466 session: &mut LoweringSession<'_>,
467 node: NodeId,
468 outer: Option<Transform3>,
469 owner: EntityId,
470) -> GeometryResult<NodeId> {
471 match outer {
472 None => Ok(node),
473 Some(transform) => session.node_for(
474 owner,
475 GeometryNode::Instance(Instance {
476 source: node,
477 transform,
478 }),
479 ),
480 }
481}
482
483fn degenerate(session: &LoweringSession<'_>, entity: EntityId, detail: &str) -> GeometryError {
484 let type_name = session
485 .model()
486 .get(entity)
487 .map_or_else(String::new, |e| e.type_name.to_string());
488 GeometryError::Degenerate {
489 entity,
490 type_name,
491 detail: detail.to_string(),
492 }
493}