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/// A borrowed view of an `IfcCompositeCurveSegment`.
107///
108/// Also covers `IfcReparametrisedCompositeCurveSegment`, whose extra
109/// `ParamLength` is read by [`Self::param_length`].
110#[derive(Debug, Clone, Copy)]
111pub struct CompositeCurveSegment<'m> {
112    slots: Slots<'m>,
113}
114
115impl<'m> CompositeCurveSegment<'m> {
116    /// Wrap an entity known to be an `IfcCompositeCurveSegment` subtype.
117    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
118        Self {
119            slots: Slots::new(id, entity),
120        }
121    }
122
123    /// The entity id.
124    pub fn id(&self) -> EntityId {
125        self.slots.id()
126    }
127
128    /// The curve this segment is a piece of.
129    ///
130    /// Frequently shared with other segments and other composite curves, which
131    /// is precisely why [`Self::same_sense`] exists.
132    pub fn parent_curve_ref(&self) -> GeometryResult<EntityId> {
133        self.slots
134            .req_ref(segment_slot::PARENT_CURVE, "ParentCurve")
135    }
136
137    /// Is the segment traversed along the parent curve's own direction?
138    ///
139    /// `false` means reverse it before joining it to its neighbours.
140    pub fn same_sense(&self) -> GeometryResult<bool> {
141        self.slots.req_bool(segment_slot::SAME_SENSE, "SameSense")
142    }
143
144    /// The continuity asserted at the joint with the *next* segment.
145    ///
146    /// Note "next": the code describes the transition at the end of this
147    /// segment, so the last segment of an open curve says `DISCONTINUOUS`.
148    pub fn transition(&self) -> GeometryResult<TransitionCode> {
149        let token = self
150            .slots
151            .opt_enum(segment_slot::TRANSITION)
152            .ok_or_else(|| {
153                self.slots
154                    .degenerate("Transition is missing or is not an enumeration token")
155            })?;
156        TransitionCode::from_token(token).ok_or_else(|| {
157            self.slots
158                .degenerate(format!("unknown IfcTransitionCode .{token}."))
159        })
160    }
161
162    /// `ParamLength`, present only on `IfcReparametrisedCompositeCurveSegment`.
163    ///
164    /// Returns `None` for a plain `IfcCompositeCurveSegment`. When present it
165    /// rescales the segment's parameter range to `[0, ParamLength]`, so a
166    /// consumer that ignores it will evaluate the parent curve at the wrong
167    /// parameters. Values must be positive.
168    pub fn param_length(&self) -> GeometryResult<Option<f64>> {
169        let Some(value) = self.slots.opt_f64(segment_slot::PARAM_LENGTH) else {
170            return Ok(None);
171        };
172        if value > 0.0 {
173            Ok(Some(value))
174        } else {
175            Err(self
176                .slots
177                .degenerate(format!("ParamLength must be positive, found {value}")))
178        }
179    }
180
181    /// Is this the reparametrised subtype?
182    pub fn is_reparametrised(&self) -> bool {
183        self.slots
184            .entity()
185            .type_name
186            .eq_ignore_ascii_case("IFCREPARAMETRISEDCOMPOSITECURVESEGMENT")
187    }
188}
189
190/// A borrowed view of an `IfcCompositeCurve`.
191///
192/// Also covers the subtypes that add no attributes:
193/// `IfcCompositeCurveOnSurface`, `IfcBoundaryCurve`, `IfcOuterBoundaryCurve`.
194#[derive(Debug, Clone, Copy)]
195pub struct CompositeCurve<'m> {
196    slots: Slots<'m>,
197}
198
199impl<'m> CompositeCurve<'m> {
200    /// Wrap an entity known to be an `IfcCompositeCurve` subtype.
201    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
202        Self {
203            slots: Slots::new(id, entity),
204        }
205    }
206
207    /// The entity id.
208    pub fn id(&self) -> EntityId {
209        self.slots.id()
210    }
211
212    /// The `IfcCompositeCurveSegment` references, in traversal order.
213    ///
214    /// Order is significant and must not be sorted: the chain's shape is the
215    /// list order combined with each segment's `SameSense`.
216    pub fn segment_refs(&self) -> GeometryResult<Vec<EntityId>> {
217        let segments = self.slots.req_ref_list(curve_slot::SEGMENTS, "Segments")?;
218        if segments.is_empty() {
219            return Err(self
220                .slots
221                .degenerate("Segments is empty; LIST [1:?] requires at least one segment"));
222        }
223        Ok(segments)
224    }
225
226    /// The informational `SelfIntersect` flag, `None` when absent or `.U.`.
227    pub fn self_intersect(&self) -> Option<bool> {
228        self.slots.opt_bool(curve_slot::SELF_INTERSECT)
229    }
230
231    /// Is this curve constrained to lie on a surface?
232    ///
233    /// True for `IfcCompositeCurveOnSurface` and its `IfcBoundaryCurve` /
234    /// `IfcOuterBoundaryCurve` descendants, whose segments must all have
235    /// `IfcPcurve` or surface-curve parents.
236    pub fn is_on_surface(&self) -> bool {
237        matches!(
238            self.slots.entity().type_name.to_ascii_uppercase().as_str(),
239            "IFCCOMPOSITECURVEONSURFACE" | "IFCBOUNDARYCURVE" | "IFCOUTERBOUNDARYCURVE"
240        )
241    }
242
243    /// Is this the *outer* boundary of a surface?
244    ///
245    /// `IfcOuterBoundaryCurve` is the only way a file marks which of a
246    /// surface's boundaries is the outline rather than a hole, and the
247    /// distinction is carried by the entity type alone.
248    pub fn is_outer_boundary(&self) -> bool {
249        self.slots
250            .entity()
251            .type_name
252            .eq_ignore_ascii_case("IFCOUTERBOUNDARYCURVE")
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use ifc_model::Value;
260
261    fn segment(transition: &str, same_sense: bool) -> Entity {
262        Entity::new(
263            "IFCCOMPOSITECURVESEGMENT",
264            vec![
265                Value::Enum(transition.into()),
266                Value::Bool(same_sense),
267                Value::Ref(EntityId(20)),
268            ],
269        )
270    }
271
272    fn composite(type_name: &str, segments: &[u64]) -> Entity {
273        Entity::new(
274            type_name,
275            vec![
276                Value::List(segments.iter().map(|i| Value::Ref(EntityId(*i))).collect()),
277                Value::Bool(false),
278            ],
279        )
280    }
281
282    #[test]
283    fn a_reversed_segment_reports_same_sense_false_rather_than_being_normalised() {
284        let e = segment("CONTINUOUS", false);
285        let view = CompositeCurveSegment::new(EntityId(1), &e);
286        assert!(!view.same_sense().unwrap());
287        assert_eq!(view.parent_curve_ref().unwrap(), EntityId(20));
288    }
289
290    #[test]
291    fn every_transition_code_round_trips_from_its_file_token() {
292        for (token, expected) in [
293            ("DISCONTINUOUS", TransitionCode::Discontinuous),
294            ("CONTINUOUS", TransitionCode::Continuous),
295            ("CONTSAMEGRADIENT", TransitionCode::ContSameGradient),
296            (
297                "CONTSAMEGRADIENTSAMECURVATURE",
298                TransitionCode::ContSameGradientSameCurvature,
299            ),
300        ] {
301            let e = segment(token, true);
302            assert_eq!(
303                CompositeCurveSegment::new(EntityId(1), &e)
304                    .transition()
305                    .unwrap(),
306                expected,
307                "token {token}"
308            );
309        }
310    }
311
312    /// Only DISCONTINUOUS leaves a gap; the other three all mean the segments
313    /// touch, which is the question a wire builder asks.
314    #[test]
315    fn only_discontinuous_reports_a_gap_at_the_joint() {
316        assert!(!TransitionCode::Discontinuous.is_connected());
317        assert!(TransitionCode::Continuous.is_connected());
318        assert!(TransitionCode::ContSameGradient.is_connected());
319        assert!(TransitionCode::ContSameGradientSameCurvature.is_connected());
320    }
321
322    #[test]
323    fn an_unknown_transition_token_is_rejected_rather_than_defaulted() {
324        assert_eq!(TransitionCode::from_token("SMOOTHISH"), None);
325        let e = segment("SMOOTHISH", true);
326        assert!(CompositeCurveSegment::new(EntityId(1), &e)
327            .transition()
328            .is_err());
329    }
330
331    #[test]
332    fn param_length_is_absent_on_a_plain_segment_and_read_on_the_reparametrised_one() {
333        let plain = segment("CONTINUOUS", true);
334        let view = CompositeCurveSegment::new(EntityId(1), &plain);
335        assert_eq!(view.param_length().unwrap(), None);
336        assert!(!view.is_reparametrised());
337
338        let reparam = Entity::new(
339            "IFCREPARAMETRISEDCOMPOSITECURVESEGMENT",
340            vec![
341                Value::Enum("CONTINUOUS".into()),
342                Value::Bool(true),
343                Value::Ref(EntityId(20)),
344                Value::Typed {
345                    type_name: "IFCPARAMETERVALUE".into(),
346                    value: Box::new(Value::Real(2.0)),
347                },
348            ],
349        );
350        let view = CompositeCurveSegment::new(EntityId(1), &reparam);
351        assert_eq!(view.param_length().unwrap(), Some(2.0));
352        assert!(view.is_reparametrised());
353    }
354
355    #[test]
356    fn a_non_positive_param_length_is_degenerate() {
357        let e = Entity::new(
358            "IFCREPARAMETRISEDCOMPOSITECURVESEGMENT",
359            vec![
360                Value::Enum("CONTINUOUS".into()),
361                Value::Bool(true),
362                Value::Ref(EntityId(20)),
363                Value::Real(0.0),
364            ],
365        );
366        assert!(CompositeCurveSegment::new(EntityId(1), &e)
367            .param_length()
368            .is_err());
369    }
370
371    #[test]
372    fn composite_curve_segments_keep_their_file_order() {
373        let e = composite("IFCCOMPOSITECURVE", &[1, 2, 3]);
374        assert_eq!(
375            CompositeCurve::new(EntityId(1), &e).segment_refs().unwrap(),
376            vec![EntityId(1), EntityId(2), EntityId(3)]
377        );
378    }
379
380    #[test]
381    fn a_composite_curve_with_no_segments_is_degenerate() {
382        let e = composite("IFCCOMPOSITECURVE", &[]);
383        assert!(CompositeCurve::new(EntityId(1), &e).segment_refs().is_err());
384    }
385
386    /// Whether a boundary is the outline or a hole is carried by the entity
387    /// type alone, so classification cannot be skipped.
388    #[test]
389    fn boundary_curve_subtypes_are_classified_from_the_type_name() {
390        let plain = composite("IFCCOMPOSITECURVE", &[1]);
391        let on_surface = composite("IFCCOMPOSITECURVEONSURFACE", &[1]);
392        let boundary = composite("IFCBOUNDARYCURVE", &[1]);
393        let outer = composite("IFCOUTERBOUNDARYCURVE", &[1]);
394
395        assert!(!CompositeCurve::new(EntityId(1), &plain).is_on_surface());
396        assert!(CompositeCurve::new(EntityId(1), &on_surface).is_on_surface());
397        assert!(CompositeCurve::new(EntityId(1), &boundary).is_on_surface());
398        assert!(!CompositeCurve::new(EntityId(1), &boundary).is_outer_boundary());
399        assert!(CompositeCurve::new(EntityId(1), &outer).is_outer_boundary());
400    }
401}