Skip to main content

ifc_geometry/surface/
bounded.rs

1//! `IfcBoundedSurface`: finite patches cut from infinite surfaces.
2//!
3//! Covers `IfcCurveBoundedPlane`, `IfcCurveBoundedSurface` and
4//! `IfcRectangularTrimmedSurface`.
5//!
6//! # The two ways to bound a surface
7//!
8//! **By curves.** `IfcCurveBoundedPlane` and `IfcCurveBoundedSurface` name
9//! boundary curves. The difference between them is subtle and load-bearing:
10//! the *plane* variant separates `OuterBoundary` from `InnerBoundaries`
11//! explicitly, so holes are unambiguous. The *surface* variant has a single
12//! `Boundaries` set and an `ImplicitOuter` flag; when that flag is true, no
13//! member is the outline and the surface's own natural parameter bounds serve
14//! as the outer boundary, making every listed boundary a hole. A consumer that
15//! treats the first member of `Boundaries` as the outline gets a hole-shaped
16//! patch instead of a patch with a hole.
17//!
18//! **By parameter range.** `IfcRectangularTrimmedSurface` cuts a rectangle in
19//! `(u, v)` space. Its `U1/U2` and `V1/V2` are *parameters*, which on a
20//! cylinder or sphere means angles, so a length unit scale applied to them is
21//! wrong. See [`crate::surface::elementary::ParameterKind`].
22//!
23//! # Why `Usense` and `Vsense` are not redundant
24//!
25//! On a closed (periodic) parameter direction, `U1 = 350deg` and `U2 = 10deg`
26//! describe two different patches: the 20-degree sliver or the 340-degree
27//! remainder. `Usense` picks which. IFC's own rule is that for a non-closed
28//! direction the sense must agree with `U1 < U2`, but for a closed one both
29//! orders are legal -- exactly the four-arc problem of `IfcTrimmedCurve`,
30//! transposed to surfaces. Nothing here reorders the bounds.
31
32use crate::error::GeometryResult;
33use crate::slots::Slots;
34use ifc_model::{Entity, EntityId};
35
36/// `IfcCurveBoundedPlane` attribute slots, from IFC4 ADD2 TC1.
37pub(crate) mod plane_slot {
38    /// `BasisSurface`: an `IfcPlane`, not a general surface.
39    pub const BASIS_SURFACE: usize = 0;
40    /// `OuterBoundary`: the outline curve.
41    pub const OUTER_BOUNDARY: usize = 1;
42    /// `InnerBoundaries`: `SET [0:?] OF IfcCurve`, the holes.
43    pub const INNER_BOUNDARIES: usize = 2;
44}
45
46/// `IfcCurveBoundedSurface` attribute slots, from IFC4 ADD2 TC1.
47pub(crate) mod surface_slot {
48    /// `BasisSurface`: any `IfcSurface`.
49    pub const BASIS_SURFACE: usize = 0;
50    /// `Boundaries`: `SET [1:?] OF IfcBoundaryCurve`.
51    pub const BOUNDARIES: usize = 1;
52    /// `ImplicitOuter`: whether the outer bound is the surface's own extent.
53    pub const IMPLICIT_OUTER: usize = 2;
54}
55
56/// `IfcRectangularTrimmedSurface` attribute slots, from IFC4 ADD2 TC1.
57pub(crate) mod trimmed_slot {
58    /// `BasisSurface`: the surface being trimmed.
59    pub const BASIS_SURFACE: usize = 0;
60    /// `U1`: first u parameter.
61    pub const U1: usize = 1;
62    /// `V1`: first v parameter.
63    pub const V1: usize = 2;
64    /// `U2`: second u parameter.
65    pub const U2: usize = 3;
66    /// `V2`: second v parameter.
67    pub const V2: usize = 4;
68    /// `Usense`: does u run in increasing parameter order?
69    pub const USENSE: usize = 5;
70    /// `Vsense`: does v run in increasing parameter order?
71    pub const VSENSE: usize = 6;
72}
73
74/// A borrowed view of an `IfcCurveBoundedPlane`.
75#[derive(Debug, Clone, Copy)]
76pub struct CurveBoundedPlane<'m> {
77    slots: Slots<'m>,
78}
79
80impl<'m> CurveBoundedPlane<'m> {
81    /// Wrap an entity known to be an `IfcCurveBoundedPlane`.
82    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
83        Self {
84            slots: Slots::new(id, entity),
85        }
86    }
87
88    /// The entity id.
89    pub fn id(&self) -> EntityId {
90        self.slots.id()
91    }
92
93    /// The `IfcPlane` this patch lies in.
94    ///
95    /// Narrower than `IfcCurveBoundedSurface::BasisSurface`: the schema
96    /// requires a plane here, so boundary curves are genuinely 2D.
97    pub fn basis_surface_ref(&self) -> GeometryResult<EntityId> {
98        self.slots
99            .req_ref(plane_slot::BASIS_SURFACE, "BasisSurface")
100    }
101
102    /// The outline curve.
103    ///
104    /// Required and unambiguous, unlike the `IfcCurveBoundedSurface` case.
105    pub fn outer_boundary_ref(&self) -> GeometryResult<EntityId> {
106        self.slots
107            .req_ref(plane_slot::OUTER_BOUNDARY, "OuterBoundary")
108    }
109
110    /// The hole curves; empty when the patch is solid.
111    ///
112    /// `SET [0:?]`, so an empty set and an absent attribute mean the same
113    /// thing and neither is an error.
114    pub fn inner_boundary_refs(&self) -> Vec<EntityId> {
115        self.slots.opt_ref_list(plane_slot::INNER_BOUNDARIES)
116    }
117}
118
119/// A borrowed view of an `IfcCurveBoundedSurface`.
120#[derive(Debug, Clone, Copy)]
121pub struct CurveBoundedSurface<'m> {
122    slots: Slots<'m>,
123}
124
125impl<'m> CurveBoundedSurface<'m> {
126    /// Wrap an entity known to be an `IfcCurveBoundedSurface`.
127    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
128        Self {
129            slots: Slots::new(id, entity),
130        }
131    }
132
133    /// The entity id.
134    pub fn id(&self) -> EntityId {
135        self.slots.id()
136    }
137
138    /// The surface being bounded; any `IfcSurface`.
139    pub fn basis_surface_ref(&self) -> GeometryResult<EntityId> {
140        self.slots
141            .req_ref(surface_slot::BASIS_SURFACE, "BasisSurface")
142    }
143
144    /// The boundary curves, at least one.
145    ///
146    /// These are `IfcBoundaryCurve` entities, so
147    /// [`crate::curve::CompositeCurve::is_outer_boundary`] can tell an
148    /// `IfcOuterBoundaryCurve` from a plain one -- which is how the outline is
149    /// identified when [`Self::implicit_outer`] is false.
150    pub fn boundary_refs(&self) -> GeometryResult<Vec<EntityId>> {
151        let boundaries = self
152            .slots
153            .req_ref_list(surface_slot::BOUNDARIES, "Boundaries")?;
154        if boundaries.is_empty() {
155            return Err(self
156                .slots
157                .degenerate("Boundaries is empty; SET [1:?] requires a member"));
158        }
159        Ok(boundaries)
160    }
161
162    /// Is the outer boundary the surface's own natural extent?
163    ///
164    /// When true, **every** member of `Boundaries` is a hole and none is the
165    /// outline. Defaults to `false` when absent, matching the more common and
166    /// more conservative reading: an explicit outline is expected among the
167    /// boundaries.
168    pub fn implicit_outer(&self) -> bool {
169        self.slots
170            .opt_bool(surface_slot::IMPLICIT_OUTER)
171            .unwrap_or(false)
172    }
173}
174
175/// The parameter rectangle cut from a surface, with senses intact.
176///
177/// Kept as a struct rather than four loose numbers because the sense flags are
178/// meaningless without the bounds they qualify, and a caller that fetches the
179/// bounds without the senses will build the complementary patch on a periodic
180/// surface.
181#[derive(Debug, Clone, Copy, PartialEq)]
182pub struct TrimRectangle {
183    /// First u parameter, as written.
184    pub u1: f64,
185    /// First v parameter, as written.
186    pub v1: f64,
187    /// Second u parameter, as written.
188    pub u2: f64,
189    /// Second v parameter, as written.
190    pub v2: f64,
191    /// Does u run from `u1` to `u2` in increasing parameter order?
192    pub usense: bool,
193    /// Does v run from `v1` to `v2` in increasing parameter order?
194    pub vsense: bool,
195}
196
197impl TrimRectangle {
198    /// Does the u range wrap through the surface's period?
199    ///
200    /// True when `u1 > u2` while `usense` claims increasing order, which is
201    /// only consistent on a closed parameter direction. Useful as a check: a
202    /// file asserting this for a plane is inconsistent, and a kernel that
203    /// clamps rather than wraps will produce an empty patch.
204    pub fn u_wraps(&self) -> bool {
205        self.usense == (self.u1 > self.u2)
206    }
207
208    /// Does the v range wrap through the surface's period?
209    pub fn v_wraps(&self) -> bool {
210        self.vsense == (self.v1 > self.v2)
211    }
212}
213
214/// A borrowed view of an `IfcRectangularTrimmedSurface`.
215#[derive(Debug, Clone, Copy)]
216pub struct RectangularTrimmedSurface<'m> {
217    slots: Slots<'m>,
218}
219
220impl<'m> RectangularTrimmedSurface<'m> {
221    /// Wrap an entity known to be an `IfcRectangularTrimmedSurface`.
222    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
223        Self {
224            slots: Slots::new(id, entity),
225        }
226    }
227
228    /// The entity id.
229    pub fn id(&self) -> EntityId {
230        self.slots.id()
231    }
232
233    /// The surface being trimmed.
234    pub fn basis_surface_ref(&self) -> GeometryResult<EntityId> {
235        self.slots
236            .req_ref(trimmed_slot::BASIS_SURFACE, "BasisSurface")
237    }
238
239    /// The full parameter rectangle including both sense flags.
240    ///
241    /// Rejects a zero-extent rectangle in either direction: `u1 == u2` gives a
242    /// patch with no area, which reaches a mesher as a degenerate face rather
243    /// than as an error.
244    pub fn rectangle(&self) -> GeometryResult<TrimRectangle> {
245        let u1 = self.slots.req_f64(trimmed_slot::U1, "U1")?;
246        let v1 = self.slots.req_f64(trimmed_slot::V1, "V1")?;
247        let u2 = self.slots.req_f64(trimmed_slot::U2, "U2")?;
248        let v2 = self.slots.req_f64(trimmed_slot::V2, "V2")?;
249
250        if u1 == u2 {
251            return Err(self
252                .slots
253                .degenerate(format!("U1 and U2 are both {u1}; the patch has no extent")));
254        }
255        if v1 == v2 {
256            return Err(self
257                .slots
258                .degenerate(format!("V1 and V2 are both {v1}; the patch has no extent")));
259        }
260
261        Ok(TrimRectangle {
262            u1,
263            v1,
264            u2,
265            v2,
266            usense: self.slots.req_bool(trimmed_slot::USENSE, "Usense")?,
267            vsense: self.slots.req_bool(trimmed_slot::VSENSE, "Vsense")?,
268        })
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use ifc_model::Value;
276
277    fn refs(ids: &[u64]) -> Value {
278        Value::List(ids.iter().map(|i| Value::Ref(EntityId(*i))).collect())
279    }
280
281    fn trimmed(u1: f64, v1: f64, u2: f64, v2: f64, usense: bool, vsense: bool) -> Entity {
282        Entity::new(
283            "IFCRECTANGULARTRIMMEDSURFACE",
284            vec![
285                Value::Ref(EntityId(100)),
286                Value::Real(u1),
287                Value::Real(v1),
288                Value::Real(u2),
289                Value::Real(v2),
290                Value::Bool(usense),
291                Value::Bool(vsense),
292            ],
293        )
294    }
295
296    #[test]
297    fn a_curve_bounded_plane_separates_its_outline_from_its_holes() {
298        let e = Entity::new(
299            "IFCCURVEBOUNDEDPLANE",
300            vec![
301                Value::Ref(EntityId(100)),
302                Value::Ref(EntityId(101)),
303                refs(&[102, 103]),
304            ],
305        );
306        let view = CurveBoundedPlane::new(EntityId(1), &e);
307        assert_eq!(view.basis_surface_ref().unwrap(), EntityId(100));
308        assert_eq!(view.outer_boundary_ref().unwrap(), EntityId(101));
309        assert_eq!(
310            view.inner_boundary_refs(),
311            vec![EntityId(102), EntityId(103)]
312        );
313    }
314
315    /// SET [0:?]: no holes is normal, not a missing attribute.
316    #[test]
317    fn a_plane_with_no_holes_reports_an_empty_inner_boundary_list() {
318        let e = Entity::new(
319            "IFCCURVEBOUNDEDPLANE",
320            vec![
321                Value::Ref(EntityId(100)),
322                Value::Ref(EntityId(101)),
323                Value::List(vec![]),
324            ],
325        );
326        assert!(CurveBoundedPlane::new(EntityId(1), &e)
327            .inner_boundary_refs()
328            .is_empty());
329
330        let absent = Entity::new(
331            "IFCCURVEBOUNDEDPLANE",
332            vec![Value::Ref(EntityId(100)), Value::Ref(EntityId(101))],
333        );
334        assert!(CurveBoundedPlane::new(EntityId(1), &absent)
335            .inner_boundary_refs()
336            .is_empty());
337    }
338
339    /// With ImplicitOuter true, every listed boundary is a hole; reading the
340    /// first as an outline inverts the patch.
341    #[test]
342    fn implicit_outer_makes_every_listed_boundary_a_hole() {
343        let implicit = Entity::new(
344            "IFCCURVEBOUNDEDSURFACE",
345            vec![
346                Value::Ref(EntityId(100)),
347                refs(&[101, 102]),
348                Value::Bool(true),
349            ],
350        );
351        let view = CurveBoundedSurface::new(EntityId(1), &implicit);
352        assert!(view.implicit_outer());
353        assert_eq!(view.boundary_refs().unwrap().len(), 2);
354
355        let explicit = Entity::new(
356            "IFCCURVEBOUNDEDSURFACE",
357            vec![
358                Value::Ref(EntityId(100)),
359                refs(&[101, 102]),
360                Value::Bool(false),
361            ],
362        );
363        assert!(!CurveBoundedSurface::new(EntityId(1), &explicit).implicit_outer());
364    }
365
366    /// The conservative default: expect an explicit outline among the
367    /// boundaries rather than silently turning the outline into a hole.
368    #[test]
369    fn an_absent_implicit_outer_defaults_to_false() {
370        let e = Entity::new(
371            "IFCCURVEBOUNDEDSURFACE",
372            vec![Value::Ref(EntityId(100)), refs(&[101])],
373        );
374        assert!(!CurveBoundedSurface::new(EntityId(1), &e).implicit_outer());
375    }
376
377    #[test]
378    fn a_curve_bounded_surface_with_no_boundaries_is_degenerate() {
379        let e = Entity::new(
380            "IFCCURVEBOUNDEDSURFACE",
381            vec![Value::Ref(EntityId(100)), Value::List(vec![])],
382        );
383        assert!(CurveBoundedSurface::new(EntityId(1), &e)
384            .boundary_refs()
385            .is_err());
386    }
387
388    /// U1,V1,U2,V2 is the declaration order; reading it as U1,U2,V1,V2 would
389    /// swap a patch's width for its height and still typecheck.
390    #[test]
391    fn trim_parameters_are_read_in_u1_v1_u2_v2_declaration_order() {
392        let e = trimmed(0.0, 1.0, 2.0, 3.0, true, true);
393        let rect = RectangularTrimmedSurface::new(EntityId(1), &e)
394            .rectangle()
395            .unwrap();
396        assert_eq!(rect.u1, 0.0);
397        assert_eq!(rect.v1, 1.0);
398        assert_eq!(rect.u2, 2.0);
399        assert_eq!(rect.v2, 3.0);
400    }
401
402    /// Descending bounds on a periodic direction are legal and select the
403    /// complementary patch, so they must not be sorted.
404    #[test]
405    fn descending_trim_bounds_are_preserved_not_normalised() {
406        let e = trimmed(350.0, 0.0, 10.0, 1.0, true, true);
407        let rect = RectangularTrimmedSurface::new(EntityId(1), &e)
408            .rectangle()
409            .unwrap();
410        assert_eq!(rect.u1, 350.0);
411        assert_eq!(rect.u2, 10.0);
412        assert!(rect.u_wraps());
413        assert!(!rect.v_wraps());
414    }
415
416    /// Sense flags distinguish patches that share their bounds, so flipping
417    /// one must produce a different rectangle.
418    #[test]
419    fn the_sense_flags_distinguish_otherwise_identical_rectangles() {
420        let a = trimmed(0.0, 0.0, 90.0, 1.0, true, true);
421        let b = trimmed(0.0, 0.0, 90.0, 1.0, false, true);
422        let rect_a = RectangularTrimmedSurface::new(EntityId(1), &a)
423            .rectangle()
424            .unwrap();
425        let rect_b = RectangularTrimmedSurface::new(EntityId(1), &b)
426            .rectangle()
427            .unwrap();
428        assert_ne!(rect_a, rect_b);
429        assert!(!rect_a.u_wraps());
430        assert!(rect_b.u_wraps());
431    }
432
433    #[test]
434    fn a_zero_extent_trim_rectangle_is_degenerate_and_names_the_direction() {
435        let flat_u = trimmed(1.0, 0.0, 1.0, 2.0, true, true);
436        let err = RectangularTrimmedSurface::new(EntityId(4), &flat_u)
437            .rectangle()
438            .unwrap_err();
439        assert!(err.to_string().contains("U1 and U2"), "got: {err}");
440
441        let flat_v = trimmed(0.0, 5.0, 1.0, 5.0, true, true);
442        let err = RectangularTrimmedSurface::new(EntityId(4), &flat_v)
443            .rectangle()
444            .unwrap_err();
445        assert!(err.to_string().contains("V1 and V2"), "got: {err}");
446    }
447
448    #[test]
449    fn trim_parameters_read_through_parameter_value_wrappers() {
450        let e = Entity::new(
451            "IFCRECTANGULARTRIMMEDSURFACE",
452            vec![
453                Value::Ref(EntityId(100)),
454                Value::Typed {
455                    type_name: "IFCPARAMETERVALUE".into(),
456                    value: Box::new(Value::Real(0.0)),
457                },
458                Value::Real(0.0),
459                Value::Typed {
460                    type_name: "IFCPARAMETERVALUE".into(),
461                    value: Box::new(Value::Real(1.0)),
462                },
463                Value::Real(1.0),
464                Value::Bool(true),
465                Value::Bool(true),
466            ],
467        );
468        let rect = RectangularTrimmedSurface::new(EntityId(1), &e)
469            .rectangle()
470            .unwrap();
471        assert_eq!(rect.u2, 1.0);
472    }
473}