Skip to main content

ifc_geometry/curve/
composite.rs

1//! `IfcCompositeCurve` and its segments.
2//!
3//! # Why segments are entities, not just curve references
4//!
5//! A composite curve is a chain of `IfcCompositeCurveSegment`, and each segment
6//! carries two facts the parent curve cannot: whether it is traversed forwards
7//! or backwards, and what kind of continuity holds at its far end.
8//!
9//! **`SameSense = .F.` is common, not exotic.** Exporters reuse one
10//! `IfcCircle` or `IfcPolyline` for several outlines and flip the sense rather
11//! than emitting a mirrored copy. A consumer that ignores `SameSense` produces
12//! a chain whose segments do not meet: each reversed segment starts where its
13//! neighbour also starts. The failure looks like a tolerance problem and is
14//! not one.
15//!
16//! **`Transition` is a promise about the joint**, checked by nobody. The last
17//! segment of a closed composite curve must say `DISCONTINUOUS` only if the
18//! curve is open; a closed curve repeats the continuity of the first joint.
19//! The code carries useful information for a kernel deciding whether it may
20//! fuse two segments into one edge, so it is exposed rather than discarded.
21
22use crate::error::GeometryResult;
23use crate::slots::Slots;
24use ifc_model::{Entity, EntityId};
25
26/// `IfcCompositeCurve` attribute slots.
27///
28/// From IFC4 ADD2 TC1. `IfcCompositeCurveOnSurface`, `IfcBoundaryCurve` and
29/// `IfcOuterBoundaryCurve` are subtypes that add no explicit attributes, so
30/// these same indices apply to all four.
31pub(crate) mod curve_slot {
32    /// `Segments`: `LIST [1:?] OF IfcCompositeCurveSegment`.
33    pub const SEGMENTS: usize = 0;
34    /// `SelfIntersect`: `IfcLogical`, informational only.
35    pub const SELF_INTERSECT: usize = 1;
36}
37
38/// `IfcCompositeCurveSegment` attribute slots.
39///
40/// From IFC4 ADD2 TC1. `IfcReparametrisedCompositeCurveSegment` adds
41/// `ParamLength` at slot 3 and inherits these three unchanged.
42pub(crate) mod segment_slot {
43    /// `Transition`: `IfcTransitionCode`.
44    pub const TRANSITION: usize = 0;
45    /// `SameSense`: `IfcBoolean`.
46    pub const SAME_SENSE: usize = 1;
47    /// `ParentCurve`: the `IfcCurve` this segment is a piece of.
48    pub const PARENT_CURVE: usize = 2;
49    /// `ParamLength` on `IfcReparametrisedCompositeCurveSegment`.
50    pub const PARAM_LENGTH: usize = 3;
51}
52
53/// `IfcTransitionCode`: what holds where one segment meets the next.
54///
55/// The values are ordered from weakest to strongest guarantee, and each
56/// implies all the weaker ones.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum TransitionCode {
59    /// The segments do not even meet; the curve has a gap at this joint.
60    Discontinuous,
61    /// The end point of this segment is the start point of the next.
62    Continuous,
63    /// Positions and tangent *directions* match.
64    ///
65    /// Tangent magnitude may still jump, so a kernel must not assume the
66    /// parameterisation is smooth across the joint.
67    ContSameGradient,
68    /// Positions, tangent directions and curvature all match.
69    ContSameGradientSameCurvature,
70}
71
72impl TransitionCode {
73    /// Parse the enumeration token.
74    ///
75    /// Returns `None` for an unrecognised token; defaulting would claim a
76    /// continuity the file never asserted.
77    pub fn from_token(token: &str) -> Option<Self> {
78        match token.to_ascii_uppercase().as_str() {
79            "DISCONTINUOUS" => Some(Self::Discontinuous),
80            "CONTINUOUS" => Some(Self::Continuous),
81            "CONTSAMEGRADIENT" => Some(Self::ContSameGradient),
82            "CONTSAMEGRADIENTSAMECURVATURE" => Some(Self::ContSameGradientSameCurvature),
83            _ => None,
84        }
85    }
86
87    /// The EXPRESS token, the inverse of [`Self::from_token`].
88    pub fn token(self) -> &'static str {
89        match self {
90            Self::Discontinuous => "DISCONTINUOUS",
91            Self::Continuous => "CONTINUOUS",
92            Self::ContSameGradient => "CONTSAMEGRADIENT",
93            Self::ContSameGradientSameCurvature => "CONTSAMEGRADIENTSAMECURVATURE",
94        }
95    }
96
97    /// Do the segments at this joint at least touch?
98    ///
99    /// The question a kernel actually asks before deciding whether the chain
100    /// forms one connected wire.
101    pub fn is_connected(&self) -> bool {
102        !matches!(self, Self::Discontinuous)
103    }
104}
105
106/// Refusal for an IFC4X3 `IfcCurveSegment` met where a composite segment is read.
107///
108/// IFC4X3 widens `IfcCompositeCurve.Segments` to `IfcSegment`, so a segment
109/// may be an `IfcCurveSegment` (`Transition`, `Placement`, `SegmentStart`,
110/// `SegmentLength`, `ParentCurve`). Reading it through the
111/// `IfcCompositeCurveSegment` slots would take `SegmentLength` for the parent
112/// curve, so the view refuses it by name. Every lowering path checks for an
113/// `IfcCurveSegment` before building this view (the curve lowering lowers it,
114/// sweep ranges, p-curves and profile boundaries refuse it with their own
115/// reason); the refusal is the backstop for any other caller.
116pub const CURVE_SEGMENT_UNREAD: &str = "an IfcCurveSegment is placed and trimmed by \
117     SegmentStart and SegmentLength and cannot be read as an \
118     IfcCompositeCurveSegment; lower it with lower::curve instead";
119
120/// A borrowed view of an `IfcCompositeCurveSegment`.
121///
122/// Also covers `IfcReparametrisedCompositeCurveSegment`, whose extra
123/// `ParamLength` is read by [`Self::param_length`]. An IFC4X3
124/// `IfcCurveSegment` has a different slot layout; every slot read refuses it
125/// with [`CURVE_SEGMENT_UNREAD`].
126#[derive(Debug, Clone, Copy)]
127pub struct CompositeCurveSegment<'m> {
128    slots: Slots<'m>,
129}
130
131impl<'m> CompositeCurveSegment<'m> {
132    /// Wrap an entity known to be an `IfcCompositeCurveSegment` subtype.
133    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
134        Self {
135            slots: Slots::new(id, entity),
136        }
137    }
138
139    /// The entity id.
140    pub fn id(&self) -> EntityId {
141        self.slots.id()
142    }
143
144    /// The curve this segment is a piece of.
145    ///
146    /// Frequently shared with other segments and other composite curves, which
147    /// is precisely why [`Self::same_sense`] exists.
148    pub fn parent_curve_ref(&self) -> GeometryResult<EntityId> {
149        self.refuse_curve_segment()?;
150        self.slots
151            .req_ref(segment_slot::PARENT_CURVE, "ParentCurve")
152    }
153
154    /// Refuse an IFC4X3 `IfcCurveSegment`, whose slots differ from ours.
155    fn refuse_curve_segment(&self) -> GeometryResult<()> {
156        if self.slots.entity().is_type("IFCCURVESEGMENT") {
157            return Err(self.slots.unsupported(CURVE_SEGMENT_UNREAD));
158        }
159        Ok(())
160    }
161
162    /// Is the segment traversed along the parent curve's own direction?
163    ///
164    /// `false` means reverse it before joining it to its neighbours.
165    pub fn same_sense(&self) -> GeometryResult<bool> {
166        self.refuse_curve_segment()?;
167        self.slots.req_bool(segment_slot::SAME_SENSE, "SameSense")
168    }
169
170    /// The continuity asserted at the joint with the *next* segment.
171    ///
172    /// Note "next": the code describes the transition at the end of this
173    /// segment, so the last segment of an open curve says `DISCONTINUOUS`.
174    pub fn transition(&self) -> GeometryResult<TransitionCode> {
175        let token = self
176            .slots
177            .opt_enum(segment_slot::TRANSITION)
178            .ok_or_else(|| {
179                self.slots
180                    .degenerate("Transition is missing or is not an enumeration token")
181            })?;
182        TransitionCode::from_token(token).ok_or_else(|| {
183            self.slots
184                .degenerate(format!("unknown IfcTransitionCode .{token}."))
185        })
186    }
187
188    /// `ParamLength`, present only on `IfcReparametrisedCompositeCurveSegment`.
189    ///
190    /// Returns `None` for a plain `IfcCompositeCurveSegment`. When present it
191    /// rescales the segment's parameter range to `[0, ParamLength]`, so a
192    /// consumer that ignores it will evaluate the parent curve at the wrong
193    /// parameters. Values must be positive.
194    pub fn param_length(&self) -> GeometryResult<Option<f64>> {
195        self.refuse_curve_segment()?;
196        let Some(value) = self.slots.opt_f64(segment_slot::PARAM_LENGTH) else {
197            return Ok(None);
198        };
199        if value > 0.0 {
200            Ok(Some(value))
201        } else {
202            Err(self
203                .slots
204                .degenerate(format!("ParamLength must be positive, found {value}")))
205        }
206    }
207
208    /// Is this the reparametrised subtype?
209    pub fn is_reparametrised(&self) -> bool {
210        self.slots
211            .entity()
212            .type_name
213            .eq_ignore_ascii_case("IFCREPARAMETRISEDCOMPOSITECURVESEGMENT")
214    }
215}
216
217/// A borrowed view of an `IfcCompositeCurve`.
218///
219/// Also covers the subtypes that add no attributes:
220/// `IfcCompositeCurveOnSurface`, `IfcBoundaryCurve`, `IfcOuterBoundaryCurve`.
221#[derive(Debug, Clone, Copy)]
222pub struct CompositeCurve<'m> {
223    slots: Slots<'m>,
224}
225
226impl<'m> CompositeCurve<'m> {
227    /// Wrap an entity known to be an `IfcCompositeCurve` subtype.
228    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
229        Self {
230            slots: Slots::new(id, entity),
231        }
232    }
233
234    /// The entity id.
235    pub fn id(&self) -> EntityId {
236        self.slots.id()
237    }
238
239    /// The `IfcCompositeCurveSegment` references, in traversal order.
240    ///
241    /// Order is significant and must not be sorted: the chain's shape is the
242    /// list order combined with each segment's `SameSense`.
243    pub fn segment_refs(&self) -> GeometryResult<Vec<EntityId>> {
244        let segments = self.slots.req_ref_list(curve_slot::SEGMENTS, "Segments")?;
245        if segments.is_empty() {
246            return Err(self
247                .slots
248                .degenerate("Segments is empty; LIST [1:?] requires at least one segment"));
249        }
250        Ok(segments)
251    }
252
253    /// The informational `SelfIntersect` flag, `None` when absent or `.U.`.
254    pub fn self_intersect(&self) -> Option<bool> {
255        self.slots.opt_bool(curve_slot::SELF_INTERSECT)
256    }
257
258    /// Is this curve constrained to lie on a surface?
259    ///
260    /// True for `IfcCompositeCurveOnSurface` and its `IfcBoundaryCurve` /
261    /// `IfcOuterBoundaryCurve` descendants, whose segments must all have
262    /// `IfcPcurve` or surface-curve parents.
263    pub fn is_on_surface(&self) -> bool {
264        matches!(
265            self.slots.entity().type_name.to_ascii_uppercase().as_str(),
266            "IFCCOMPOSITECURVEONSURFACE" | "IFCBOUNDARYCURVE" | "IFCOUTERBOUNDARYCURVE"
267        )
268    }
269
270    /// Is this the *outer* boundary of a surface?
271    ///
272    /// `IfcOuterBoundaryCurve` is the only way a file marks which of a
273    /// surface's boundaries is the outline rather than a hole, and the
274    /// distinction is carried by the entity type alone.
275    pub fn is_outer_boundary(&self) -> bool {
276        self.slots
277            .entity()
278            .type_name
279            .eq_ignore_ascii_case("IFCOUTERBOUNDARYCURVE")
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use ifc_model::Value;
287
288    fn segment(transition: &str, same_sense: bool) -> Entity {
289        Entity::new(
290            "IFCCOMPOSITECURVESEGMENT",
291            vec![
292                Value::Enum(transition.into()),
293                Value::Bool(same_sense),
294                Value::Ref(EntityId(20)),
295            ],
296        )
297    }
298
299    /// An IFC4X3 curve segment's slot 2 is `SegmentStart`, not
300    /// `ParentCurve`; reading it as one must be a named refusal.
301    #[test]
302    fn an_ifc4x3_curve_segment_is_refused_by_name_not_misread() {
303        let length = |v: f64| Value::Typed {
304            type_name: "IFCLENGTHMEASURE".into(),
305            value: Box::new(Value::Real(v)),
306        };
307        let e = Entity::new(
308            "IFCCURVESEGMENT",
309            vec![
310                Value::Enum("DISCONTINUOUS".into()),
311                Value::Ref(EntityId(10)),
312                length(0.0),
313                length(50.0),
314                Value::Ref(EntityId(20)),
315            ],
316        );
317        let view = CompositeCurveSegment::new(EntityId(7), &e);
318        for error in [
319            view.parent_curve_ref().map(|_| ()).unwrap_err(),
320            view.same_sense().map(|_| ()).unwrap_err(),
321            view.param_length().map(|_| ()).unwrap_err(),
322        ] {
323            assert!(
324                matches!(
325                    &error,
326                    crate::GeometryError::Unsupported { type_name, detail, .. }
327                        if type_name == "IFCCURVESEGMENT" && *detail == CURVE_SEGMENT_UNREAD
328                ),
329                "{error}"
330            );
331        }
332    }
333
334    fn composite(type_name: &str, segments: &[u64]) -> Entity {
335        Entity::new(
336            type_name,
337            vec![
338                Value::List(segments.iter().map(|i| Value::Ref(EntityId(*i))).collect()),
339                Value::Bool(false),
340            ],
341        )
342    }
343
344    #[test]
345    fn a_reversed_segment_reports_same_sense_false_rather_than_being_normalised() {
346        let e = segment("CONTINUOUS", false);
347        let view = CompositeCurveSegment::new(EntityId(1), &e);
348        assert!(!view.same_sense().unwrap());
349        assert_eq!(view.parent_curve_ref().unwrap(), EntityId(20));
350    }
351
352    #[test]
353    fn every_transition_code_round_trips_from_its_file_token() {
354        for (token, expected) in [
355            ("DISCONTINUOUS", TransitionCode::Discontinuous),
356            ("CONTINUOUS", TransitionCode::Continuous),
357            ("CONTSAMEGRADIENT", TransitionCode::ContSameGradient),
358            (
359                "CONTSAMEGRADIENTSAMECURVATURE",
360                TransitionCode::ContSameGradientSameCurvature,
361            ),
362        ] {
363            let e = segment(token, true);
364            assert_eq!(
365                CompositeCurveSegment::new(EntityId(1), &e)
366                    .transition()
367                    .unwrap(),
368                expected,
369                "token {token}"
370            );
371        }
372    }
373
374    /// Only DISCONTINUOUS leaves a gap; the other three all mean the segments
375    /// touch, which is the question a wire builder asks.
376    #[test]
377    fn only_discontinuous_reports_a_gap_at_the_joint() {
378        assert!(!TransitionCode::Discontinuous.is_connected());
379        assert!(TransitionCode::Continuous.is_connected());
380        assert!(TransitionCode::ContSameGradient.is_connected());
381        assert!(TransitionCode::ContSameGradientSameCurvature.is_connected());
382    }
383
384    #[test]
385    fn an_unknown_transition_token_is_rejected_rather_than_defaulted() {
386        assert_eq!(TransitionCode::from_token("SMOOTHISH"), None);
387        let e = segment("SMOOTHISH", true);
388        assert!(CompositeCurveSegment::new(EntityId(1), &e)
389            .transition()
390            .is_err());
391    }
392
393    #[test]
394    fn param_length_is_absent_on_a_plain_segment_and_read_on_the_reparametrised_one() {
395        let plain = segment("CONTINUOUS", true);
396        let view = CompositeCurveSegment::new(EntityId(1), &plain);
397        assert_eq!(view.param_length().unwrap(), None);
398        assert!(!view.is_reparametrised());
399
400        let reparam = Entity::new(
401            "IFCREPARAMETRISEDCOMPOSITECURVESEGMENT",
402            vec![
403                Value::Enum("CONTINUOUS".into()),
404                Value::Bool(true),
405                Value::Ref(EntityId(20)),
406                Value::Typed {
407                    type_name: "IFCPARAMETERVALUE".into(),
408                    value: Box::new(Value::Real(2.0)),
409                },
410            ],
411        );
412        let view = CompositeCurveSegment::new(EntityId(1), &reparam);
413        assert_eq!(view.param_length().unwrap(), Some(2.0));
414        assert!(view.is_reparametrised());
415    }
416
417    #[test]
418    fn a_non_positive_param_length_is_degenerate() {
419        let e = Entity::new(
420            "IFCREPARAMETRISEDCOMPOSITECURVESEGMENT",
421            vec![
422                Value::Enum("CONTINUOUS".into()),
423                Value::Bool(true),
424                Value::Ref(EntityId(20)),
425                Value::Real(0.0),
426            ],
427        );
428        assert!(CompositeCurveSegment::new(EntityId(1), &e)
429            .param_length()
430            .is_err());
431    }
432
433    #[test]
434    fn composite_curve_segments_keep_their_file_order() {
435        let e = composite("IFCCOMPOSITECURVE", &[1, 2, 3]);
436        assert_eq!(
437            CompositeCurve::new(EntityId(1), &e).segment_refs().unwrap(),
438            vec![EntityId(1), EntityId(2), EntityId(3)]
439        );
440    }
441
442    #[test]
443    fn a_composite_curve_with_no_segments_is_degenerate() {
444        let e = composite("IFCCOMPOSITECURVE", &[]);
445        assert!(CompositeCurve::new(EntityId(1), &e).segment_refs().is_err());
446    }
447
448    /// Whether a boundary is the outline or a hole is carried by the entity
449    /// type alone, so classification cannot be skipped.
450    #[test]
451    fn boundary_curve_subtypes_are_classified_from_the_type_name() {
452        let plain = composite("IFCCOMPOSITECURVE", &[1]);
453        let on_surface = composite("IFCCOMPOSITECURVEONSURFACE", &[1]);
454        let boundary = composite("IFCBOUNDARYCURVE", &[1]);
455        let outer = composite("IFCOUTERBOUNDARYCURVE", &[1]);
456
457        assert!(!CompositeCurve::new(EntityId(1), &plain).is_on_surface());
458        assert!(CompositeCurve::new(EntityId(1), &on_surface).is_on_surface());
459        assert!(CompositeCurve::new(EntityId(1), &boundary).is_on_surface());
460        assert!(!CompositeCurve::new(EntityId(1), &boundary).is_outer_boundary());
461        assert!(CompositeCurve::new(EntityId(1), &outer).is_outer_boundary());
462    }
463}