ifc_lite_geometry/profiles/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Profile Processors - Handle all IFC profile types
6//!
7//! Dynamic profile processing for parametric, arbitrary, and composite profiles.
8
9use crate::profile::Profile2D;
10use crate::tessellation::TessellationQuality;
11use crate::{Error, Point2, Point3, Result};
12use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcSchema, IfcType, ProfileCategory};
13use std::cell::Cell;
14
15mod curves_2d;
16mod curves_3d;
17mod directrix;
18mod outline;
19mod placement;
20mod shapes;
21mod steel_shapes;
22mod simplify;
23#[cfg(test)]
24mod tests;
25
26use simplify::{mirror_profile_about_y_axis, simplify_smooth_curve_polyline};
27
28/// Maximum recursion depth for nested curve processing.
29/// Prevents stack overflow from deeply nested CompositeCurve → TrimmedCurve → CompositeCurve chains.
30/// Longest nested-curve chain the profile samplers will follow.
31///
32/// This bounds ONE PATH's length and says nothing about the NUMBER of paths,
33/// which is why it is not sufficient on its own. A composite curve with two
34/// segments per level, each resolving successfully, doubles the work per level:
35/// measured at 2^levels points (levels=20 gave 1,048,577 points in 473ms), so
36/// at this cap alone a file reaches 2^50. Nothing errors on that input, so the
37/// `?` propagation in the loops below never fires. [`MAX_CURVE_NODES`] is what
38/// actually bounds the traversal.
39const MAX_CURVE_DEPTH: u32 = 50;
40
41/// Total nested curve visits allowed per entry call.
42///
43/// `MAX_CURVE_DEPTH` bounds depth; this bounds BREADTH, and a file-driven
44/// traversal needs both. An acyclic composite-curve DAG -- every branch valid,
45/// nothing cyclic, nothing failing -- costs `O(2^depth)` under a depth cap
46/// alone, in time and in the `Vec<Point3>` it materialises.
47///
48/// 100k visits is far above any real profile (a detailed composite curve runs
49/// to hundreds of segments) and far below the point where the work is
50/// noticeable. Exhausting it is reported as an error rather than truncating
51/// silently: a short polyline returned as if complete is a wrong profile, and
52/// the router dropping the element is the honest outcome.
53const MAX_CURVE_NODES: u32 = 100_000;
54
55/// One bound of an `IfcTrimmingSelect` on a trimmed conic. A `Parameter` is an
56/// angle in the project's PLANEANGLEUNIT; a `Cartesian` point is resolved to an
57/// angle against the conic's own placement and radii once those are known.
58#[derive(Debug, Clone, Copy)]
59enum TrimSelect {
60    Parameter(f64),
61    Cartesian(Point2<f64>),
62}
63
64/// Maximum recursion depth for nested profile definitions (DerivedProfile → parent → parent...).
65/// Prevents stack overflow in WASM from Revit exports with deep profile nesting.
66const MAX_PROFILE_DEPTH: u32 = 16;
67
68/// Profile processor - processes IFC profiles into 2D contours
69pub struct ProfileProcessor {
70    schema: IfcSchema,
71    /// Tessellation detail for the in-flight `process`/`get_curve_points` call.
72    /// Set at those entry points and read by the curve/arc tessellators below,
73    /// avoiding a `quality` parameter on every internal curve method. Single
74    /// router instance is single-threaded (the router holds `RefCell` caches),
75    /// so a `Cell` is sufficient. Defaults to [`TessellationQuality::Medium`].
76    active_quality: Cell<TessellationQuality>,
77    /// Remaining curve visits for the in-flight entry call. Reset wherever
78    /// `active_quality` is, and decremented on every nested curve. See
79    /// [`MAX_CURVE_NODES`].
80    curve_budget: Cell<u32>,
81}
82
83impl ProfileProcessor {
84    /// Create new profile processor
85    pub fn new(schema: IfcSchema) -> Self {
86        Self {
87            schema,
88            active_quality: Cell::new(TessellationQuality::Medium),
89            curve_budget: Cell::new(MAX_CURVE_NODES),
90        }
91    }
92
93    /// Tessellation detail selected for the current call.
94    #[inline]
95    fn quality(&self) -> TessellationQuality {
96        self.active_quality.get()
97    }
98
99    /// Charge one nested-curve visit against the entry call's budget.
100    ///
101    /// `MAX_CURVE_DEPTH` bounds one path; this bounds the whole traversal. An
102    /// acyclic composite-curve DAG fans out `2^depth` with nothing cyclic and
103    /// nothing failing, so neither a cycle guard nor the `?` propagation in the
104    /// segment loops can see it.
105    fn spend_curve_node(&self) -> Result<()> {
106        match self.curve_budget.get().checked_sub(1) {
107            Some(left) => {
108                self.curve_budget.set(left);
109                Ok(())
110            }
111            None => Err(Error::geometry(format!(
112                "Curve traversal exceeded {MAX_CURVE_NODES} nested curves"
113            ))),
114        }
115    }
116
117    /// Set the tessellation detail for subsequent curve sampling.
118    ///
119    /// [`process`](Self::process) and [`get_curve_points`](Self::get_curve_points)
120    /// set this themselves; call it explicitly before the lower-level samplers
121    /// (`get_composite_curve_points_trimmed`, `get_polyline_points_trimmed`)
122    /// that don't take a `quality` argument.
123    #[inline]
124    pub fn set_tessellation_quality(&self, quality: TessellationQuality) {
125        self.active_quality.set(quality);
126        self.curve_budget.set(MAX_CURVE_NODES);
127    }
128
129    /// Process any IFC profile definition at the given tessellation `quality`.
130    ///
131    /// Profile-plane tessellation (the 2D outline that becomes an extruded cap
132    /// or an opening cutter) never gets *finer* above `Medium` — denser opening
133    /// circles only multiply the earcut cap-bridge slivers that show up as scar
134    /// lines on plates with bolt holes (issue #976). Below `Medium` they do get
135    /// *coarser*: circular profiles via
136    /// [`TessellationQuality::circle_profile_segments`], and profile arcs/fillets
137    /// (rounded rectangles, steel-section root fillets, trimmed conics,
138    /// indexed-polycurve arcs) via [`TessellationQuality::profile_arc_segments`].
139    /// The quality knob drives the *curved 3D surfaces* instead — swept paths (via
140    /// [`get_curve_points`](Self::get_curve_points)), cylinders, surfaces of
141    /// revolution, NURBS, and brep edges — where faceting is actually visible.
142    #[inline]
143    pub fn process(
144        &self,
145        profile: &DecodedEntity,
146        decoder: &mut EntityDecoder,
147        quality: TessellationQuality,
148    ) -> Result<Profile2D> {
149        self.active_quality.set(quality);
150        self.curve_budget.set(MAX_CURVE_NODES);
151        self.process_with_depth(profile, decoder, 0)
152    }
153
154    /// Process profile with depth tracking to prevent stack overflow from nested profiles.
155    fn process_with_depth(
156        &self,
157        profile: &DecodedEntity,
158        decoder: &mut EntityDecoder,
159        depth: u32,
160    ) -> Result<Profile2D> {
161        if depth > MAX_PROFILE_DEPTH {
162            return Err(Error::geometry(format!(
163                "Profile nesting depth {} exceeds limit {} at #{}",
164                depth, MAX_PROFILE_DEPTH, profile.id
165            )));
166        }
167        match profile.ifc_type {
168            IfcType::IfcDerivedProfileDef | IfcType::IfcMirroredProfileDef => {
169                self.process_derived_with_depth(profile, decoder, depth)
170            }
171            _ => match self.schema.profile_category(&profile.ifc_type) {
172                Some(ProfileCategory::Parametric) => self.process_parametric(profile, decoder),
173                Some(ProfileCategory::Arbitrary) => self.process_arbitrary(profile, decoder),
174                Some(ProfileCategory::Composite) => self.process_composite_with_depth(profile, decoder, depth),
175                _ => Err(Error::geometry(format!(
176                    "Unsupported profile type: {}",
177                    profile.ifc_type
178                ))),
179            },
180        }
181    }
182
183    /// Process parametric profiles (rectangle, circle, I-shape, etc.)
184    #[inline]
185    fn process_parametric(
186        &self,
187        profile: &DecodedEntity,
188        decoder: &mut EntityDecoder,
189    ) -> Result<Profile2D> {
190        // First create the base profile shape
191        let mut base_profile = match profile.ifc_type {
192            IfcType::IfcRectangleProfileDef => self.process_rectangle(profile),
193            IfcType::IfcRoundedRectangleProfileDef => self.process_rounded_rectangle(profile),
194            IfcType::IfcCircleProfileDef => self.process_circle(profile),
195            IfcType::IfcCircleHollowProfileDef => self.process_circle_hollow(profile),
196            IfcType::IfcRectangleHollowProfileDef => self.process_rectangle_hollow(profile),
197            IfcType::IfcIShapeProfileDef => self.process_i_shape(profile),
198            IfcType::IfcAsymmetricIShapeProfileDef => self.process_asymmetric_i_shape(profile),
199            IfcType::IfcLShapeProfileDef => self.process_l_shape(profile),
200            IfcType::IfcUShapeProfileDef => self.process_u_shape(profile),
201            IfcType::IfcTShapeProfileDef => self.process_t_shape(profile),
202            IfcType::IfcCShapeProfileDef => self.process_c_shape(profile),
203            IfcType::IfcZShapeProfileDef => self.process_z_shape(profile),
204            _ => Err(Error::geometry(format!(
205                "Unsupported parametric profile: {}",
206                profile.ifc_type
207            ))),
208        }?;
209
210        // Parameterised profiles are defined centred on their bounding box (the
211        // Position placement below is relative to that origin), yet several
212        // builders (L/U/T/C) emit from a corner, and T/Z listed their points
213        // clockwise: centre and pin the outer loop's CCW contract here, once.
214        base_profile.center_on_bbox();
215        base_profile.make_outer_ccw();
216
217        // Apply Profile Position transform (attribute 2: IfcAxis2Placement2D)
218        if let Some(pos_attr) = profile.get(2) {
219            if !pos_attr.is_null() {
220                if let Some(pos_entity) = decoder.resolve_ref(pos_attr)? {
221                    if pos_entity.ifc_type == IfcType::IfcAxis2Placement2D {
222                        self.apply_profile_position(&mut base_profile, &pos_entity, decoder)?;
223                    }
224                }
225            }
226        }
227
228        Ok(base_profile)
229    }
230
231    /// Process IfcDerivedProfileDef / IfcMirroredProfileDef.
232    ///
233    /// IFC4 attributes:
234    ///   0: ProfileType
235    ///   1: ProfileName
236    ///   2: ParentProfile (IfcProfileDef)
237    ///   3: Operator      (IfcCartesianTransformationOperator2D)
238    ///   4: Label
239    ///
240    /// `IfcMirroredProfileDef` is a subtype that **always** writes `$` for
241    /// the Operator attribute — the mirror is implicit about the parent
242    /// profile's local Y-axis (x → −x) per IFC4. We therefore short-circuit
243    /// on the subtype and only require Operator on the bare
244    /// `IfcDerivedProfileDef` form.
245    fn process_derived_with_depth(
246        &self,
247        profile: &DecodedEntity,
248        decoder: &mut EntityDecoder,
249        depth: u32,
250    ) -> Result<Profile2D> {
251        let parent_attr = profile
252            .get(2)
253            .ok_or_else(|| Error::geometry("Derived profile missing ParentProfile".to_string()))?;
254        let parent_profile = decoder.resolve_ref(parent_attr)?.ok_or_else(|| {
255            Error::geometry("Derived profile ParentProfile not found".to_string())
256        })?;
257
258        let mut result = self.process_with_depth(&parent_profile, decoder, depth + 1)?;
259
260        if profile.ifc_type == IfcType::IfcMirroredProfileDef {
261            mirror_profile_about_y_axis(&mut result);
262            return Ok(result);
263        }
264
265        // IfcDerivedProfileDef. Operator is required per the spec but some
266        // authoring tools omit it when the derived profile happens to equal
267        // its parent; treat null as the identity transform rather than
268        // erroring (the parent already came back fully processed).
269        let Some(operator_attr) = profile.get(3) else {
270            return Ok(result);
271        };
272        if operator_attr.is_null() {
273            return Ok(result);
274        }
275        let Some(operator) = decoder.resolve_ref(operator_attr)? else {
276            return Ok(result);
277        };
278        self.apply_cartesian_transformation_operator_2d(&mut result, &operator, decoder)?;
279        Ok(result)
280    }
281
282    /// Process arbitrary closed profile (polyline-based)
283    /// IfcArbitraryClosedProfileDef: ProfileType, ProfileName, OuterCurve
284    /// IfcArbitraryProfileDefWithVoids: ProfileType, ProfileName, OuterCurve, InnerCurves
285    fn process_arbitrary(
286        &self,
287        profile: &DecodedEntity,
288        decoder: &mut EntityDecoder,
289    ) -> Result<Profile2D> {
290        // Get outer curve (attribute 2)
291        let curve_attr = profile
292            .get(2)
293            .ok_or_else(|| Error::geometry("Arbitrary profile missing OuterCurve".to_string()))?;
294
295        let curve = decoder
296            .resolve_ref(curve_attr)?
297            .ok_or_else(|| Error::geometry("Failed to resolve OuterCurve".to_string()))?;
298
299        // Process outer curve
300        let raw_outer = self.process_curve(&curve, decoder)?;
301        // Issue #635 — downsample over-tessellated smooth curves so round/
302        // curved openings produce compact extrusions (a big perf win on the
303        // exact kernel; historically also the deleted BSP polygon budget).
304        let outer_points = simplify_smooth_curve_polyline(&raw_outer, decoder.length_unit_scale());
305        let mut result = Profile2D::new(outer_points);
306
307        // Check if this is IfcArbitraryProfileDefWithVoids (has inner curves)
308        if profile.ifc_type == IfcType::IfcArbitraryProfileDefWithVoids {
309            // Get inner curves list (attribute 3)
310            if let Some(inner_curves_attr) = profile.get(3) {
311                let inner_curves = decoder.resolve_ref_list(inner_curves_attr)?;
312                for inner_curve in inner_curves {
313                    let raw_hole = self.process_curve(&inner_curve, decoder)?;
314                    let hole_points =
315                        simplify_smooth_curve_polyline(&raw_hole, decoder.length_unit_scale());
316                    result.add_hole(hole_points);
317                }
318            }
319        }
320
321        Ok(result)
322    }
323
324    /// Process any supported curve type into 2D points
325    #[inline]
326    fn process_curve(
327        &self,
328        curve: &DecodedEntity,
329        decoder: &mut EntityDecoder,
330    ) -> Result<Vec<Point2<f64>>> {
331        self.process_curve_with_depth(curve, decoder, 0)
332    }
333
334    /// Process curve with depth tracking to prevent stack overflow
335    fn process_curve_with_depth(
336        &self,
337        curve: &DecodedEntity,
338        decoder: &mut EntityDecoder,
339        depth: u32,
340    ) -> Result<Vec<Point2<f64>>> {
341        if depth > MAX_CURVE_DEPTH {
342            return Err(Error::geometry(format!(
343                "Curve nesting depth {} exceeds limit {}",
344                depth, MAX_CURVE_DEPTH
345            )));
346        }
347        self.spend_curve_node()?;
348        match curve.ifc_type {
349            IfcType::IfcPolyline => self.process_polyline(curve, decoder),
350            IfcType::IfcIndexedPolyCurve => self.process_indexed_polycurve(curve, decoder),
351            IfcType::IfcCompositeCurve => {
352                self.process_composite_curve_with_depth(curve, decoder, depth)
353            }
354            IfcType::IfcTrimmedCurve => {
355                self.process_trimmed_curve_with_depth(curve, decoder, depth)
356            }
357            IfcType::IfcCircle => self.process_circle_curve(curve, decoder),
358            IfcType::IfcEllipse => self.process_ellipse_curve(curve, decoder),
359            // A bare IfcLine projected onto the 2D plane. Rare as a profile curve,
360            // but handling it keeps trimmed-line bases (below) from erroring.
361            IfcType::IfcLine => Ok(self
362                .get_line_points_3d(curve, decoder, 0.0, 1.0)?
363                .into_iter()
364                .map(|p| Point2::new(p.x, p.y))
365                .collect()),
366            _ => Err(Error::geometry(format!(
367                "Unsupported curve type: {}",
368                curve.ifc_type
369            ))),
370        }
371    }
372
373    /// Get 3D points from a curve (for swept disk solid, etc.) at the given
374    /// tessellation `quality`.
375    #[inline]
376    pub fn get_curve_points(
377        &self,
378        curve: &DecodedEntity,
379        decoder: &mut EntityDecoder,
380        quality: TessellationQuality,
381    ) -> Result<Vec<Point3<f64>>> {
382        self.active_quality.set(quality);
383        self.curve_budget.set(MAX_CURVE_NODES);
384        self.get_curve_points_with_depth(curve, decoder, 0)
385    }
386
387    /// Get 3D curve points with depth tracking to prevent stack overflow
388    fn get_curve_points_with_depth(
389        &self,
390        curve: &DecodedEntity,
391        decoder: &mut EntityDecoder,
392        depth: u32,
393    ) -> Result<Vec<Point3<f64>>> {
394        if depth > MAX_CURVE_DEPTH {
395            return Err(Error::geometry(format!(
396                "Curve nesting depth {} exceeds limit {}",
397                depth, MAX_CURVE_DEPTH
398            )));
399        }
400        self.spend_curve_node()?;
401        match curve.ifc_type {
402            IfcType::IfcPolyline => self.process_polyline_3d(curve, decoder),
403            IfcType::IfcCompositeCurve => {
404                self.process_composite_curve_3d_with_depth(curve, decoder, depth)
405            }
406            // IFC4x3 IfcGradientCurve = IfcCompositeCurve subtype that adds a
407            // 2D BaseCurve (attr 2) supplying the horizontal layout + own
408            // segments supplying the vertical (z) profile. Points here are the
409            // horizontal track (BaseCurve, z = 0); consumers that need the
410            // elevation evaluate the profile themselves (`gradient.rs`:
411            // IfcLinearPlacement, `AlignmentCurve::from_gradient_curve`).
412            IfcType::IfcGradientCurve => {
413                if let Some(base_attr) = curve.get(2) {
414                    if !base_attr.is_null() {
415                        if let Some(base) = decoder.resolve_ref(base_attr)? {
416                            return self.get_curve_points_with_depth(&base, decoder, depth + 1);
417                        }
418                    }
419                }
420                // No BaseCurve → fall through to the segments-as-composite path
421                // so we at least produce something rather than erroring.
422                self.process_composite_curve_3d_with_depth(curve, decoder, depth)
423            }
424            IfcType::IfcCircle => self.process_circle_3d(curve, decoder),
425            IfcType::IfcIndexedPolyCurve => {
426                // Native 3D path: handles both IfcCartesianPointList2D (z=0) and
427                // IfcCartesianPointList3D, and fits arc segments in the plane of
428                // their three control points. Falling through to the 2D fallback
429                // would drop the Z coordinate of every 3D point list (issue #631
430                // stirrup case).
431                self.process_indexed_polycurve_3d(curve, decoder)
432            }
433            // A bare IfcLine directrix: P(u) = Pnt + u·V over the unit parameter
434            // range [0, 1]. Swept-disk solids that reference an untrimmed line
435            // rely on the solid's own StartParam/EndParam (applied by the swept
436            // processor) for the real extent.
437            IfcType::IfcLine => self.get_line_points_3d(curve, decoder, 0.0, 1.0),
438            IfcType::IfcTrimmedCurve => {
439                // A trimmed IfcLine has a well-defined 3D parametric form that
440                // must NOT be flattened through the 2D path (z would be dropped,
441                // and the basis IfcLine isn't handled there at all — issue #1164,
442                // where a SweptDiskSolid rebar directrix is an IfcTrimmedCurve
443                // over an IfcLine and produced an empty mesh).
444                if let Some(basis_attr) = curve.get(0) {
445                    if let Some(basis) = decoder.resolve_ref(basis_attr)? {
446                        match basis.ifc_type {
447                            IfcType::IfcLine => {
448                                return self.process_trimmed_line_3d(curve, &basis, decoder);
449                            }
450                            // A trimmed circle/ellipse must be sampled against its
451                            // own 3D placement. The 2D fallback below lifts with
452                            // z=0 and drops any out-of-plane component — rebar
453                            // bend arcs live in the XZ plane, so flattening them
454                            // twisted the swept tube (issue #1348).
455                            IfcType::IfcCircle | IfcType::IfcEllipse => {
456                                return self.process_trimmed_conic_3d(curve, &basis, decoder);
457                            }
458                            _ => {}
459                        }
460                    }
461                }
462                // Other basis curves (splines): get 2D points and lift to 3D.
463                let points_2d = self.process_trimmed_curve_with_depth(curve, decoder, depth)?;
464                Ok(points_2d
465                    .into_iter()
466                    .map(|p| Point3::new(p.x, p.y, 0.0))
467                    .collect())
468            }
469            _ => {
470                // Fallback: try 2D curve and convert to 3D
471                let points_2d = self.process_curve_with_depth(curve, decoder, depth)?;
472                Ok(points_2d
473                    .into_iter()
474                    .map(|p| Point3::new(p.x, p.y, 0.0))
475                    .collect())
476            }
477        }
478    }
479
480    /// Process composite curve into 3D points
481    fn process_composite_curve_3d_with_depth(
482        &self,
483        curve: &DecodedEntity,
484        decoder: &mut EntityDecoder,
485        depth: u32,
486    ) -> Result<Vec<Point3<f64>>> {
487        // IfcCompositeCurve: Segments, SelfIntersect
488        let segments_attr = curve
489            .get(0)
490            .ok_or_else(|| Error::geometry("CompositeCurve missing Segments".to_string()))?;
491
492        let segments = decoder.resolve_ref_list(segments_attr)?;
493        let mut result = Vec::new();
494        // Terminal of the last sparsely sampled IfcCurveSegment (fallback path
495        // below), appended after the loop so a `DistanceAlong` inside the
496        // final segment doesn't clamp to that segment's start (#859).
497        let mut last_curve_segment_terminal: Option<Point3<f64>> = None;
498
499        for segment in segments {
500            // IFC4x3 IfcCurveSegment (0 Transition, 1 Placement, 2 SegmentStart,
501            // 3 SegmentLength, 4 ParentCurve) differs from IFC2x3/IFC4
502            // IfcCompositeCurveSegment. Line / circle / clothoid parents are
503            // sampled densely (`curve_segment.rs`, #5327) so arcs stay arcs;
504            // other parents fall back to one point per segment (its
505            // Placement.Location), which the linear-placement sampler
506            // interpolates between (#859).
507            if segment.ifc_type == IfcType::IfcCurveSegment {
508                if let Some(points) = crate::curve_segment::sample_curve_segment(&segment, decoder) {
509                    for p in points {
510                        if result.last().is_none_or(|last: &Point3<f64>| (last - p).norm() > 1e-9) {
511                            result.push(p);
512                        }
513                    }
514                    last_curve_segment_terminal = None;
515                    continue;
516                }
517                if let Some(placement_attr) = segment.get(1) {
518                    if !placement_attr.is_null() {
519                        if let Some(placement) = decoder.resolve_ref(placement_attr)? {
520                            if let Some((origin, x_axis)) =
521                                axis2_placement_location_and_x_axis_3d(&placement, decoder)
522                            {
523                                if result.last().is_none_or(|last: &Point3<f64>| {
524                                    (last - origin).norm() > 1e-9
525                                }) {
526                                    result.push(origin);
527                                }
528                                // Stash the segment's projected terminal in
529                                // case this turns out to be the last segment.
530                                // Read SegmentLength (attr 3); the value may
531                                // be wrapped in an IfcLengthMeasure typed
532                                // record or be a bare REAL.
533                                let segment_length = segment
534                                    .get(3)
535                                    .and_then(|a| a.as_float())
536                                    .unwrap_or(0.0);
537                                if segment_length > 1e-9 {
538                                    last_curve_segment_terminal =
539                                        Some(origin + x_axis * segment_length);
540                                } else {
541                                    last_curve_segment_terminal = None;
542                                }
543                                continue;
544                            }
545                        }
546                    }
547                }
548                // Couldn't read this segment's placement — skip rather than fail.
549                last_curve_segment_terminal = None;
550                continue;
551            }
552            // Non-IfcCurveSegment branch (IfcCompositeCurveSegment): the
553            // explicit ParentCurve samples below already give us the segment
554            // end, so clear the stashed terminal.
555            last_curve_segment_terminal = None;
556
557            // IfcCompositeCurveSegment: Transition, SameSense, ParentCurve
558            let parent_curve_attr = segment.get(2).ok_or_else(|| {
559                Error::geometry("CompositeCurveSegment missing ParentCurve".to_string())
560            })?;
561
562            let parent_curve = decoder
563                .resolve_ref(parent_curve_attr)?
564                .ok_or_else(|| Error::geometry("Failed to resolve ParentCurve".to_string()))?;
565
566            // Get same_sense for direction
567            let same_sense = segment
568                .get(1)
569                .and_then(|v| match v {
570                    ifc_lite_core::AttributeValue::Enum(e) => Some(e.as_str()),
571                    _ => None,
572                })
573                .map(|e| e == "T" || e == "TRUE")
574                .unwrap_or(true);
575
576            let mut segment_points =
577                self.get_curve_points_with_depth(&parent_curve, decoder, depth + 1)?;
578
579            if !same_sense {
580                segment_points.reverse();
581            }
582
583            // Skip first point if we already have points (avoid duplicates)
584            if !result.is_empty() && !segment_points.is_empty() {
585                result.extend(segment_points.into_iter().skip(1));
586            } else {
587                result.extend(segment_points);
588            }
589        }
590
591        // Terminal of a sparsely sampled last segment (exact for straight
592        // segments, tangent approximation for curves) — see above.
593        if let Some(terminal) = last_curve_segment_terminal {
594            if result.last().is_none_or(|last: &Point3<f64>| {
595                (last - terminal).norm() > 1e-9
596            }) {
597                result.push(terminal);
598            }
599        }
600
601        Ok(result)
602    }
603
604    /// Process trimmed curve
605    /// IfcTrimmedCurve: BasisCurve, Trim1, Trim2, SenseAgreement, MasterRepresentation
606    fn process_trimmed_curve_with_depth(
607        &self,
608        curve: &DecodedEntity,
609        decoder: &mut EntityDecoder,
610        depth: u32,
611    ) -> Result<Vec<Point2<f64>>> {
612        // Get basis curve (attribute 0)
613        let basis_attr = curve
614            .get(0)
615            .ok_or_else(|| Error::geometry("TrimmedCurve missing BasisCurve".to_string()))?;
616
617        let basis_curve = decoder
618            .resolve_ref(basis_attr)?
619            .ok_or_else(|| Error::geometry("Failed to resolve BasisCurve".to_string()))?;
620
621        // MasterRepresentation (attribute 4) selects which trim flavour wins when
622        // both an IfcParameterValue and an IfcCartesianPoint are supplied for the
623        // same Trim*. `.CARTESIAN.` means resolve the bounds from the points;
624        // anything else (`.PARAMETER.`, `.UNSPECIFIED.`, or missing) keeps the
625        // parameter-first behaviour. Either way `extract_trim_select` falls back
626        // to whichever flavour is actually present.
627        let prefer_cartesian = curve
628            .get(4)
629            .and_then(|v| v.as_enum())
630            .map(|m| m == "CARTESIAN")
631            .unwrap_or(false);
632
633        // Get trim parameters
634        let trim1 = curve
635            .get(1)
636            .and_then(|v| self.extract_trim_select(v, prefer_cartesian, decoder));
637        let trim2 = curve
638            .get(2)
639            .and_then(|v| self.extract_trim_select(v, prefer_cartesian, decoder));
640
641        // Get sense agreement (attribute 3) - default true
642        let sense = curve
643            .get(3)
644            .and_then(|v| match v {
645                ifc_lite_core::AttributeValue::Enum(s) => Some(s == "T"),
646                _ => None,
647            })
648            .unwrap_or(true);
649
650        // Process basis curve based on type
651        match basis_curve.ifc_type {
652            IfcType::IfcCircle | IfcType::IfcEllipse => {
653                self.process_trimmed_conic(&basis_curve, trim1, trim2, sense, decoder)
654            }
655            IfcType::IfcLine => {
656                // Apply the trim parametrically in 3D, then project to 2D. The
657                // generic fallback would call process_curve_with_depth on the raw
658                // line and silently drop Trim1/Trim2 (the unit-length segment).
659                Ok(self
660                    .process_trimmed_line_3d(curve, &basis_curve, decoder)?
661                    .into_iter()
662                    .map(|p| Point2::new(p.x, p.y))
663                    .collect())
664            }
665            _ => {
666                // Fallback: try to process as a regular curve (with depth tracking)
667                self.process_curve_with_depth(&basis_curve, decoder, depth + 1)
668            }
669        }
670    }
671
672    /// Process composite curve into 2D points
673    /// IfcCompositeCurve: Segments (list of IfcCompositeCurveSegment), SelfIntersect
674    fn process_composite_curve_with_depth(
675        &self,
676        curve: &DecodedEntity,
677        decoder: &mut EntityDecoder,
678        depth: u32,
679    ) -> Result<Vec<Point2<f64>>> {
680        // Get segments list (attribute 0)
681        let segments_attr = curve
682            .get(0)
683            .ok_or_else(|| Error::geometry("CompositeCurve missing Segments".to_string()))?;
684
685        let segments = decoder.resolve_ref_list(segments_attr)?;
686
687        let mut all_points = Vec::new();
688
689        for segment in segments {
690            // IfcCompositeCurveSegment: Transition, SameSense, ParentCurve
691            if segment.ifc_type != IfcType::IfcCompositeCurveSegment {
692                continue;
693            }
694
695            // Get ParentCurve (attribute 2)
696            let parent_curve_attr = segment.get(2).ok_or_else(|| {
697                Error::geometry("CompositeCurveSegment missing ParentCurve".to_string())
698            })?;
699
700            let parent_curve = decoder
701                .resolve_ref(parent_curve_attr)?
702                .ok_or_else(|| Error::geometry("Failed to resolve ParentCurve".to_string()))?;
703
704            // Get SameSense (attribute 1) - whether to reverse the curve
705            // Note: IFC enum values like ".T." are parsed/stored as "T" without dots
706            let same_sense = segment
707                .get(1)
708                .and_then(|v| match v {
709                    ifc_lite_core::AttributeValue::Enum(s) => Some(s == "T" || s == "TRUE"),
710                    _ => None,
711                })
712                .unwrap_or(true);
713
714            // Process the parent curve (with depth tracking)
715            let mut segment_points =
716                self.process_curve_with_depth(&parent_curve, decoder, depth + 1)?;
717
718            if !same_sense {
719                segment_points.reverse();
720            }
721
722            // Append to result, avoiding duplicates at connection points
723            for pt in segment_points {
724                if all_points.last() != Some(&pt) {
725                    all_points.push(pt);
726                }
727            }
728        }
729
730        Ok(all_points)
731    }
732
733    /// Process composite profile (combination of profiles)
734    /// IfcCompositeProfileDef: ProfileType, ProfileName, Profiles, Label
735    fn process_composite_with_depth(
736        &self,
737        profile: &DecodedEntity,
738        decoder: &mut EntityDecoder,
739        depth: u32,
740    ) -> Result<Profile2D> {
741        // Get profiles list (attribute 2)
742        let profiles_attr = profile
743            .get(2)
744            .ok_or_else(|| Error::geometry("Composite profile missing Profiles".to_string()))?;
745
746        let sub_profiles = decoder.resolve_ref_list(profiles_attr)?;
747
748        if sub_profiles.is_empty() {
749            return Err(Error::geometry(
750                "Composite profile has no sub-profiles".to_string(),
751            ));
752        }
753
754        // Process first profile as base
755        let mut result = self.process_with_depth(&sub_profiles[0], decoder, depth + 1)?;
756
757        // Add remaining profiles as holes (simplified - assumes they're holes)
758        for sub_profile in &sub_profiles[1..] {
759            let hole = self.process_with_depth(sub_profile, decoder, depth + 1)?;
760            result.add_hole(hole.outer);
761        }
762
763        Ok(result)
764    }
765}
766
767/// Resolve an `IfcAxis2Placement2D` or `IfcAxis2Placement3D` into its
768/// origin point AND local X-axis (RefDirection) as a unit vector. Used to
769/// extrapolate the last `IfcCurveSegment`'s terminal point:
770/// `origin + x_axis * SegmentLength` is exact for straight segments and a
771/// tangent approximation for arcs / clothoids — both strictly better than
772/// dropping the terminal sample entirely, which caused
773/// `sample_polyline_at_distance` to clamp any product whose
774/// `DistanceAlong` fell inside the final segment to its start station.
775///
776/// IFC4x3 attribute layout:
777///   IfcAxis2Placement2D: 0 Location, 1 RefDirection
778///   IfcAxis2Placement3D: 0 Location, 1 Axis (local Z), 2 RefDirection (local X)
779///
780/// Returns `(origin, x_axis)` with `x_axis` defaulting to +X when the
781/// RefDirection is absent or zero-length (matches the EXPRESS default).
782fn axis2_placement_location_and_x_axis_3d(
783    placement: &DecodedEntity,
784    decoder: &mut EntityDecoder,
785) -> Option<(Point3<f64>, nalgebra::Vector3<f64>)> {
786    let is_3d = placement.ifc_type == IfcType::IfcAxis2Placement3D;
787    let is_2d = placement.ifc_type == IfcType::IfcAxis2Placement2D;
788    if !is_2d && !is_3d {
789        return None;
790    }
791    let location_attr = placement.get(0)?;
792    if location_attr.is_null() {
793        return None;
794    }
795    let location = decoder.resolve_ref(location_attr).ok().flatten()?;
796    if location.ifc_type != IfcType::IfcCartesianPoint {
797        return None;
798    }
799    let coords = location.get(0)?.as_list()?;
800    let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
801    let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
802    let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
803    let origin = Point3::new(x, y, z);
804
805    // RefDirection slot: index 2 on 3D, index 1 on 2D.
806    let ref_dir_idx = if is_3d { 2 } else { 1 };
807    let mut x_axis = nalgebra::Vector3::x();
808    if let Some(dir_attr) = placement.get(ref_dir_idx) {
809        if !dir_attr.is_null() {
810            if let Some(dir) = decoder.resolve_ref(dir_attr).ok().flatten() {
811                if dir.ifc_type == IfcType::IfcDirection {
812                    if let Some(ratios) = dir.get(0).and_then(|a| a.as_list()) {
813                        let dx = ratios.first().and_then(|v| v.as_float()).unwrap_or(0.0);
814                        let dy = ratios.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
815                        let dz = ratios.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
816                        let v = nalgebra::Vector3::new(dx, dy, dz);
817                        if v.norm() > 1e-12 {
818                            x_axis = v.normalize();
819                        }
820                    }
821                }
822            }
823        }
824    }
825    Some((origin, x_axis))
826}
827
828#[cfg(test)]
829#[path = "curve_fanout_tests.rs"]
830mod curve_fanout_tests;