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