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