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;