Skip to main content

ifc_geometry/solid/
halfspace.rs

1//! Half spaces: **infinite** solids, valid only as boolean operands.
2//!
3//! # Read this before using anything in this module
4//!
5//! An `IfcHalfSpaceSolid` is everything on one side of a surface. It has
6//! **infinite volume**. It cannot be tessellated, cannot be given a bounding
7//! box, and has no meaningful surface area. Any pipeline that treats it as an
8//! ordinary solid either hangs, allocates until it dies, or emits a mesh the
9//! size of the coordinate space.
10//!
11//! It is legal in exactly one place: as an operand of an `IfcBooleanResult`
12//! (in practice as the `SecondOperand` of an `IfcBooleanClippingResult`, where
13//! the schema requires it). The intended reading is "cut this solid with a
14//! plane", and the infinite half space is how IFC spells the cutting tool.
15//!
16//! # `BaseSurface` and `AgreementFlag`
17//!
18//! `BaseSurface` is an `IfcSurface`, and in real files essentially always an
19//! `IfcPlane`. It divides space in two. `AgreementFlag` picks which of the two
20//! halves is the material:
21//!
22//! - `.T.` -- the solid is on the side the plane's **normal points away from**
23//!   (that is, the side of decreasing surface parameter / below the plane in
24//!   its own coordinate system).
25//! - `.F.` -- the solid is on the other side.
26//!
27//! Getting this backwards does not fail: it cuts away the part that should have
28//! been kept, producing a wall with the wrong end missing. There is no
29//! geometric check that catches it, so the flag must be transcribed exactly.
30//!
31//! # The two bounded subtypes
32//!
33//! Both bound the half space, but in different senses, and the difference
34//! matters:
35//!
36//! - [`BoxedHalfSpace`] carries an `Enclosure` bounding box. It is a
37//!   **declaration of the region of interest**, letting a consumer clip the
38//!   infinite solid to something finite before the boolean.
39//! - [`PolygonalBoundedHalfSpace`] carries a 2D `PolygonalBoundary` in the XY
40//!   plane of its own `Position` and is bounded by the **prism** obtained by
41//!   extruding that boundary along +Z of that same `Position`. The subtraction
42//!   body is the intersection of the half space with that prism.
43
44use crate::error::GeometryResult;
45use crate::slots::Slots;
46use ifc_model::{Entity, EntityId};
47
48/// Half space attribute slots.
49///
50/// EXPRESS (IFC4 ADD2 TC1): `IfcHalfSpaceSolid` subtypes
51/// `IfcGeometricRepresentationItem`, which declares no explicit attributes, so
52/// `BaseSurface` and `AgreementFlag` are absolute slots 0 and 1 -- and the
53/// subtypes' own attributes therefore start at slot 2.
54pub(crate) mod slot {
55    /// `BaseSurface : IfcSurface`, on `IfcHalfSpaceSolid`.
56    pub const BASE_SURFACE: usize = 0;
57    /// `AgreementFlag : IfcBoolean`, on `IfcHalfSpaceSolid`.
58    pub const AGREEMENT_FLAG: usize = 1;
59    /// `Enclosure : IfcBoundingBox` on `IfcBoxedHalfSpace`, absolute slot 2.
60    pub const ENCLOSURE: usize = 2;
61    /// `Position : IfcAxis2Placement3D` on `IfcPolygonalBoundedHalfSpace`.
62    pub const POSITION: usize = 2;
63    /// `PolygonalBoundary : IfcBoundedCurve` on the polygonal subtype.
64    pub const POLYGONAL_BOUNDARY: usize = 3;
65}
66
67/// `IfcHalfSpaceSolid`: an **infinite** solid on one side of a surface.
68///
69/// See the module documentation. This type is not tessellatable and callers
70/// must route it through a boolean; [`Self::is_infinite`] exists so that a
71/// pipeline can assert the invariant rather than discovering it at render time.
72#[derive(Debug, Clone, Copy)]
73pub struct HalfSpaceSolid<'m> {
74    slots: Slots<'m>,
75}
76
77impl<'m> HalfSpaceSolid<'m> {
78    /// Wrap an entity assumed to be an `IfcHalfSpaceSolid` or a subtype.
79    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
80        Self {
81            slots: Slots::new(id, entity),
82        }
83    }
84
85    /// The entity id.
86    pub fn id(&self) -> EntityId {
87        self.slots.id()
88    }
89
90    /// The IFC type name, naming the concrete subtype.
91    pub fn type_name(&self) -> &'m str {
92        self.slots.type_name()
93    }
94
95    /// The `IfcSurface` reference dividing space. Normally an `IfcPlane`.
96    ///
97    /// TODO: resolve through the surface module once it exists; this crate
98    /// deliberately does not define a competing surface view.
99    pub fn base_surface(&self) -> GeometryResult<EntityId> {
100        self.slots.req_ref(slot::BASE_SURFACE, "BaseSurface")
101    }
102
103    /// Which side of `BaseSurface` is material.
104    ///
105    /// `true` means the solid lies on the side the surface normal points away
106    /// from. Inverting this silently cuts the wrong half away.
107    pub fn agreement_flag(&self) -> GeometryResult<bool> {
108        self.slots.req_bool(slot::AGREEMENT_FLAG, "AgreementFlag")
109    }
110
111    /// Is the solid unbounded in every direction?
112    ///
113    /// `true` for a plain `IfcHalfSpaceSolid`. `false` for the two bounded
114    /// subtypes, whose extra attributes give a finite region to work in.
115    ///
116    /// Note that even a `false` here does not make the entity meaningful on its
117    /// own: it is still only valid as a boolean operand. What changes is that a
118    /// consumer can build a finite body for it.
119    pub fn is_infinite(&self) -> bool {
120        !self.is_bounded()
121    }
122
123    /// Does the concrete type carry a bounding attribute?
124    pub fn is_bounded(&self) -> bool {
125        let name = self.type_name();
126        name.eq_ignore_ascii_case("IFCBOXEDHALFSPACE")
127            || name.eq_ignore_ascii_case("IFCPOLYGONALBOUNDEDHALFSPACE")
128    }
129
130    /// Reject use of this half space anywhere other than a boolean operand.
131    ///
132    /// Exists so the "cannot be tessellated" rule is a single call rather than
133    /// a comment every consumer is expected to have read. The error is
134    /// [`crate::GeometryError::Unsupported`], because the file is perfectly
135    /// valid -- it is the requested operation that is not.
136    pub fn reject_standalone_use(&self) -> crate::GeometryError {
137        self.slots
138            .unsupported("IfcHalfSpaceSolid is infinite and can only be used as a boolean operand")
139    }
140}
141
142/// `IfcBoxedHalfSpace`: a half space with a declared bounding box.
143///
144/// `Enclosure` is an `IfcBoundingBox` that bounds the half space, turning an
145/// infinite operand into a finite one a kernel can actually build. The schema
146/// additionally forbids `BaseSurface` from being an `IfcCurveBoundedPlane`
147/// here, so the base surface really is unbounded and the box is doing all the
148/// bounding.
149///
150/// The box is expressed in the coordinate system of the containing
151/// representation, not relative to `BaseSurface`.
152#[derive(Debug, Clone, Copy)]
153pub struct BoxedHalfSpace<'m> {
154    slots: Slots<'m>,
155}
156
157impl<'m> BoxedHalfSpace<'m> {
158    /// Wrap an entity assumed to be an `IfcBoxedHalfSpace`.
159    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
160        Self {
161            slots: Slots::new(id, entity),
162        }
163    }
164
165    /// The entity id.
166    pub fn id(&self) -> EntityId {
167        self.slots.id()
168    }
169
170    /// The inherited `IfcHalfSpaceSolid` attributes.
171    pub fn base(&self) -> HalfSpaceSolid<'m> {
172        HalfSpaceSolid { slots: self.slots }
173    }
174
175    /// The `IfcBoundingBox` reference that bounds the half space.
176    ///
177    /// See [`crate::solid::bbox::BoundingBox`] for reading it.
178    pub fn enclosure(&self) -> GeometryResult<EntityId> {
179        self.slots.req_ref(slot::ENCLOSURE, "Enclosure")
180    }
181}
182
183/// `IfcPolygonalBoundedHalfSpace`: a half space clipped by an extruded polygon.
184///
185/// # How the bounding actually works
186///
187/// This is the single most misread entity in IFC clipping, so, precisely:
188///
189/// 1. `Position` is an `IfcAxis2Placement3D` establishing a local coordinate
190///    system. It is **independent of `BaseSurface`**; the two need not share an
191///    origin or an orientation.
192/// 2. `PolygonalBoundary` is a **2D** bounded curve (an `IfcPolyline` or an
193///    `IfcCompositeCurve`, per the schema's `BoundaryType` rule) lying in the
194///    **XY plane of `Position`**. Its coordinates are 2D, so it is not a curve
195///    in world space and cannot be used without applying `Position`.
196/// 3. The bounding body is that polygon extruded **along +Z of `Position`**,
197///    unbounded in that direction.
198/// 4. The final solid is the intersection of that prism with the infinite half
199///    space defined by `BaseSurface` and `AgreementFlag`.
200///
201/// The consequence is that the polygon does not lie on the base surface and
202/// should never be projected onto it. Doing so produces a clip of roughly the
203/// right shape in roughly the wrong place, which is why bad wall openings look
204/// almost correct.
205#[derive(Debug, Clone, Copy)]
206pub struct PolygonalBoundedHalfSpace<'m> {
207    slots: Slots<'m>,
208}
209
210impl<'m> PolygonalBoundedHalfSpace<'m> {
211    /// Wrap an entity assumed to be an `IfcPolygonalBoundedHalfSpace`.
212    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
213        Self {
214            slots: Slots::new(id, entity),
215        }
216    }
217
218    /// The entity id.
219    pub fn id(&self) -> EntityId {
220        self.slots.id()
221    }
222
223    /// The inherited `IfcHalfSpaceSolid` attributes.
224    pub fn base(&self) -> HalfSpaceSolid<'m> {
225        HalfSpaceSolid { slots: self.slots }
226    }
227
228    /// The `IfcAxis2Placement3D` whose XY plane holds the boundary.
229    ///
230    /// Required here, unlike the optional `Position` on `IfcSweptAreaSolid`:
231    /// without it the 2D boundary cannot be placed at all.
232    pub fn position(&self) -> GeometryResult<EntityId> {
233        self.slots.req_ref(slot::POSITION, "Position")
234    }
235
236    /// The 2D `IfcBoundedCurve` reference bounding the half space.
237    ///
238    /// Its coordinates are in the XY plane of [`Self::position`]; the clipping
239    /// body is this curve extruded along that placement's +Z.
240    ///
241    /// TODO: resolve through the curve module once it exists; this crate
242    /// deliberately does not define a competing curve view.
243    pub fn polygonal_boundary(&self) -> GeometryResult<EntityId> {
244        self.slots
245            .req_ref(slot::POLYGONAL_BOUNDARY, "PolygonalBoundary")
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::solid::testkit::{entity, r};
253    use ifc_model::Value;
254
255    #[test]
256    fn base_surface_and_agreement_flag_are_slots_zero_and_one() {
257        let e = entity("IFCHALFSPACESOLID", vec![r(10), Value::Bool(true)]);
258        let view = HalfSpaceSolid::new(EntityId(1), &e);
259        assert_eq!(view.base_surface().unwrap(), EntityId(10));
260        assert!(view.agreement_flag().unwrap());
261    }
262
263    /// The flag has no geometric fallback: reading it wrong keeps the wrong
264    /// half, so both states must round-trip exactly.
265    #[test]
266    fn agreement_flag_preserves_both_states_without_defaulting() {
267        for expected in [true, false] {
268            let e = entity("IFCHALFSPACESOLID", vec![r(10), Value::Bool(expected)]);
269            assert_eq!(
270                HalfSpaceSolid::new(EntityId(1), &e)
271                    .agreement_flag()
272                    .unwrap(),
273                expected
274            );
275        }
276    }
277
278    /// A logical unknown is not a false; it must surface as an error rather
279    /// than silently selecting a side.
280    #[test]
281    fn a_logical_unknown_agreement_flag_is_an_error_not_a_false() {
282        let e = entity("IFCHALFSPACESOLID", vec![r(10), Value::LogicalUnknown]);
283        assert!(HalfSpaceSolid::new(EntityId(1), &e)
284            .agreement_flag()
285            .is_err());
286    }
287
288    /// The defining property of this whole module: an unbounded half space has
289    /// no finite body and must never reach a tessellator.
290    #[test]
291    fn a_plain_half_space_is_infinite_and_refuses_standalone_use() {
292        let e = entity("IFCHALFSPACESOLID", vec![r(10), Value::Bool(true)]);
293        let view = HalfSpaceSolid::new(EntityId(77), &e);
294        assert!(view.is_infinite());
295        assert!(!view.is_bounded());
296
297        let err = view.reject_standalone_use();
298        assert!(err.is_unsupported());
299        assert_eq!(err.entity(), Some(EntityId(77)));
300        assert!(err.to_string().contains("boolean operand"));
301    }
302
303    #[test]
304    fn the_bounded_subtypes_are_not_reported_as_infinite() {
305        for name in ["IFCBOXEDHALFSPACE", "IFCPOLYGONALBOUNDEDHALFSPACE"] {
306            let e = entity(name, vec![r(10), Value::Bool(true), r(20), r(30)]);
307            let view = HalfSpaceSolid::new(EntityId(1), &e);
308            assert!(view.is_bounded(), "{name}");
309            assert!(!view.is_infinite(), "{name}");
310        }
311    }
312
313    #[test]
314    fn boxed_half_space_enclosure_follows_the_inherited_pair() {
315        let e = entity("IFCBOXEDHALFSPACE", vec![r(10), Value::Bool(false), r(20)]);
316        let view = BoxedHalfSpace::new(EntityId(1), &e);
317        assert_eq!(view.base().base_surface().unwrap(), EntityId(10));
318        assert!(!view.base().agreement_flag().unwrap());
319        assert_eq!(view.enclosure().unwrap(), EntityId(20));
320    }
321
322    /// The verified layout from the schema: BaseSurface, AgreementFlag,
323    /// Position, PolygonalBoundary. Reading Position from slot 0 is the
324    /// local-index mistake that silently swaps a surface for a placement.
325    #[test]
326    fn polygonal_bounded_half_space_reads_position_at_two_and_boundary_at_three() {
327        let e = entity(
328            "IFCPOLYGONALBOUNDEDHALFSPACE",
329            vec![r(10), Value::Bool(true), r(20), r(30)],
330        );
331        let view = PolygonalBoundedHalfSpace::new(EntityId(1), &e);
332        assert_eq!(view.base().base_surface().unwrap(), EntityId(10));
333        assert!(view.base().agreement_flag().unwrap());
334        assert_eq!(view.position().unwrap(), EntityId(20));
335        assert_eq!(view.polygonal_boundary().unwrap(), EntityId(30));
336    }
337
338    /// The boundary is placed by Position, not by BaseSurface, so the two must
339    /// never collapse into one reference.
340    #[test]
341    fn boundary_placement_is_independent_of_the_base_surface() {
342        let e = entity(
343            "IFCPOLYGONALBOUNDEDHALFSPACE",
344            vec![r(10), Value::Bool(true), r(20), r(30)],
345        );
346        let view = PolygonalBoundedHalfSpace::new(EntityId(1), &e);
347        assert_ne!(
348            view.position().unwrap(),
349            view.base().base_surface().unwrap(),
350            "Position places the 2D boundary; BaseSurface divides space"
351        );
352    }
353
354    #[test]
355    fn a_polygonal_bounded_half_space_without_a_position_is_an_error() {
356        let e = entity(
357            "IFCPOLYGONALBOUNDEDHALFSPACE",
358            vec![r(10), Value::Bool(true)],
359        );
360        let view = PolygonalBoundedHalfSpace::new(EntityId(6), &e);
361        assert!(view.position().is_err());
362        assert!(view.polygonal_boundary().is_err());
363    }
364}