Skip to main content

ifc_geometry/surface/
elementary.rs

1//! `IfcElementarySurface` subtypes: plane, cylinder, sphere, torus.
2//!
3//! # What they share
4//!
5//! Exactly one inherited attribute, `Position`, an `IfcAxis2Placement3D`.
6//! Unlike `IfcConic::Position` this is *not* a select: an elementary surface
7//! is always placed in 3D. The placement is not decoration -- it defines the
8//! surface's parameter space, so two cylinders with the same radius and
9//! different placements have different `(u, v)` meanings and their p-curves
10//! are not interchangeable.
11//!
12//! # Parameterisation, and why it decides unit handling
13//!
14//! | Surface | u | v |
15//! | --- | --- | --- |
16//! | `IfcPlane` | length along local X | length along local Y |
17//! | `IfcCylindricalSurface` | **angle** about local Z | length along local Z |
18//! | `IfcSphericalSurface` | **angle** about local Z | **angle** from equator |
19//! | `IfcToroidalSurface` | **angle** about local Z | **angle** about the tube |
20//!
21//! A consumer that scales every `IfcPcurve` coordinate by the model's length
22//! unit will corrupt every non-planar surface: on a cylinder in millimetres it
23//! multiplies an angle by 0.001. [`ParameterKind`] exists so that decision can
24//! be made from data rather than from a comment.
25
26use crate::error::GeometryResult;
27use crate::resource::placement::Axis2Placement3D;
28use crate::resource::resolve;
29use crate::slots::Slots;
30use ifc_model::{Entity, EntityId, Model};
31
32/// `IfcElementarySurface` family attribute slots.
33///
34/// From IFC4 ADD2 TC1: slot 0 `Position` is inherited from
35/// `IfcElementarySurface` by all four subtypes; radii follow from slot 1.
36pub(crate) mod slot {
37    /// `Position`: `IfcAxis2Placement3D`, from `IfcElementarySurface`.
38    pub const POSITION: usize = 0;
39    /// `Radius` on `IfcCylindricalSurface` and `IfcSphericalSurface`.
40    pub const RADIUS: usize = 1;
41    /// `MajorRadius` on `IfcToroidalSurface`: centre to tube centre.
42    pub const MAJOR_RADIUS: usize = 1;
43    /// `MinorRadius` on `IfcToroidalSurface`: the tube's own radius.
44    pub const MINOR_RADIUS: usize = 2;
45}
46
47/// What a surface parameter means, and therefore how to convert it.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ParameterKind {
50    /// A distance in the model's length unit.
51    Length,
52    /// An angle in the model's plane-angle unit, which may be degrees.
53    Angle,
54}
55
56/// A borrowed view of an `IfcPlane`.
57///
58/// Infinite in both parameters. A file that means a finite patch wraps this in
59/// an `IfcCurveBoundedPlane` or an `IfcRectangularTrimmedSurface`; a consumer
60/// that renders a bare `IfcPlane` will fill the world.
61#[derive(Debug, Clone, Copy)]
62pub struct Plane<'m> {
63    slots: Slots<'m>,
64}
65
66impl<'m> Plane<'m> {
67    /// Wrap an entity known to be an `IfcPlane`.
68    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
69        Self {
70            slots: Slots::new(id, entity),
71        }
72    }
73
74    /// The entity id.
75    pub fn id(&self) -> EntityId {
76        self.slots.id()
77    }
78
79    /// The `IfcAxis2Placement3D` whose Z axis is the plane normal.
80    ///
81    /// The normal direction matters beyond orientation: it decides which side
82    /// of a half-space solid is solid.
83    pub fn position_ref(&self) -> GeometryResult<EntityId> {
84        self.slots.req_ref(slot::POSITION, "Position")
85    }
86
87    /// The placement as a typed view, resolved from the model.
88    ///
89    /// [`Self::position_ref`] returns the raw reference; this resolves it
90    /// so callers read location/axis/RefDirection without re-entering the
91    /// model themselves. A dangling reference is `MissingEntity` naming this
92    /// surface; any target other than an `IfcAxis2Placement3D` is
93    /// `WrongEntityType` naming the target.
94    pub fn position<'v>(&self, model: &'v Model) -> GeometryResult<Axis2Placement3D<'v>> {
95        resolve::axis2_placement_3d(model, self.id(), self.position_ref()?)
96    }
97
98    /// Both parameters of a plane are lengths.
99    pub fn parameter_kinds(&self) -> (ParameterKind, ParameterKind) {
100        (ParameterKind::Length, ParameterKind::Length)
101    }
102}
103
104/// A borrowed view of an `IfcCylindricalSurface`.
105#[derive(Debug, Clone, Copy)]
106pub struct CylindricalSurface<'m> {
107    slots: Slots<'m>,
108}
109
110impl<'m> CylindricalSurface<'m> {
111    /// Wrap an entity known to be an `IfcCylindricalSurface`.
112    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
113        Self {
114            slots: Slots::new(id, entity),
115        }
116    }
117
118    /// The entity id.
119    pub fn id(&self) -> EntityId {
120        self.slots.id()
121    }
122
123    /// The placement; local Z is the cylinder axis.
124    pub fn position_ref(&self) -> GeometryResult<EntityId> {
125        self.slots.req_ref(slot::POSITION, "Position")
126    }
127
128    /// The placement as a typed view, resolved from the model.
129    ///
130    /// [`Self::position_ref`] returns the raw reference; this resolves it
131    /// so callers read location/axis/RefDirection without re-entering the
132    /// model themselves. A dangling reference is `MissingEntity` naming this
133    /// surface; any target other than an `IfcAxis2Placement3D` is
134    /// `WrongEntityType` naming the target.
135    pub fn position<'v>(&self, model: &'v Model) -> GeometryResult<Axis2Placement3D<'v>> {
136        resolve::axis2_placement_3d(model, self.id(), self.position_ref()?)
137    }
138
139    /// The radius, guaranteed positive.
140    pub fn radius(&self) -> GeometryResult<f64> {
141        positive(&self.slots, slot::RADIUS, "Radius")
142    }
143
144    /// `u` is an angle about the axis, `v` a length along it.
145    pub fn parameter_kinds(&self) -> (ParameterKind, ParameterKind) {
146        (ParameterKind::Angle, ParameterKind::Length)
147    }
148}
149
150/// A borrowed view of an `IfcSphericalSurface`.
151#[derive(Debug, Clone, Copy)]
152pub struct SphericalSurface<'m> {
153    slots: Slots<'m>,
154}
155
156impl<'m> SphericalSurface<'m> {
157    /// Wrap an entity known to be an `IfcSphericalSurface`.
158    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
159        Self {
160            slots: Slots::new(id, entity),
161        }
162    }
163
164    /// The entity id.
165    pub fn id(&self) -> EntityId {
166        self.slots.id()
167    }
168
169    /// The placement; local Z runs through the poles.
170    pub fn position_ref(&self) -> GeometryResult<EntityId> {
171        self.slots.req_ref(slot::POSITION, "Position")
172    }
173
174    /// The placement as a typed view, resolved from the model.
175    ///
176    /// [`Self::position_ref`] returns the raw reference; this resolves it
177    /// so callers read location/axis/RefDirection without re-entering the
178    /// model themselves. A dangling reference is `MissingEntity` naming this
179    /// surface; any target other than an `IfcAxis2Placement3D` is
180    /// `WrongEntityType` naming the target.
181    pub fn position<'v>(&self, model: &'v Model) -> GeometryResult<Axis2Placement3D<'v>> {
182        resolve::axis2_placement_3d(model, self.id(), self.position_ref()?)
183    }
184
185    /// The radius, guaranteed positive.
186    pub fn radius(&self) -> GeometryResult<f64> {
187        positive(&self.slots, slot::RADIUS, "Radius")
188    }
189
190    /// Both parameters are angles: longitude and latitude.
191    pub fn parameter_kinds(&self) -> (ParameterKind, ParameterKind) {
192        (ParameterKind::Angle, ParameterKind::Angle)
193    }
194}
195
196/// A borrowed view of an `IfcToroidalSurface`.
197#[derive(Debug, Clone, Copy)]
198pub struct ToroidalSurface<'m> {
199    slots: Slots<'m>,
200}
201
202impl<'m> ToroidalSurface<'m> {
203    /// Wrap an entity known to be an `IfcToroidalSurface`.
204    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
205        Self {
206            slots: Slots::new(id, entity),
207        }
208    }
209
210    /// The entity id.
211    pub fn id(&self) -> EntityId {
212        self.slots.id()
213    }
214
215    /// The placement; local Z is the torus axis.
216    pub fn position_ref(&self) -> GeometryResult<EntityId> {
217        self.slots.req_ref(slot::POSITION, "Position")
218    }
219
220    /// The placement as a typed view, resolved from the model.
221    ///
222    /// [`Self::position_ref`] returns the raw reference; this resolves it
223    /// so callers read location/axis/RefDirection without re-entering the
224    /// model themselves. A dangling reference is `MissingEntity` naming this
225    /// surface; any target other than an `IfcAxis2Placement3D` is
226    /// `WrongEntityType` naming the target.
227    pub fn position<'v>(&self, model: &'v Model) -> GeometryResult<Axis2Placement3D<'v>> {
228        resolve::axis2_placement_3d(model, self.id(), self.position_ref()?)
229    }
230
231    /// Distance from the torus centre to the centre of the tube.
232    pub fn major_radius(&self) -> GeometryResult<f64> {
233        positive(&self.slots, slot::MAJOR_RADIUS, "MajorRadius")
234    }
235
236    /// The tube's own radius.
237    pub fn minor_radius(&self) -> GeometryResult<f64> {
238        positive(&self.slots, slot::MINOR_RADIUS, "MinorRadius")
239    }
240
241    /// Is the tube radius at least the major radius?
242    ///
243    /// IFC permits it and it is not an error: `minor >= major` gives a
244    /// self-intersecting "spindle" or "apple" torus rather than a ring. Worth
245    /// asking because a kernel that assumes a ring topology will produce
246    /// inverted normals on the inner surface, and because it is far more often
247    /// a units mistake in the file than a deliberate shape.
248    pub fn is_self_intersecting(&self) -> GeometryResult<bool> {
249        Ok(self.minor_radius()? >= self.major_radius()?)
250    }
251
252    /// Both parameters are angles: around the axis and around the tube.
253    pub fn parameter_kinds(&self) -> (ParameterKind, ParameterKind) {
254        (ParameterKind::Angle, ParameterKind::Angle)
255    }
256}
257
258/// Read a radius that the schema declares `IfcPositiveLengthMeasure`.
259///
260/// STEP does not enforce constrained types, and zero radii reach a kernel as
261/// divisions by zero on a surface thousands of entities from the cause.
262fn positive(slots: &Slots<'_>, index: usize, name: &'static str) -> GeometryResult<f64> {
263    let value = slots.req_f64(index, name)?;
264    if value > 0.0 {
265        Ok(value)
266    } else {
267        Err(slots.degenerate(format!("{name} must be positive, found {value}")))
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use ifc_model::Value;
275
276    fn surface(type_name: &str, radii: &[f64]) -> Entity {
277        let mut attributes = vec![Value::Ref(EntityId(70))];
278        attributes.extend(radii.iter().map(|r| Value::Real(*r)));
279        Entity::new(type_name, attributes)
280    }
281
282    #[test]
283    fn every_elementary_surface_reads_position_from_the_inherited_slot_zero() {
284        let plane = surface("IFCPLANE", &[]);
285        assert_eq!(
286            Plane::new(EntityId(1), &plane).position_ref().unwrap(),
287            EntityId(70)
288        );
289
290        let cylinder = surface("IFCCYLINDRICALSURFACE", &[2.0]);
291        assert_eq!(
292            CylindricalSurface::new(EntityId(1), &cylinder)
293                .position_ref()
294                .unwrap(),
295            EntityId(70)
296        );
297
298        let sphere = surface("IFCSPHERICALSURFACE", &[3.0]);
299        assert_eq!(
300            SphericalSurface::new(EntityId(1), &sphere)
301                .position_ref()
302                .unwrap(),
303            EntityId(70)
304        );
305
306        let torus = surface("IFCTOROIDALSURFACE", &[5.0, 1.0]);
307        assert_eq!(
308            ToroidalSurface::new(EntityId(1), &torus)
309                .position_ref()
310                .unwrap(),
311            EntityId(70)
312        );
313    }
314
315    #[test]
316    fn cylinder_and_sphere_radii_are_read_from_the_slot_after_position() {
317        let cylinder = surface("IFCCYLINDRICALSURFACE", &[2.5]);
318        assert_eq!(
319            CylindricalSurface::new(EntityId(1), &cylinder)
320                .radius()
321                .unwrap(),
322            2.5
323        );
324        let sphere = surface("IFCSPHERICALSURFACE", &[4.0]);
325        assert_eq!(
326            SphericalSurface::new(EntityId(1), &sphere)
327                .radius()
328                .unwrap(),
329            4.0
330        );
331    }
332
333    #[test]
334    fn torus_radii_are_read_in_major_then_minor_order() {
335        let e = surface("IFCTOROIDALSURFACE", &[5.0, 1.0]);
336        let view = ToroidalSurface::new(EntityId(1), &e);
337        assert_eq!(view.major_radius().unwrap(), 5.0);
338        assert_eq!(view.minor_radius().unwrap(), 1.0);
339        assert!(!view.is_self_intersecting().unwrap());
340    }
341
342    /// A spindle torus is legal IFC and usually a units bug, so it is flagged
343    /// rather than rejected.
344    #[test]
345    fn a_minor_radius_at_least_the_major_is_reported_as_self_intersecting() {
346        let e = surface("IFCTOROIDALSURFACE", &[1.0, 2.0]);
347        assert!(ToroidalSurface::new(EntityId(1), &e)
348            .is_self_intersecting()
349            .unwrap());
350    }
351
352    #[test]
353    fn a_zero_radius_surface_is_degenerate_and_names_the_attribute() {
354        let cylinder = surface("IFCCYLINDRICALSURFACE", &[0.0]);
355        let err = CylindricalSurface::new(EntityId(8), &cylinder)
356            .radius()
357            .unwrap_err();
358        assert!(err.to_string().contains("#8"), "got: {err}");
359        assert!(err.to_string().contains("Radius"), "got: {err}");
360
361        let torus = surface("IFCTOROIDALSURFACE", &[5.0, -1.0]);
362        let err = ToroidalSurface::new(EntityId(1), &torus)
363            .minor_radius()
364            .unwrap_err();
365        assert!(err.to_string().contains("MinorRadius"), "got: {err}");
366    }
367
368    /// Scaling an angular surface parameter by a length unit is a silent
369    /// corruption, so each surface states which of its parameters are angles.
370    #[test]
371    fn only_the_plane_has_two_length_parameters() {
372        let plane = surface("IFCPLANE", &[]);
373        assert_eq!(
374            Plane::new(EntityId(1), &plane).parameter_kinds(),
375            (ParameterKind::Length, ParameterKind::Length)
376        );
377
378        let cylinder = surface("IFCCYLINDRICALSURFACE", &[1.0]);
379        assert_eq!(
380            CylindricalSurface::new(EntityId(1), &cylinder).parameter_kinds(),
381            (ParameterKind::Angle, ParameterKind::Length)
382        );
383
384        let sphere = surface("IFCSPHERICALSURFACE", &[1.0]);
385        assert_eq!(
386            SphericalSurface::new(EntityId(1), &sphere).parameter_kinds(),
387            (ParameterKind::Angle, ParameterKind::Angle)
388        );
389
390        let torus = surface("IFCTOROIDALSURFACE", &[5.0, 1.0]);
391        assert_eq!(
392            ToroidalSurface::new(EntityId(1), &torus).parameter_kinds(),
393            (ParameterKind::Angle, ParameterKind::Angle)
394        );
395    }
396
397    #[test]
398    fn a_surface_missing_its_position_reports_the_attribute_by_name() {
399        let e = Entity::new("IFCPLANE", vec![]);
400        let err = Plane::new(EntityId(1), &e).position_ref().unwrap_err();
401        assert!(err.to_string().contains("Position"), "got: {err}");
402    }
403
404    /// All four elementary surfaces resolve their placement to a typed view.
405    ///
406    /// The accessor must read the same location the raw reference points at,
407    /// for every surface family -- a wrong slot would silently relocate one.
408    #[test]
409    fn every_elementary_surface_resolves_a_typed_placement_view() {
410        let mut model = Model::new();
411        model.insert(
412            EntityId(60),
413            Entity::new(
414                "IFCCARTESIANPOINT",
415                vec![Value::List(vec![
416                    Value::Real(1.0),
417                    Value::Real(2.0),
418                    Value::Real(3.0),
419                ])],
420            ),
421        );
422        model.insert(
423            EntityId(70),
424            Entity::new("IFCAXIS2PLACEMENT3D", vec![Value::Ref(EntityId(60))]),
425        );
426
427        let plane = surface("IFCPLANE", &[]);
428        let view = Plane::new(EntityId(1), &plane).position(&model).unwrap();
429        assert_eq!(view.id(), EntityId(70));
430        assert_eq!(view.location(&model).unwrap(), [1.0, 2.0, 3.0]);
431
432        let cylinder = surface("IFCCYLINDRICALSURFACE", &[2.0]);
433        let view = CylindricalSurface::new(EntityId(2), &cylinder)
434            .position(&model)
435            .unwrap();
436        assert_eq!(view.location(&model).unwrap(), [1.0, 2.0, 3.0]);
437
438        let sphere = surface("IFCSPHERICALSURFACE", &[3.0]);
439        let view = SphericalSurface::new(EntityId(3), &sphere)
440            .position(&model)
441            .unwrap();
442        assert_eq!(view.location(&model).unwrap(), [1.0, 2.0, 3.0]);
443
444        let torus = surface("IFCTOROIDALSURFACE", &[5.0, 1.0]);
445        let view = ToroidalSurface::new(EntityId(4), &torus)
446            .position(&model)
447            .unwrap();
448        assert_eq!(view.location(&model).unwrap(), [1.0, 2.0, 3.0]);
449    }
450
451    /// A placement reference to an absent entity is reported, not panicked on.
452    #[test]
453    fn a_dangling_placement_reference_is_reported() {
454        let model = Model::new();
455        let plane = surface("IFCPLANE", &[]);
456        let error = Plane::new(EntityId(1), &plane)
457            .position(&model)
458            .expect_err("placement 70 is not in the model");
459        assert_eq!(error.entity(), Some(EntityId(1)));
460    }
461
462    /// A `Position` naming a 2D placement: the same slot layout starts with a
463    /// point, so an unchecked wrap would read it as a 3D frame.
464    fn model_with_2d_position() -> Model {
465        let mut model = Model::new();
466        model.insert(
467            EntityId(60),
468            Entity::new(
469                "IFCCARTESIANPOINT",
470                vec![Value::List(vec![Value::Real(1.0), Value::Real(2.0)])],
471            ),
472        );
473        model.insert(
474            EntityId(70),
475            Entity::new("IFCAXIS2PLACEMENT2D", vec![Value::Ref(EntityId(60))]),
476        );
477        model
478    }
479
480    fn assert_wrong_placement(result: GeometryResult<Axis2Placement3D<'_>>) {
481        let error = result.expect_err("#70 is not an IfcAxis2Placement3D");
482        assert!(
483            matches!(
484                error,
485                crate::GeometryError::WrongEntityType {
486                    entity: EntityId(70),
487                    expected: "IfcAxis2Placement3D",
488                    ..
489                }
490            ),
491            "got: {error:?}"
492        );
493    }
494
495    #[test]
496    fn a_plane_rejects_a_position_that_is_not_an_axis2_placement_3d() {
497        let model = model_with_2d_position();
498        let plane = surface("IFCPLANE", &[]);
499        assert_wrong_placement(Plane::new(EntityId(1), &plane).position(&model));
500    }
501
502    #[test]
503    fn a_cylinder_rejects_a_position_that_is_not_an_axis2_placement_3d() {
504        let model = model_with_2d_position();
505        let cylinder = surface("IFCCYLINDRICALSURFACE", &[2.0]);
506        assert_wrong_placement(CylindricalSurface::new(EntityId(2), &cylinder).position(&model));
507    }
508
509    #[test]
510    fn a_sphere_rejects_a_position_that_is_not_an_axis2_placement_3d() {
511        let model = model_with_2d_position();
512        let sphere = surface("IFCSPHERICALSURFACE", &[3.0]);
513        assert_wrong_placement(SphericalSurface::new(EntityId(3), &sphere).position(&model));
514    }
515
516    #[test]
517    fn a_torus_rejects_a_position_that_is_not_an_axis2_placement_3d() {
518        let model = model_with_2d_position();
519        let torus = surface("IFCTOROIDALSURFACE", &[5.0, 1.0]);
520        assert_wrong_placement(ToroidalSurface::new(EntityId(4), &torus).position(&model));
521    }
522}