Skip to main content

ifc_geometry/solid/swept/
directrix.rs

1//! Path-driven sweeps: directrix sweeps, swept disks, sectioned spines.
2//!
3//! What unites these is that a **curve** rather than a linear direction or an
4//! axis drives the sweep, which is why the orientation rules differ per type
5//! and why each one records its own frame convention.
6
7use super::trim::{read_trim, TrimMeasure};
8use super::{directrix_slot, disk_slot, spine_slot};
9use crate::error::GeometryResult;
10use crate::slots::Slots;
11use crate::solid::swept::area::SweptAreaSolid;
12use ifc_model::{Entity, EntityId};
13
14/// `IfcSurfaceCurveSweptAreaSolid`: a profile swept along a curve that lies on
15/// a surface.
16///
17/// The directrix is a curve **on** `ReferenceSurface`, and the surface normal
18/// at each point defines the profile's orientation. Sweeping along the curve
19/// alone, ignoring the surface, twists the section wrongly on any
20/// non-developable surface -- this is how curved facade mullions go wrong.
21///
22/// `StartParam`/`EndParam` may be absent, in which case the directrix must
23/// itself be bounded or conic and is used in full. In IFC4X3 each may be a
24/// length rather than a parameter; see [`TrimMeasure`].
25#[derive(Debug, Clone, Copy)]
26pub struct SurfaceCurveSweptAreaSolid<'m> {
27    slots: Slots<'m>,
28}
29
30impl<'m> SurfaceCurveSweptAreaSolid<'m> {
31    /// Wrap an entity assumed to be an `IfcSurfaceCurveSweptAreaSolid`.
32    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
33        Self {
34            slots: Slots::new(id, entity),
35        }
36    }
37
38    /// The entity id.
39    pub fn id(&self) -> EntityId {
40        self.slots.id()
41    }
42
43    /// The inherited `IfcSweptAreaSolid` attributes.
44    pub fn base(&self) -> SweptAreaSolid<'m> {
45        SweptAreaSolid::from_slots(self.slots)
46    }
47
48    /// The `IfcCurve` reference the profile follows.
49    pub fn directrix(&self) -> GeometryResult<EntityId> {
50        self.slots.req_ref(directrix_slot::DIRECTRIX, "Directrix")
51    }
52
53    /// Where the sweep starts, if trimmed, and whether the file states it as
54    /// a curve parameter or (IFC4X3) a length along the directrix.
55    ///
56    /// # Errors
57    ///
58    /// [`GeometryError::WrongValueKind`](crate::GeometryError::WrongValueKind)
59    /// for a value that is neither a number nor an `IfcCurveMeasureSelect`
60    /// member.
61    pub fn start_param(&self) -> GeometryResult<Option<TrimMeasure>> {
62        read_trim(&self.slots, directrix_slot::START_PARAM, "StartParam")
63    }
64
65    /// Where the sweep ends, if trimmed; as [`Self::start_param`].
66    ///
67    /// # Errors
68    ///
69    /// As [`Self::start_param`].
70    pub fn end_param(&self) -> GeometryResult<Option<TrimMeasure>> {
71        read_trim(&self.slots, directrix_slot::END_PARAM, "EndParam")
72    }
73
74    /// The `IfcSurface` reference the directrix lies on.
75    pub fn reference_surface(&self) -> GeometryResult<EntityId> {
76        self.slots
77            .req_ref(directrix_slot::REFERENCE_SURFACE, "ReferenceSurface")
78    }
79}
80
81/// `IfcFixedReferenceSweptAreaSolid`: a sweep whose section orientation is
82/// pinned to a constant direction.
83///
84/// Unlike a Frenet sweep, the profile's frame is derived from a fixed
85/// direction rather than the curve's own normal. That is exactly what keeps a
86/// road cross section upright over a crest; substituting a Frenet frame rolls
87/// the section with the curve's torsion.
88#[derive(Debug, Clone, Copy)]
89pub struct FixedReferenceSweptAreaSolid<'m> {
90    slots: Slots<'m>,
91}
92
93impl<'m> FixedReferenceSweptAreaSolid<'m> {
94    /// Wrap an entity assumed to be an `IfcFixedReferenceSweptAreaSolid`.
95    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
96        Self {
97            slots: Slots::new(id, entity),
98        }
99    }
100
101    /// The entity id.
102    pub fn id(&self) -> EntityId {
103        self.slots.id()
104    }
105
106    /// The inherited `IfcSweptAreaSolid` attributes.
107    pub fn base(&self) -> SweptAreaSolid<'m> {
108        SweptAreaSolid::from_slots(self.slots)
109    }
110
111    /// The `IfcCurve` reference the profile follows.
112    pub fn directrix(&self) -> GeometryResult<EntityId> {
113        self.slots.req_ref(directrix_slot::DIRECTRIX, "Directrix")
114    }
115
116    /// Where the sweep starts, if trimmed, and whether the file states it as
117    /// a curve parameter or (IFC4X3) a length along the directrix.
118    ///
119    /// # Errors
120    ///
121    /// [`GeometryError::WrongValueKind`](crate::GeometryError::WrongValueKind)
122    /// for a value that is neither a number nor an `IfcCurveMeasureSelect`
123    /// member.
124    pub fn start_param(&self) -> GeometryResult<Option<TrimMeasure>> {
125        read_trim(&self.slots, directrix_slot::START_PARAM, "StartParam")
126    }
127
128    /// Where the sweep ends, if trimmed; as [`Self::start_param`].
129    ///
130    /// # Errors
131    ///
132    /// As [`Self::start_param`].
133    pub fn end_param(&self) -> GeometryResult<Option<TrimMeasure>> {
134        read_trim(&self.slots, directrix_slot::END_PARAM, "EndParam")
135    }
136
137    /// The `IfcDirection` reference that fixes the section's orientation.
138    pub fn fixed_reference(&self) -> GeometryResult<EntityId> {
139        self.slots
140            .req_ref(directrix_slot::FIXED_REFERENCE, "FixedReference")
141    }
142}
143
144/// `IfcSweptDiskSolid`: a circular disk swept along a curve.
145///
146/// This is how pipes, ducts, cable trays and reinforcement bars are modelled.
147///
148/// # Radii
149///
150/// `InnerRadius` is optional and turns the solid into a tube. It must be
151/// strictly smaller than `Radius`; a file that violates that describes a solid
152/// with non-positive wall thickness, which is why [`Self::checked_radii`]
153/// exists.
154///
155/// # Slot warning
156///
157/// This entity subtypes `IfcSolidModel` **directly**, so it has no `SweptArea`
158/// and no `Position`. The directrix is absolute slot 0, not slot 2 -- assuming
159/// the `IfcSweptAreaSolid` layout here shifts every attribute by two.
160#[derive(Debug, Clone, Copy)]
161pub struct SweptDiskSolid<'m> {
162    slots: Slots<'m>,
163}
164
165impl<'m> SweptDiskSolid<'m> {
166    /// Wrap an entity assumed to be an `IfcSweptDiskSolid`.
167    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
168        Self {
169            slots: Slots::new(id, entity),
170        }
171    }
172
173    /// The entity id.
174    pub fn id(&self) -> EntityId {
175        self.slots.id()
176    }
177
178    /// The `IfcCurve` reference the disk is swept along.
179    pub fn directrix(&self) -> GeometryResult<EntityId> {
180        self.slots.req_ref(disk_slot::DIRECTRIX, "Directrix")
181    }
182
183    /// The outer radius, in file length units.
184    pub fn radius(&self) -> GeometryResult<f64> {
185        self.slots.req_f64(disk_slot::RADIUS, "Radius")
186    }
187
188    /// The inner radius, when the solid is a tube rather than a rod.
189    pub fn inner_radius(&self) -> Option<f64> {
190        self.slots.opt_f64(disk_slot::INNER_RADIUS)
191    }
192
193    /// The parameter at which the sweep starts, if trimmed.
194    pub fn start_param(&self) -> Option<f64> {
195        self.slots.opt_f64(disk_slot::START_PARAM)
196    }
197
198    /// The parameter at which the sweep ends, if trimmed.
199    pub fn end_param(&self) -> Option<f64> {
200        self.slots.opt_f64(disk_slot::END_PARAM)
201    }
202
203    /// The radii, rejecting a tube whose bore is at least its outside.
204    pub fn checked_radii(&self) -> GeometryResult<(f64, Option<f64>)> {
205        let radius = self.radius()?;
206        if radius <= 0.0 {
207            return Err(self
208                .slots
209                .degenerate(format!("Radius must be positive, found {radius}")));
210        }
211        let inner = self.inner_radius();
212        if let Some(inner) = inner {
213            if inner >= radius {
214                return Err(self.slots.degenerate(format!(
215                    "InnerRadius {inner} must be smaller than Radius {radius}"
216                )));
217            }
218        }
219        Ok((radius, inner))
220    }
221}
222
223/// `IfcSweptDiskSolidPolygonal`: a swept disk whose directrix is a polyline
224/// with optionally filleted corners.
225///
226/// `FilletRadius` absent means sharp corners. When present the schema requires
227/// it to be at least the disk radius, so that the fillet does not pinch the
228/// tube shut at a corner.
229#[derive(Debug, Clone, Copy)]
230pub struct SweptDiskSolidPolygonal<'m> {
231    slots: Slots<'m>,
232}
233
234impl<'m> SweptDiskSolidPolygonal<'m> {
235    /// Wrap an entity assumed to be an `IfcSweptDiskSolidPolygonal`.
236    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
237        Self {
238            slots: Slots::new(id, entity),
239        }
240    }
241
242    /// The entity id.
243    pub fn id(&self) -> EntityId {
244        self.slots.id()
245    }
246
247    /// The inherited `IfcSweptDiskSolid` attributes.
248    pub fn base(&self) -> SweptDiskSolid<'m> {
249        SweptDiskSolid { slots: self.slots }
250    }
251
252    /// The corner fillet radius, when corners are rounded.
253    pub fn fillet_radius(&self) -> Option<f64> {
254        self.slots.opt_f64(disk_slot::FILLET_RADIUS)
255    }
256}
257
258/// `IfcSectionedSpine`: cross sections positioned along a composite curve.
259///
260/// # Not a swept area solid
261///
262/// Despite the family resemblance it subtypes `IfcGeometricRepresentationItem`
263/// directly, not `IfcSolidModel`. Slot 0 is `SpineCurve`, and there is no
264/// inherited `SweptArea` or `Position`.
265///
266/// # The pairing invariant
267///
268/// `CrossSections` and `CrossSectionPositions` are parallel lists that the
269/// schema requires to be the same length. Exporters do get this wrong, and a
270/// plain zip silently truncates to the shorter one, producing a solid missing
271/// its tail. [`Self::checked_sections`] reports the mismatch instead.
272#[derive(Debug, Clone, Copy)]
273pub struct SectionedSpine<'m> {
274    slots: Slots<'m>,
275}
276
277impl<'m> SectionedSpine<'m> {
278    /// Wrap an entity assumed to be an `IfcSectionedSpine`.
279    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
280        Self {
281            slots: Slots::new(id, entity),
282        }
283    }
284
285    /// The entity id.
286    pub fn id(&self) -> EntityId {
287        self.slots.id()
288    }
289
290    /// The `IfcCompositeCurve` reference forming the spine.
291    pub fn spine_curve(&self) -> GeometryResult<EntityId> {
292        self.slots.req_ref(spine_slot::SPINE_CURVE, "SpineCurve")
293    }
294
295    /// The `IfcProfileDef` references, in spine order.
296    pub fn cross_sections(&self) -> GeometryResult<Vec<EntityId>> {
297        self.slots
298            .req_ref_list(spine_slot::CROSS_SECTIONS, "CrossSections")
299    }
300
301    /// The `IfcAxis2Placement3D` references locating each cross section.
302    pub fn cross_section_positions(&self) -> GeometryResult<Vec<EntityId>> {
303        self.slots
304            .req_ref_list(spine_slot::CROSS_SECTION_POSITIONS, "CrossSectionPositions")
305    }
306
307    /// Sections paired with their positions, rejecting a length mismatch.
308    pub fn checked_sections(&self) -> GeometryResult<Vec<(EntityId, EntityId)>> {
309        let sections = self.cross_sections()?;
310        let positions = self.cross_section_positions()?;
311        if sections.len() != positions.len() {
312            return Err(self.slots.degenerate(format!(
313                "CrossSections has {} entries but CrossSectionPositions has {}",
314                sections.len(),
315                positions.len()
316            )));
317        }
318        Ok(sections.into_iter().zip(positions).collect())
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::solid::testkit::{entity, list, n, r};
326    use ifc_model::Value;
327
328    #[test]
329    fn surface_curve_sweep_exposes_directrix_surface_and_optional_trim() {
330        let e = entity(
331            "IFCSURFACECURVESWEPTAREASOLID",
332            vec![r(10), r(20), r(30), n(0.0), n(1.0), r(60)],
333        );
334        let view = SurfaceCurveSweptAreaSolid::new(EntityId(1), &e);
335        assert_eq!(view.base().swept_area().unwrap(), EntityId(10));
336        assert_eq!(view.directrix().unwrap(), EntityId(30));
337        assert_eq!(
338            view.start_param().unwrap(),
339            Some(TrimMeasure::Parameter(0.0))
340        );
341        assert_eq!(view.end_param().unwrap(), Some(TrimMeasure::Parameter(1.0)));
342        assert_eq!(view.reference_surface().unwrap(), EntityId(60));
343    }
344
345    #[test]
346    fn untrimmed_directrix_reports_absent_parameters_rather_than_zero() {
347        let e = entity(
348            "IFCSURFACECURVESWEPTAREASOLID",
349            vec![r(10), r(20), r(30), Value::Null, Value::Null, r(60)],
350        );
351        let view = SurfaceCurveSweptAreaSolid::new(EntityId(1), &e);
352        assert_eq!(view.start_param().unwrap(), None);
353        assert_eq!(view.end_param().unwrap(), None);
354    }
355
356    /// Slot 5 is a surface on one sweep and a direction on the other;
357    /// conflating them silently swaps a surface reference for a direction.
358    #[test]
359    fn fixed_reference_sweep_reads_a_direction_where_surface_sweep_reads_a_surface() {
360        let attrs = vec![r(10), r(20), r(30), Value::Null, Value::Null, r(70)];
361        let fixed = entity("IFCFIXEDREFERENCESWEPTAREASOLID", attrs.clone());
362        let surface = entity("IFCSURFACECURVESWEPTAREASOLID", attrs);
363
364        assert_eq!(
365            FixedReferenceSweptAreaSolid::new(EntityId(1), &fixed)
366                .fixed_reference()
367                .unwrap(),
368            EntityId(70)
369        );
370        assert_eq!(
371            SurfaceCurveSweptAreaSolid::new(EntityId(1), &surface)
372                .reference_surface()
373                .unwrap(),
374            EntityId(70)
375        );
376        assert_eq!(
377            FixedReferenceSweptAreaSolid::new(EntityId(1), &fixed)
378                .directrix()
379                .unwrap(),
380            EntityId(30)
381        );
382    }
383
384    /// The disk solid subtypes IfcSolidModel directly: Directrix is slot 0,
385    /// not slot 2 as it is on the IfcSweptAreaSolid branch.
386    #[test]
387    fn swept_disk_directrix_is_slot_zero_with_no_inherited_profile() {
388        let e = entity(
389            "IFCSWEPTDISKSOLID",
390            vec![r(10), n(0.1), n(0.08), n(0.0), n(1.0)],
391        );
392        let view = SweptDiskSolid::new(EntityId(1), &e);
393        assert_eq!(view.directrix().unwrap(), EntityId(10));
394        assert_eq!(view.radius().unwrap(), 0.1);
395        assert_eq!(view.inner_radius(), Some(0.08));
396        assert_eq!(view.start_param(), Some(0.0));
397        assert_eq!(view.end_param(), Some(1.0));
398    }
399
400    #[test]
401    fn inner_radius_not_smaller_than_outer_is_degenerate() {
402        let bad = entity("IFCSWEPTDISKSOLID", vec![r(10), n(0.1), n(0.1)]);
403        let err = SweptDiskSolid::new(EntityId(5), &bad)
404            .checked_radii()
405            .unwrap_err();
406        assert_eq!(err.entity(), Some(EntityId(5)));
407
408        let tube = entity("IFCSWEPTDISKSOLID", vec![r(10), n(0.1), n(0.09)]);
409        assert_eq!(
410            SweptDiskSolid::new(EntityId(5), &tube)
411                .checked_radii()
412                .unwrap(),
413            (0.1, Some(0.09))
414        );
415
416        let rod = entity("IFCSWEPTDISKSOLID", vec![r(10), n(0.1)]);
417        assert_eq!(
418            SweptDiskSolid::new(EntityId(5), &rod)
419                .checked_radii()
420                .unwrap(),
421            (0.1, None)
422        );
423    }
424
425    #[test]
426    fn polygonal_disk_fillet_radius_is_optional() {
427        let with_fillet = entity(
428            "IFCSWEPTDISKSOLIDPOLYGONAL",
429            vec![
430                r(10),
431                n(0.1),
432                Value::Null,
433                Value::Null,
434                Value::Null,
435                n(0.15),
436            ],
437        );
438        let view = SweptDiskSolidPolygonal::new(EntityId(1), &with_fillet);
439        assert_eq!(view.fillet_radius(), Some(0.15));
440        assert_eq!(view.base().radius().unwrap(), 0.1);
441
442        let sharp = entity("IFCSWEPTDISKSOLIDPOLYGONAL", vec![r(10), n(0.1)]);
443        assert_eq!(
444            SweptDiskSolidPolygonal::new(EntityId(1), &sharp).fillet_radius(),
445            None
446        );
447    }
448
449    #[test]
450    fn sectioned_spine_pairs_each_section_with_its_own_placement() {
451        let e = entity(
452            "IFCSECTIONEDSPINE",
453            vec![
454                r(1),
455                list(vec![r(10), r(11), r(12)]),
456                list(vec![r(20), r(21), r(22)]),
457            ],
458        );
459        let view = SectionedSpine::new(EntityId(9), &e);
460        assert_eq!(view.spine_curve().unwrap(), EntityId(1));
461        assert_eq!(
462            view.checked_sections().unwrap(),
463            vec![
464                (EntityId(10), EntityId(20)),
465                (EntityId(11), EntityId(21)),
466                (EntityId(12), EntityId(22)),
467            ]
468        );
469    }
470
471    /// A plain zip would silently drop the unpaired tail; the file is
472    /// malformed and must say so.
473    #[test]
474    fn mismatched_spine_list_lengths_are_reported_not_truncated() {
475        let e = entity(
476            "IFCSECTIONEDSPINE",
477            vec![
478                r(1),
479                list(vec![r(10), r(11), r(12)]),
480                list(vec![r(20), r(21)]),
481            ],
482        );
483        let view = SectionedSpine::new(EntityId(9), &e);
484        let err = view.checked_sections().unwrap_err();
485        assert_eq!(err.entity(), Some(EntityId(9)));
486        assert!(err.to_string().contains('3'));
487    }
488}