Skip to main content

ifc_geometry/solid/
boolean.rs

1//! Booleans: `IfcBooleanResult` and the clipping specialisation.
2//!
3//! # A boolean is a tree, not a pair of solids
4//!
5//! `FirstOperand` and `SecondOperand` are both `IfcBooleanOperand`, a SELECT
6//! that **includes `IfcBooleanResult` itself**. Real files nest these deeply:
7//! a wall with eight openings is commonly a left-leaning chain of eight
8//! `IfcBooleanClippingResult`s, each one's `FirstOperand` being the previous
9//! result.
10//!
11//! Any consumer that assumes two leaf solids handles the first opening and
12//! drops the other seven. [`BooleanResult::operands`] therefore returns
13//! references to be walked recursively, and [`OperandKind`] classifies what was
14//! found so a walker knows when to recurse.
15//!
16//! Depth is bounded in practice but not by the schema, and a self-referencing
17//! tree is expressible, so any recursive walk needs its own depth limit and
18//! cycle check -- see [`crate::GeometryError::CyclicChain`].
19//!
20//! # `IfcBooleanClippingResult` is a constrained `IfcBooleanResult`
21//!
22//! It adds no attributes. Its EXPRESS WHERE rules require the operator to be
23//! DIFFERENCE, the first operand to be a swept solid or another clipping
24//! result, and the second to be an `IfcHalfSpaceSolid`. It exists so a consumer
25//! can recognise "solid minus half space" -- the cheap, always-implementable
26//! case -- without inspecting the operands.
27
28use crate::error::GeometryResult;
29use crate::slots::Slots;
30use ifc_model::{Entity, EntityId, Model};
31
32/// `IfcBooleanResult` attribute slots.
33///
34/// EXPRESS (IFC4 ADD2 TC1): subtypes `IfcGeometricRepresentationItem`, which
35/// declares no explicit attributes, so all three are absolute slots 0-2.
36/// `IfcBooleanClippingResult` adds none, so it shares this exact layout.
37pub(crate) mod slot {
38    /// `Operator : IfcBooleanOperator`.
39    pub const OPERATOR: usize = 0;
40    /// `FirstOperand : IfcBooleanOperand`.
41    pub const FIRST_OPERAND: usize = 1;
42    /// `SecondOperand : IfcBooleanOperand`.
43    pub const SECOND_OPERAND: usize = 2;
44}
45
46/// `IfcBooleanOperator`: the three set operations IFC defines.
47///
48/// The IFC-file enumeration is distinct from the neutral operator, with an
49/// explicit lossless conversion at the adapter boundary under `lowering`.
50///
51/// The link is feature-gated because the target crate is optional; naming it
52/// unconditionally breaks `cargo doc --no-default-features`.
53#[cfg_attr(feature = "lowering", doc = "See [`axiolid_core::BooleanOperator`].")]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum IfcBooleanOperator {
56    /// `.UNION.` -- everything in either operand.
57    Union,
58    /// `.INTERSECTION.` -- everything in both operands.
59    Intersection,
60    /// `.DIFFERENCE.` -- first operand minus second. **Not commutative**;
61    /// swapping the operands of a clipping result deletes the wall instead of
62    /// the opening.
63    Difference,
64}
65
66/// Legacy source-compatible name for the IFC-file enumeration.
67pub use IfcBooleanOperator as BooleanOperator;
68
69/// Invalid `IfcBooleanOperator` token.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
71#[error("unknown IfcBooleanOperator token")]
72pub struct ParseIfcBooleanOperatorError;
73
74impl core::str::FromStr for IfcBooleanOperator {
75    type Err = ParseIfcBooleanOperatorError;
76
77    fn from_str(token: &str) -> Result<Self, Self::Err> {
78        let bare = token.trim_matches('.');
79        if bare.eq_ignore_ascii_case("UNION") {
80            Ok(Self::Union)
81        } else if bare.eq_ignore_ascii_case("INTERSECTION") {
82            Ok(Self::Intersection)
83        } else if bare.eq_ignore_ascii_case("DIFFERENCE") {
84            Ok(Self::Difference)
85        } else {
86            Err(ParseIfcBooleanOperatorError)
87        }
88    }
89}
90
91impl core::fmt::Display for IfcBooleanOperator {
92    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
93        write!(formatter, ".{}.", self.as_token())
94    }
95}
96
97#[cfg(feature = "lowering")]
98impl From<IfcBooleanOperator> for axiolid_core::BooleanOperator {
99    fn from(value: IfcBooleanOperator) -> Self {
100        match value {
101            IfcBooleanOperator::Union => Self::Union,
102            IfcBooleanOperator::Intersection => Self::Intersection,
103            IfcBooleanOperator::Difference => Self::Difference,
104        }
105    }
106}
107
108impl IfcBooleanOperator {
109    /// Parse an enumeration token, with or without its surrounding dots.
110    ///
111    /// Accepts any case because STEP keywords are case-insensitive and real
112    /// exporters are inconsistent about it.
113    pub fn parse(token: &str) -> Option<Self> {
114        token.parse().ok()
115    }
116
117    /// The EXPRESS token, without dots.
118    pub fn as_token(self) -> &'static str {
119        match self {
120            Self::Union => "UNION",
121            Self::Intersection => "INTERSECTION",
122            Self::Difference => "DIFFERENCE",
123        }
124    }
125
126    /// Does operand order change the result?
127    ///
128    /// Only DIFFERENCE is order-sensitive, and it is also the most common
129    /// operator in building models, so a walker that normalises operand order
130    /// for caching must check this first.
131    pub fn is_order_sensitive(self) -> bool {
132        matches!(self, Self::Difference)
133    }
134}
135
136/// What an `IfcBooleanOperand` reference actually points at.
137///
138/// The SELECT permits five families. A walker needs to know whether to recurse
139/// (a nested result) or to hand the entity to a solid builder, and classifying
140/// once here keeps that decision out of every call site.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum OperandKind {
143    /// Another `IfcBooleanResult` (or `IfcBooleanClippingResult`): **recurse**.
144    BooleanResult,
145    /// An `IfcCsgPrimitive3D` subtype.
146    CsgPrimitive,
147    /// An `IfcHalfSpaceSolid` subtype: infinite, only valid here.
148    HalfSpace,
149    /// An `IfcSolidModel` subtype: swept, brep or CSG solid.
150    SolidModel,
151    /// An `IfcTessellatedFaceSet` subtype. The schema requires it to be closed
152    /// when used as a boolean operand.
153    TessellatedFaceSet,
154}
155
156impl OperandKind {
157    /// Classify by IFC type name.
158    ///
159    /// Name-based because the model is untyped by design; the alternative is a
160    /// generated subtype table per schema version, which is what this codebase
161    /// exists to avoid.
162    pub fn classify(type_name: &str) -> Option<Self> {
163        let n = type_name.to_ascii_uppercase();
164        let kind = match n.as_str() {
165            "IFCBOOLEANRESULT" | "IFCBOOLEANCLIPPINGRESULT" => Self::BooleanResult,
166            "IFCBLOCK"
167            | "IFCRECTANGULARPYRAMID"
168            | "IFCRIGHTCIRCULARCONE"
169            | "IFCRIGHTCIRCULARCYLINDER"
170            | "IFCSPHERE"
171            | "IFCCSGPRIMITIVE3D" => Self::CsgPrimitive,
172            "IFCHALFSPACESOLID" | "IFCBOXEDHALFSPACE" | "IFCPOLYGONALBOUNDEDHALFSPACE" => {
173                Self::HalfSpace
174            }
175            "IFCTRIANGULATEDFACESET" | "IFCPOLYGONALFACESET" => Self::TessellatedFaceSet,
176            "IFCCSGSOLID"
177            | "IFCFACETEDBREP"
178            | "IFCFACETEDBREPWITHVOIDS"
179            | "IFCADVANCEDBREP"
180            | "IFCADVANCEDBREPWITHVOIDS"
181            | "IFCEXTRUDEDAREASOLID"
182            | "IFCEXTRUDEDAREASOLIDTAPERED"
183            | "IFCREVOLVEDAREASOLID"
184            | "IFCREVOLVEDAREASOLIDTAPERED"
185            | "IFCSURFACECURVESWEPTAREASOLID"
186            | "IFCFIXEDREFERENCESWEPTAREASOLID"
187            | "IFCSWEPTDISKSOLID"
188            | "IFCSWEPTDISKSOLIDPOLYGONAL" => Self::SolidModel,
189            _ => return None,
190        };
191        Some(kind)
192    }
193
194    /// Must a walker descend into this operand?
195    pub fn is_nested_boolean(self) -> bool {
196        matches!(self, Self::BooleanResult)
197    }
198}
199
200/// `IfcBooleanResult`: two operands combined by a set operation.
201///
202/// See the module docs: the operands form a **tree** and may themselves be
203/// boolean results.
204#[derive(Debug, Clone, Copy)]
205pub struct BooleanResult<'m> {
206    slots: Slots<'m>,
207}
208
209impl<'m> BooleanResult<'m> {
210    /// Wrap an entity assumed to be an `IfcBooleanResult` or a subtype.
211    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
212        Self {
213            slots: Slots::new(id, entity),
214        }
215    }
216
217    /// The entity id.
218    pub fn id(&self) -> EntityId {
219        self.slots.id()
220    }
221
222    /// The IFC type name, naming the concrete subtype.
223    pub fn type_name(&self) -> &'m str {
224        self.slots.type_name()
225    }
226
227    /// The set operation, parsed.
228    ///
229    /// An unrecognised token is an error rather than a silent UNION, because
230    /// substituting the wrong operator produces a solid that looks built.
231    pub fn operator(&self) -> GeometryResult<IfcBooleanOperator> {
232        let token = self
233            .slots
234            .opt_enum(slot::OPERATOR)
235            .ok_or_else(|| self.missing("Operator"))?;
236        IfcBooleanOperator::parse(token).ok_or_else(|| {
237            self.slots
238                .degenerate(format!("unknown IfcBooleanOperator '.{token}.'"))
239        })
240    }
241
242    /// The first operand's reference. For DIFFERENCE this is the minuend.
243    pub fn first_operand(&self) -> GeometryResult<EntityId> {
244        self.slots.req_ref(slot::FIRST_OPERAND, "FirstOperand")
245    }
246
247    /// The second operand's reference. For DIFFERENCE this is the subtrahend.
248    pub fn second_operand(&self) -> GeometryResult<EntityId> {
249        self.slots.req_ref(slot::SECOND_OPERAND, "SecondOperand")
250    }
251
252    /// Both operands in schema order.
253    ///
254    /// Order is preserved and must never be normalised for a DIFFERENCE; see
255    /// [`IfcBooleanOperator::is_order_sensitive`].
256    pub fn operands(&self) -> GeometryResult<(EntityId, EntityId)> {
257        Ok((self.first_operand()?, self.second_operand()?))
258    }
259
260    /// Classify an operand, resolving it in `model`.
261    ///
262    /// Returns `None` for an entity type that is not a legal operand, letting a
263    /// caller report the file's own inconsistency with its own context.
264    pub fn operand_kind(
265        &self,
266        model: &'m Model,
267        operand: EntityId,
268    ) -> GeometryResult<Option<OperandKind>> {
269        let entity = self.slots.resolve(model, operand)?;
270        Ok(OperandKind::classify(&entity.type_name))
271    }
272
273    /// Is this the `IfcBooleanClippingResult` specialisation?
274    pub fn is_clipping(&self) -> bool {
275        self.type_name()
276            .eq_ignore_ascii_case("IFCBOOLEANCLIPPINGRESULT")
277    }
278
279    /// A `MissingAttribute` error for this entity.
280    fn missing(&self, attribute: &'static str) -> crate::GeometryError {
281        crate::GeometryError::MissingAttribute {
282            entity: self.slots.id(),
283            type_name: self.slots.type_name().to_string(),
284            attribute,
285        }
286    }
287}
288
289/// `IfcBooleanClippingResult`: a solid clipped by a half space.
290///
291/// Adds no attributes over [`BooleanResult`]; it is a promise about the
292/// operands, backed by EXPRESS WHERE rules:
293///
294/// - `Operator` is DIFFERENCE.
295/// - `FirstOperand` is an `IfcSweptAreaSolid`, an `IfcSweptDiskSolid` or
296///   another `IfcBooleanClippingResult`.
297/// - `SecondOperand` is an `IfcHalfSpaceSolid`.
298///
299/// That recursion in the first operand is the point: an element clipped N
300/// times is a chain of N of these, and the chain must be walked to the bottom.
301#[derive(Debug, Clone, Copy)]
302pub struct BooleanClippingResult<'m> {
303    slots: Slots<'m>,
304}
305
306impl<'m> BooleanClippingResult<'m> {
307    /// Wrap an entity assumed to be an `IfcBooleanClippingResult`.
308    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
309        Self {
310            slots: Slots::new(id, entity),
311        }
312    }
313
314    /// The entity id.
315    pub fn id(&self) -> EntityId {
316        self.slots.id()
317    }
318
319    /// The inherited `IfcBooleanResult` attributes.
320    pub fn base(&self) -> BooleanResult<'m> {
321        BooleanResult { slots: self.slots }
322    }
323
324    /// Check the schema's operator constraint.
325    ///
326    /// Files do violate it. Reporting rather than assuming DIFFERENCE means a
327    /// mislabelled union is visible instead of quietly cutting the element.
328    pub fn checked_operator(&self) -> GeometryResult<IfcBooleanOperator> {
329        let op = self.base().operator()?;
330        if op == IfcBooleanOperator::Difference {
331            Ok(op)
332        } else {
333            Err(self.slots.degenerate(format!(
334                "IfcBooleanClippingResult requires DIFFERENCE, found {}",
335                op.as_token()
336            )))
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests;