Skip to main content

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}