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