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}