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    /// Returned as a reference on purpose. `BaseSurface` is typed as the
98    /// abstract `IfcSurface`, so there is no single view to resolve into:
99    /// `lower::halfspace` dispatches on the concrete surface type (refusing
100    /// non-planar ones by name) and the typed views live in
101    /// [`crate::surface`].
102    pub fn base_surface(&self) -> GeometryResult<EntityId> {
103        self.slots.req_ref(slot::BASE_SURFACE, "BaseSurface")
104    }
105
106    /// Which side of `BaseSurface` is material.
107    ///
108    /// `true` means the solid lies on the side the surface normal points away
109    /// from. Inverting this silently cuts the wrong half away.
110    pub fn agreement_flag(&self) -> GeometryResult<bool> {
111        self.slots.req_bool(slot::AGREEMENT_FLAG, "AgreementFlag")
112    }
113
114    /// Is the solid unbounded in every direction?
115    ///
116    /// `true` for a plain `IfcHalfSpaceSolid`. `false` for the two bounded
117    /// subtypes, whose extra attributes give a finite region to work in.
118    ///
119    /// Note that even a `false` here does not make the entity meaningful on its
120    /// own: it is still only valid as a boolean operand. What changes is that a
121    /// consumer can build a finite body for it.
122    pub fn is_infinite(&self) -> bool {
123        !self.is_bounded()
124    }
125
126    /// Does the concrete type carry a bounding attribute?
127    pub fn is_bounded(&self) -> bool {
128        let name = self.type_name();
129        name.eq_ignore_ascii_case("IFCBOXEDHALFSPACE")
130            || name.eq_ignore_ascii_case("IFCPOLYGONALBOUNDEDHALFSPACE")
131    }
132
133    /// Reject use of this half space anywhere other than a boolean operand.
134    ///
135    /// Exists so the "cannot be tessellated" rule is a single call rather than
136    /// a comment every consumer is expected to have read. The error is
137    /// [`crate::GeometryError::Unsupported`], because the file is perfectly
138    /// valid -- it is the requested operation that is not.
139    pub fn reject_standalone_use(&self) -> crate::GeometryError {
140        self.slots
141            .unsupported("IfcHalfSpaceSolid is infinite and can only be used as a boolean operand")
142    }
143}
144
145/// `IfcBoxedHalfSpace`: a half space with a declared bounding box.
146///
147/// `Enclosure` is an `IfcBoundingBox` that bounds the half space, turning an
148/// infinite operand into a finite one a kernel can actually build. The schema
149/// additionally forbids `BaseSurface` from being an `IfcCurveBoundedPlane`
150/// here, so the base surface really is unbounded and the box is doing all the
151/// bounding.
152///
153/// The box is expressed in the coordinate system of the containing
154/// representation, not relative to `BaseSurface`.
155#[derive(Debug, Clone, Copy)]
156pub struct BoxedHalfSpace<'m> {
157    slots: Slots<'m>,
158}
159
160impl<'m> BoxedHalfSpace<'m> {
161    /// Wrap an entity assumed to be an `IfcBoxedHalfSpace`.
162    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
163        Self {
164            slots: Slots::new(id, entity),
165        }
166    }
167
168    /// The entity id.
169    pub fn id(&self) -> EntityId {
170        self.slots.id()
171    }
172
173    /// The inherited `IfcHalfSpaceSolid` attributes.
174    pub fn base(&self) -> HalfSpaceSolid<'m> {
175        HalfSpaceSolid { slots: self.slots }
176    }
177
178    /// The `IfcBoundingBox` reference that bounds the half space.
179    ///
180    /// See [`crate::solid::bbox::BoundingBox`] for reading it.
181    pub fn enclosure(&self) -> GeometryResult<EntityId> {
182        self.slots.req_ref(slot::ENCLOSURE, "Enclosure")
183    }
184}
185
186/// `IfcPolygonalBoundedHalfSpace`: a half space clipped by an extruded polygon.
187///
188/// # How the bounding actually works
189///
190/// This is the single most misread entity in IFC clipping, so, precisely:
191///
192/// 1. `Position` is an `IfcAxis2Placement3D` establishing a local coordinate
193///    system. It is **independent of `BaseSurface`**; the two need not share an
194///    origin or an orientation.
195/// 2. `PolygonalBoundary` is a **2D** bounded curve (an `IfcPolyline` or an
196///    `IfcCompositeCurve`, per the schema's `BoundaryType` rule) lying in the
197///    **XY plane of `Position`**. Its coordinates are 2D, so it is not a curve
198///    in world space and cannot be used without applying `Position`.
199/// 3. The bounding body is that polygon extruded **along +Z of `Position`**,
200///    unbounded in that direction.
201/// 4. The final solid is the intersection of that prism with the infinite half
202///    space defined by `BaseSurface` and `AgreementFlag`.
203///
204/// The consequence is that the polygon does not lie on the base surface and
205/// should never be projected onto it. Doing so produces a clip of roughly the
206/// right shape in roughly the wrong place, which is why bad wall openings look
207/// almost correct.
208#[derive(Debug, Clone, Copy)]
209pub struct PolygonalBoundedHalfSpace<'m> {
210    slots: Slots<'m>,
211}
212
213impl<'m> PolygonalBoundedHalfSpace<'m> {
214    /// Wrap an entity assumed to be an `IfcPolygonalBoundedHalfSpace`.
215    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
216        Self {
217            slots: Slots::new(id, entity),
218        }
219    }
220
221    /// The entity id.
222    pub fn id(&self) -> EntityId {
223        self.slots.id()
224    }
225
226    /// The inherited `IfcHalfSpaceSolid` attributes.
227    pub fn base(&self) -> HalfSpaceSolid<'m> {
228        HalfSpaceSolid { slots: self.slots }
229    }
230
231    /// The `IfcAxis2Placement3D` whose XY plane holds the boundary.
232    ///
233    /// Required here, unlike the optional `Position` on `IfcSweptAreaSolid`:
234    /// without it the 2D boundary cannot be placed at all.
235    pub fn position(&self) -> GeometryResult<EntityId> {
236        self.slots.req_ref(slot::POSITION, "Position")
237    }
238
239    /// The 2D `IfcBoundedCurve` reference bounding the half space.
240    ///
241    /// Its coordinates are in the XY plane of [`Self::position`]; the clipping
242    /// body is this curve extruded along that placement's +Z.
243    ///
244    /// Returned as a reference on purpose. The attribute is typed as the
245    /// abstract `IfcBoundedCurve`, so there is no single view to resolve
246    /// into: `lower::halfspace` dispatches on the concrete curve type
247    /// (refusing the families it does not lower by name) and the typed views
248    /// live in [`crate::curve`].
249    pub fn polygonal_boundary(&self) -> GeometryResult<EntityId> {
250        self.slots
251            .req_ref(slot::POLYGONAL_BOUNDARY, "PolygonalBoundary")
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::solid::testkit::{entity, r};
259    use ifc_model::Value;
260
261    #[test]
262    fn base_surface_and_agreement_flag_are_slots_zero_and_one() {
263        let e = entity("IFCHALFSPACESOLID", vec![r(10), Value::Bool(true)]);
264        let view = HalfSpaceSolid::new(EntityId(1), &e);
265        assert_eq!(view.base_surface().unwrap(), EntityId(10));
266        assert!(view.agreement_flag().unwrap());
267    }
268
269    /// The flag has no geometric fallback: reading it wrong keeps the wrong
270    /// half, so both states must round-trip exactly.
271    #[test]
272    fn agreement_flag_preserves_both_states_without_defaulting() {
273        for expected in [true, false] {
274            let e = entity("IFCHALFSPACESOLID", vec![r(10), Value::Bool(expected)]);
275            assert_eq!(
276                HalfSpaceSolid::new(EntityId(1), &e)
277                    .agreement_flag()
278                    .unwrap(),
279                expected
280            );
281        }
282    }
283
284    /// A logical unknown is not a false; it must surface as an error rather
285    /// than silently selecting a side.
286    #[test]
287    fn a_logical_unknown_agreement_flag_is_an_error_not_a_false() {
288        let e = entity("IFCHALFSPACESOLID", vec![r(10), Value::LogicalUnknown]);
289        assert!(HalfSpaceSolid::new(EntityId(1), &e)
290            .agreement_flag()
291            .is_err());
292    }
293
294    /// The defining property of this whole module: an unbounded half space has
295    /// no finite body and must never reach a tessellator.
296    #[test]
297    fn a_plain_half_space_is_infinite_and_refuses_standalone_use() {
298        let e = entity("IFCHALFSPACESOLID", vec![r(10), Value::Bool(true)]);
299        let view = HalfSpaceSolid::new(EntityId(77), &e);
300        assert!(view.is_infinite());
301        assert!(!view.is_bounded());
302
303        let err = view.reject_standalone_use();
304        assert!(err.is_unsupported());
305        assert_eq!(err.entity(), Some(EntityId(77)));
306        assert!(err.to_string().contains("boolean operand"));
307    }
308
309    #[test]
310    fn the_bounded_subtypes_are_not_reported_as_infinite() {
311        for name in ["IFCBOXEDHALFSPACE", "IFCPOLYGONALBOUNDEDHALFSPACE"] {
312            let e = entity(name, vec![r(10), Value::Bool(true), r(20), r(30)]);
313            let view = HalfSpaceSolid::new(EntityId(1), &e);
314            assert!(view.is_bounded(), "{name}");
315            assert!(!view.is_infinite(), "{name}");
316        }
317    }
318
319    #[test]
320    fn boxed_half_space_enclosure_follows_the_inherited_pair() {
321        let e = entity("IFCBOXEDHALFSPACE", vec![r(10), Value::Bool(false), r(20)]);
322        let view = BoxedHalfSpace::new(EntityId(1), &e);
323        assert_eq!(view.base().base_surface().unwrap(), EntityId(10));
324        assert!(!view.base().agreement_flag().unwrap());
325        assert_eq!(view.enclosure().unwrap(), EntityId(20));
326    }
327
328    /// The verified layout from the schema: BaseSurface, AgreementFlag,
329    /// Position, PolygonalBoundary. Reading Position from slot 0 is the
330    /// local-index mistake that silently swaps a surface for a placement.
331    #[test]
332    fn polygonal_bounded_half_space_reads_position_at_two_and_boundary_at_three() {
333        let e = entity(
334            "IFCPOLYGONALBOUNDEDHALFSPACE",
335            vec![r(10), Value::Bool(true), r(20), r(30)],
336        );
337        let view = PolygonalBoundedHalfSpace::new(EntityId(1), &e);
338        assert_eq!(view.base().base_surface().unwrap(), EntityId(10));
339        assert!(view.base().agreement_flag().unwrap());
340        assert_eq!(view.position().unwrap(), EntityId(20));
341        assert_eq!(view.polygonal_boundary().unwrap(), EntityId(30));
342    }
343
344    /// The boundary is placed by Position, not by BaseSurface, so the two must
345    /// never collapse into one reference.
346    #[test]
347    fn boundary_placement_is_independent_of_the_base_surface() {
348        let e = entity(
349            "IFCPOLYGONALBOUNDEDHALFSPACE",
350            vec![r(10), Value::Bool(true), r(20), r(30)],
351        );
352        let view = PolygonalBoundedHalfSpace::new(EntityId(1), &e);
353        assert_ne!(
354            view.position().unwrap(),
355            view.base().base_surface().unwrap(),
356            "Position places the 2D boundary; BaseSurface divides space"
357        );
358    }
359
360    #[test]
361    fn a_polygonal_bounded_half_space_without_a_position_is_an_error() {
362        let e = entity(
363            "IFCPOLYGONALBOUNDEDHALFSPACE",
364            vec![r(10), Value::Bool(true)],
365        );
366        let view = PolygonalBoundedHalfSpace::new(EntityId(6), &e);
367        assert!(view.position().is_err());
368        assert!(view.polygonal_boundary().is_err());
369    }
370}