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}