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
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}