Skip to main content

ifc_geometry/solid/swept/
area.rs

1//! Profile sweeps: `IfcSweptAreaSolid` and its extrusion/revolution subtypes.
2//!
3//! These four concrete types cover almost every solid in a real building model.
4
5use super::{extruded_slot, revolved_slot, swept_area_slot};
6use crate::error::GeometryResult;
7use crate::slots::Slots;
8use ifc_model::{Entity, EntityId};
9
10/// The abstract `IfcSweptAreaSolid` view: profile plus optional placement.
11///
12/// Constructible over any subtype, because the inherited slots sit at the same
13/// absolute positions in all of them. That is what lets a caller read the
14/// profile of an extrusion, a revolution or a tapered sweep uniformly.
15#[derive(Debug, Clone, Copy)]
16pub struct SweptAreaSolid<'m> {
17    slots: Slots<'m>,
18}
19
20impl<'m> SweptAreaSolid<'m> {
21    /// Wrap an entity assumed to be an `IfcSweptAreaSolid` subtype.
22    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
23        Self {
24            slots: Slots::new(id, entity),
25        }
26    }
27
28    /// Build from already-wrapped slots, for a subtype view delegating upward.
29    pub(super) fn from_slots(slots: Slots<'m>) -> Self {
30        Self { slots }
31    }
32
33    /// The entity id.
34    pub fn id(&self) -> EntityId {
35        self.slots.id()
36    }
37
38    /// The IFC type name, which names the concrete subtype.
39    pub fn type_name(&self) -> &'m str {
40        self.slots.type_name()
41    }
42
43    /// The `IfcProfileDef` reference giving the cross section.
44    ///
45    /// Stays a reference: `IfcProfileDef` families are read by
46    /// [`crate::describe_profile`], the one owner of profile semantics, so
47    /// this crate does not define a second, competing profile view.
48    pub fn swept_area(&self) -> GeometryResult<EntityId> {
49        self.slots.req_ref(swept_area_slot::SWEPT_AREA, "SweptArea")
50    }
51
52    /// The `IfcAxis2Placement3D` positioning the sweep, when present.
53    ///
54    /// Absent means the identity placement: the profile sits in the XY plane
55    /// of the containing representation's coordinate system.
56    pub fn position(&self) -> Option<EntityId> {
57        self.slots.opt_ref(swept_area_slot::POSITION)
58    }
59}
60
61/// `IfcExtrudedAreaSolid`: a profile swept along a straight direction.
62///
63/// The overwhelming majority of walls, slabs, columns and beams in a real model
64/// are one of these, so this is the hot path of IFC geometry.
65///
66/// # The oblique-extrusion trap
67///
68/// `ExtrudedDirection` is expressed **in the `Position` coordinate system** and
69/// is *not* required to be +Z. The schema only forbids it from being
70/// perpendicular to the local Z axis. Assuming `[0, 0, 1]` produces a solid of
71/// the right volume in the wrong place for every sheared extrusion in the file,
72/// and sheared extrusions are common in roof and ramp geometry.
73///
74/// `Depth` is measured **along `ExtrudedDirection`**, not along Z, and is a
75/// positive length measure: a zero or negative depth is a degenerate file.
76#[derive(Debug, Clone, Copy)]
77pub struct ExtrudedAreaSolid<'m> {
78    slots: Slots<'m>,
79}
80
81impl<'m> ExtrudedAreaSolid<'m> {
82    /// Wrap an entity assumed to be an `IfcExtrudedAreaSolid`.
83    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
84        Self {
85            slots: Slots::new(id, entity),
86        }
87    }
88
89    /// The entity id.
90    pub fn id(&self) -> EntityId {
91        self.slots.id()
92    }
93
94    /// The inherited `IfcSweptAreaSolid` attributes.
95    pub fn base(&self) -> SweptAreaSolid<'m> {
96        SweptAreaSolid::from_slots(self.slots)
97    }
98
99    /// The `IfcDirection` reference giving the sweep direction.
100    ///
101    /// Expressed in the `Position` coordinate system. See the type docs: it is
102    /// not necessarily +Z.
103    pub fn extruded_direction(&self) -> GeometryResult<EntityId> {
104        self.slots
105            .req_ref(extruded_slot::EXTRUDED_DIRECTION, "ExtrudedDirection")
106    }
107
108    /// The sweep distance along `ExtrudedDirection`, in file length units.
109    pub fn depth(&self) -> GeometryResult<f64> {
110        self.slots.req_f64(extruded_slot::DEPTH, "Depth")
111    }
112
113    /// The depth, rejecting the non-positive values the schema forbids.
114    ///
115    /// Separate from [`Self::depth`] so a caller inspecting a file can still
116    /// see the raw value while a caller building geometry gets a located error
117    /// instead of a zero-volume solid.
118    pub fn checked_depth(&self) -> GeometryResult<f64> {
119        let depth = self.depth()?;
120        if depth > 0.0 {
121            Ok(depth)
122        } else {
123            Err(self
124                .slots
125                .degenerate(format!("Depth must be positive, found {depth}")))
126        }
127    }
128}
129
130/// `IfcExtrudedAreaSolidTapered`: an extrusion whose section morphs.
131///
132/// The solid is lofted between `SweptArea` at the start and `EndSweptArea` at
133/// the depth. A kernel that ignores `EndSweptArea` silently produces a prism
134/// where the file describes a taper, which is why this is a distinct view
135/// rather than an optional field on [`ExtrudedAreaSolid`].
136#[derive(Debug, Clone, Copy)]
137pub struct ExtrudedAreaSolidTapered<'m> {
138    slots: Slots<'m>,
139}
140
141impl<'m> ExtrudedAreaSolidTapered<'m> {
142    /// Wrap an entity assumed to be an `IfcExtrudedAreaSolidTapered`.
143    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
144        Self {
145            slots: Slots::new(id, entity),
146        }
147    }
148
149    /// The entity id.
150    pub fn id(&self) -> EntityId {
151        self.slots.id()
152    }
153
154    /// The inherited `IfcExtrudedAreaSolid` attributes.
155    pub fn base(&self) -> ExtrudedAreaSolid<'m> {
156        ExtrudedAreaSolid { slots: self.slots }
157    }
158
159    /// The `IfcProfileDef` reference for the section at full depth.
160    pub fn end_swept_area(&self) -> GeometryResult<EntityId> {
161        self.slots
162            .req_ref(extruded_slot::END_SWEPT_AREA, "EndSweptArea")
163    }
164}
165
166/// `IfcRevolvedAreaSolid`: a profile swept about an axis.
167///
168/// # The angle-unit trap
169///
170/// `Angle` is an `IfcPlaneAngleMeasure`, expressed in the **file's declared
171/// angle unit**. That is very often degrees: IFC exporters routinely declare
172/// the plane angle unit as `DEGREE` through a conversion-based unit. Treating a
173/// `90` as radians yields fourteen full turns.
174///
175/// This view returns the raw number deliberately. Converting is
176/// [`crate::units`]'s responsibility and must be applied exactly once.
177#[derive(Debug, Clone, Copy)]
178pub struct RevolvedAreaSolid<'m> {
179    slots: Slots<'m>,
180}
181
182impl<'m> RevolvedAreaSolid<'m> {
183    /// Wrap an entity assumed to be an `IfcRevolvedAreaSolid`.
184    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
185        Self {
186            slots: Slots::new(id, entity),
187        }
188    }
189
190    /// The entity id.
191    pub fn id(&self) -> EntityId {
192        self.slots.id()
193    }
194
195    /// The inherited `IfcSweptAreaSolid` attributes.
196    pub fn base(&self) -> SweptAreaSolid<'m> {
197        SweptAreaSolid::from_slots(self.slots)
198    }
199
200    /// The `IfcAxis1Placement` reference giving the axis of revolution.
201    ///
202    /// Expressed in the `Position` coordinate system, and its own `Axis`
203    /// attribute is itself optional, defaulting to +Z of that placement.
204    pub fn axis(&self) -> GeometryResult<EntityId> {
205        self.slots.req_ref(revolved_slot::AXIS, "Axis")
206    }
207
208    /// The sweep angle **in the file's angle unit**, unconverted.
209    pub fn angle_raw(&self) -> GeometryResult<f64> {
210        self.slots.req_f64(revolved_slot::ANGLE, "Angle")
211    }
212}
213
214/// `IfcRevolvedAreaSolidTapered`: a revolution whose section morphs.
215///
216/// Same lofting caveat as [`ExtrudedAreaSolidTapered`]: ignoring
217/// `EndSweptArea` produces a plain revolution, not the described solid.
218#[derive(Debug, Clone, Copy)]
219pub struct RevolvedAreaSolidTapered<'m> {
220    slots: Slots<'m>,
221}
222
223impl<'m> RevolvedAreaSolidTapered<'m> {
224    /// Wrap an entity assumed to be an `IfcRevolvedAreaSolidTapered`.
225    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
226        Self {
227            slots: Slots::new(id, entity),
228        }
229    }
230
231    /// The entity id.
232    pub fn id(&self) -> EntityId {
233        self.slots.id()
234    }
235
236    /// The inherited `IfcRevolvedAreaSolid` attributes.
237    pub fn base(&self) -> RevolvedAreaSolid<'m> {
238        RevolvedAreaSolid { slots: self.slots }
239    }
240
241    /// The `IfcProfileDef` reference for the section at the end angle.
242    pub fn end_swept_area(&self) -> GeometryResult<EntityId> {
243        self.slots
244            .req_ref(revolved_slot::END_SWEPT_AREA, "EndSweptArea")
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::solid::testkit::{entity, n, r};
252    use ifc_model::Value;
253
254    fn extrusion(attrs: Vec<Value>) -> Entity {
255        entity("IFCEXTRUDEDAREASOLID", attrs)
256    }
257
258    /// The inherited slots come FIRST; reading SweptArea from slot 2 is the
259    /// classic local-index mistake.
260    #[test]
261    fn inherited_swept_area_slots_precede_the_subtype_own_slots() {
262        let e = extrusion(vec![r(10), r(20), r(30), n(3.0)]);
263        let view = ExtrudedAreaSolid::new(EntityId(1), &e);
264        assert_eq!(view.base().swept_area().unwrap(), EntityId(10));
265        assert_eq!(view.base().position(), Some(EntityId(20)));
266        assert_eq!(view.extruded_direction().unwrap(), EntityId(30));
267        assert_eq!(view.depth().unwrap(), 3.0);
268    }
269
270    /// Position is optional and its absence means identity, not an error.
271    #[test]
272    fn absent_position_is_reported_as_none_not_as_a_failure() {
273        let e = extrusion(vec![r(10), Value::Null, r(30), n(3.0)]);
274        let view = ExtrudedAreaSolid::new(EntityId(1), &e);
275        assert_eq!(view.base().position(), None);
276        assert!(view.base().swept_area().is_ok());
277    }
278
279    /// The direction is a reference to be resolved in the Position system; it
280    /// is never assumed to be +Z, and a missing one is an error not a default.
281    #[test]
282    fn extruded_direction_is_a_reference_and_never_defaulted_to_z() {
283        let e = extrusion(vec![r(10), r(20), r(99), n(1.0)]);
284        assert_eq!(
285            ExtrudedAreaSolid::new(EntityId(1), &e)
286                .extruded_direction()
287                .unwrap(),
288            EntityId(99)
289        );
290
291        let missing = extrusion(vec![r(10), r(20), Value::Null, n(1.0)]);
292        assert!(ExtrudedAreaSolid::new(EntityId(1), &missing)
293            .extruded_direction()
294            .is_err());
295    }
296
297    #[test]
298    fn non_positive_depth_is_rejected_as_degenerate() {
299        for bad in [0.0, -2.5] {
300            let e = extrusion(vec![r(10), r(20), r(30), n(bad)]);
301            let view = ExtrudedAreaSolid::new(EntityId(7), &e);
302            let err = view.checked_depth().unwrap_err();
303            assert_eq!(err.entity(), Some(EntityId(7)));
304            assert!(view.depth().is_ok(), "raw depth stays readable");
305        }
306        let good = extrusion(vec![r(10), r(20), r(30), n(2.5)]);
307        assert_eq!(
308            ExtrudedAreaSolid::new(EntityId(7), &good)
309                .checked_depth()
310                .unwrap(),
311            2.5
312        );
313    }
314
315    #[test]
316    fn tapered_extrusion_keeps_both_profiles_addressable() {
317        let e = entity(
318            "IFCEXTRUDEDAREASOLIDTAPERED",
319            vec![r(10), r(20), r(30), n(3.0), r(40)],
320        );
321        let view = ExtrudedAreaSolidTapered::new(EntityId(1), &e);
322        assert_eq!(view.base().base().swept_area().unwrap(), EntityId(10));
323        assert_eq!(view.base().depth().unwrap(), 3.0);
324        assert_eq!(view.end_swept_area().unwrap(), EntityId(40));
325    }
326
327    /// Angle is in the file's angle unit, so the view must hand back the
328    /// literal without scaling it.
329    #[test]
330    fn revolution_angle_is_returned_raw_without_unit_conversion() {
331        let e = entity("IFCREVOLVEDAREASOLID", vec![r(10), r(20), r(30), n(90.0)]);
332        let view = RevolvedAreaSolid::new(EntityId(1), &e);
333        assert_eq!(view.angle_raw().unwrap(), 90.0);
334        assert_eq!(view.axis().unwrap(), EntityId(30));
335        assert_eq!(view.base().swept_area().unwrap(), EntityId(10));
336    }
337
338    #[test]
339    fn tapered_revolution_exposes_its_end_profile() {
340        let e = entity(
341            "IFCREVOLVEDAREASOLIDTAPERED",
342            vec![r(10), r(20), r(30), n(45.0), r(50)],
343        );
344        let view = RevolvedAreaSolidTapered::new(EntityId(1), &e);
345        assert_eq!(view.base().angle_raw().unwrap(), 45.0);
346        assert_eq!(view.end_swept_area().unwrap(), EntityId(50));
347    }
348
349    /// Type name survives the view so diagnostics can name the subtype.
350    #[test]
351    fn abstract_view_reports_the_concrete_subtype_name() {
352        let e = extrusion(vec![r(10), r(20), r(30), n(1.0)]);
353        let view = SweptAreaSolid::new(EntityId(1), &e);
354        assert_eq!(view.type_name(), "IFCEXTRUDEDAREASOLID");
355        assert_eq!(view.id(), EntityId(1));
356    }
357}