Skip to main content

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 outline;
18mod placement;
19mod shapes;
20mod steel_shapes;
21mod simplify;
22#[cfg(test)]
23mod tests;
24
25use outline::trim_polyline;
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, and the
211        // Position placement below is applied relative to that centred origin.
212        // Several asymmetric builders (L/U/T/C) emit their points from a corner, so
213        // centre every parametric profile here in one place. Already-centred shapes
214        // (rectangle, circle, I, Z, …) are unaffected.
215        base_profile.center_on_bbox();
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. The minimum-viable
409            // sampler for #859's IfcLinearPlacement use case returns the
410            // horizontal track of points by recursing into BaseCurve and
411            // dropping Z to 0 — every signal lands at the correct (x, y)
412            // station, just at the alignment's reference elevation instead
413            // of the true grade-corrected z. Full grade evaluation is a
414            // follow-up; "every signal pinned to its alignment station" is
415            // already a vast improvement over the pre-fix "all signals at
416            // world origin" state.
417            IfcType::IfcGradientCurve => {
418                if let Some(base_attr) = curve.get(2) {
419                    if !base_attr.is_null() {
420                        if let Some(base) = decoder.resolve_ref(base_attr)? {
421                            return self.get_curve_points_with_depth(&base, decoder, depth + 1);
422                        }
423                    }
424                }
425                // No BaseCurve → fall through to the segments-as-composite path
426                // so we at least produce something rather than erroring.
427                self.process_composite_curve_3d_with_depth(curve, decoder, depth)
428            }
429            IfcType::IfcCircle => self.process_circle_3d(curve, decoder),
430            IfcType::IfcIndexedPolyCurve => {
431                // Native 3D path: handles both IfcCartesianPointList2D (z=0) and
432                // IfcCartesianPointList3D, and fits arc segments in the plane of
433                // their three control points. Falling through to the 2D fallback
434                // would drop the Z coordinate of every 3D point list (issue #631
435                // stirrup case).
436                self.process_indexed_polycurve_3d(curve, decoder)
437            }
438            // A bare IfcLine directrix: P(u) = Pnt + u·V over the unit parameter
439            // range [0, 1]. Swept-disk solids that reference an untrimmed line
440            // rely on the solid's own StartParam/EndParam (applied by the swept
441            // processor) for the real extent.
442            IfcType::IfcLine => self.get_line_points_3d(curve, decoder, 0.0, 1.0),
443            IfcType::IfcTrimmedCurve => {
444                // A trimmed IfcLine has a well-defined 3D parametric form that
445                // must NOT be flattened through the 2D path (z would be dropped,
446                // and the basis IfcLine isn't handled there at all — issue #1164,
447                // where a SweptDiskSolid rebar directrix is an IfcTrimmedCurve
448                // over an IfcLine and produced an empty mesh).
449                if let Some(basis_attr) = curve.get(0) {
450                    if let Some(basis) = decoder.resolve_ref(basis_attr)? {
451                        match basis.ifc_type {
452                            IfcType::IfcLine => {
453                                return self.process_trimmed_line_3d(curve, &basis, decoder);
454                            }
455                            // A trimmed circle/ellipse must be sampled against its
456                            // own 3D placement. The 2D fallback below lifts with
457                            // z=0 and drops any out-of-plane component — rebar
458                            // bend arcs live in the XZ plane, so flattening them
459                            // twisted the swept tube (issue #1348).
460                            IfcType::IfcCircle | IfcType::IfcEllipse => {
461                                return self.process_trimmed_conic_3d(curve, &basis, decoder);
462                            }
463                            _ => {}
464                        }
465                    }
466                }
467                // Other basis curves (splines): get 2D points and lift to 3D.
468                let points_2d = self.process_trimmed_curve_with_depth(curve, decoder, depth)?;
469                Ok(points_2d
470                    .into_iter()
471                    .map(|p| Point3::new(p.x, p.y, 0.0))
472                    .collect())
473            }
474            _ => {
475                // Fallback: try 2D curve and convert to 3D
476                let points_2d = self.process_curve_with_depth(curve, decoder, depth)?;
477                Ok(points_2d
478                    .into_iter()
479                    .map(|p| Point3::new(p.x, p.y, 0.0))
480                    .collect())
481            }
482        }
483    }
484
485    /// Process composite curve into 3D points
486    fn process_composite_curve_3d_with_depth(
487        &self,
488        curve: &DecodedEntity,
489        decoder: &mut EntityDecoder,
490        depth: u32,
491    ) -> Result<Vec<Point3<f64>>> {
492        // IfcCompositeCurve: Segments, SelfIntersect
493        let segments_attr = curve
494            .get(0)
495            .ok_or_else(|| Error::geometry("CompositeCurve missing Segments".to_string()))?;
496
497        let segments = decoder.resolve_ref_list(segments_attr)?;
498        let mut result = Vec::new();
499        // Track the last IfcCurveSegment we sampled so we can extrapolate its
500        // terminal point after the loop. Each segment in the loop body emits
501        // only its START placement; without the terminal, every product whose
502        // `DistanceAlong` falls inside the FINAL segment after its start
503        // station gets clamped by `sample_polyline_at_distance` to that
504        // segment's start (i.e. authored station 800 instead of 900 on a
505        // 932-m alignment with the last segment spanning 800..932). See the
506        // post-loop block below.
507        let mut last_curve_segment_terminal: Option<Point3<f64>> = None;
508
509        for segment in segments {
510            // IFC4x3 IfcCurveSegment (alignment fixtures) has a different
511            // attribute layout from the IFC2x3/IFC4 IfcCompositeCurveSegment
512            // the original walker was written for:
513            //   IfcCurveSegment: 0 Transition, 1 Placement (IfcAxis2Placement2D/3D),
514            //                    2 SegmentStart (length measure), 3 SegmentLength,
515            //                    4 ParentCurve
516            // Without recognising it, every alignment-authored composite
517            // curve errored out at "Failed to resolve ParentCurve" (the old
518            // walker reading attr 2 hit the SegmentStart length measure),
519            // which broke #859's IfcLinearPlacement resolver — every
520            // linearly-placed signal/referent fell back to identity.
521            //
522            // Minimum-viable handling: emit the segment's Placement.Location
523            // as ONE sample point and let the linear-placement sampler
524            // interpolate linearly between segment starts. Sparse but
525            // already a vast improvement over "all at origin". A full
526            // alignment evaluator (sampling the ParentCurve inside each
527            // segment's authored start..start+length range) is follow-up
528            // scope.
529            if segment.ifc_type == IfcType::IfcCurveSegment {
530                if let Some(placement_attr) = segment.get(1) {
531                    if !placement_attr.is_null() {
532                        if let Some(placement) = decoder.resolve_ref(placement_attr)? {
533                            if let Some((origin, x_axis)) =
534                                axis2_placement_location_and_x_axis_3d(&placement, decoder)
535                            {
536                                if result.last().is_none_or(|last: &Point3<f64>| {
537                                    (last - origin).norm() > 1e-9
538                                }) {
539                                    result.push(origin);
540                                }
541                                // Stash the segment's projected terminal in
542                                // case this turns out to be the last segment.
543                                // Read SegmentLength (attr 3); the value may
544                                // be wrapped in an IfcLengthMeasure typed
545                                // record or be a bare REAL.
546                                let segment_length = segment
547                                    .get(3)
548                                    .and_then(|a| a.as_float())
549                                    .unwrap_or(0.0);
550                                if segment_length > 1e-9 {
551                                    last_curve_segment_terminal =
552                                        Some(origin + x_axis * segment_length);
553                                } else {
554                                    last_curve_segment_terminal = None;
555                                }
556                                continue;
557                            }
558                        }
559                    }
560                }
561                // Couldn't read this segment's placement — skip rather than fail.
562                last_curve_segment_terminal = None;
563                continue;
564            }
565            // Non-IfcCurveSegment branch (IfcCompositeCurveSegment): the
566            // explicit ParentCurve samples below already give us the segment
567            // end, so clear the stashed terminal.
568            last_curve_segment_terminal = None;
569
570            // IfcCompositeCurveSegment: Transition, SameSense, ParentCurve
571            let parent_curve_attr = segment.get(2).ok_or_else(|| {
572                Error::geometry("CompositeCurveSegment missing ParentCurve".to_string())
573            })?;
574
575            let parent_curve = decoder
576                .resolve_ref(parent_curve_attr)?
577                .ok_or_else(|| Error::geometry("Failed to resolve ParentCurve".to_string()))?;
578
579            // Get same_sense for direction
580            let same_sense = segment
581                .get(1)
582                .and_then(|v| match v {
583                    ifc_lite_core::AttributeValue::Enum(e) => Some(e.as_str()),
584                    _ => None,
585                })
586                .map(|e| e == "T" || e == "TRUE")
587                .unwrap_or(true);
588
589            let mut segment_points =
590                self.get_curve_points_with_depth(&parent_curve, decoder, depth + 1)?;
591
592            if !same_sense {
593                segment_points.reverse();
594            }
595
596            // Skip first point if we already have points (avoid duplicates)
597            if !result.is_empty() && !segment_points.is_empty() {
598                result.extend(segment_points.into_iter().skip(1));
599            } else {
600                result.extend(segment_points);
601            }
602        }
603
604        // Append the last IfcCurveSegment's terminal sample (exact for
605        // straight segments, tangent approximation for curves). Pre-fix the
606        // missing terminal made `sample_polyline_at_distance` clamp any
607        // product in the final segment to the segment's start station; this
608        // surfaces visibly as railway signals authored at station 900 m
609        // snapping onto the segment-start marker around station 800 m.
610        if let Some(terminal) = last_curve_segment_terminal {
611            if result.last().is_none_or(|last: &Point3<f64>| {
612                (last - terminal).norm() > 1e-9
613            }) {
614                result.push(terminal);
615            }
616        }
617
618        Ok(result)
619    }
620
621    /// Process composite curve into 3D points, honoring `IfcSweptDiskSolid`'s
622    /// `StartParam`/`EndParam`. Per IFC, a composite curve is parameterised so
623    /// segment `i` covers `[i, i+1]`. Segments fully outside `[start, end]` are
624    /// dropped; boundary segments are truncated by linearly interpolating along
625    /// their sampled point list (a per-segment normalised parameter).
626    ///
627    /// Non-conformant out-of-range `EndParam` values (notably Revit, which
628    /// emits a cumulative-per-segment parameter that can exceed `num_segments`)
629    /// are clamped to the upper bound of the spec domain — this matches the
630    /// authoring tool's effective intent (render the whole curve) without
631    /// guessing at a length-unit interpretation that proved wrong on real
632    /// files (see #631 follow-up notes).
633    pub fn get_composite_curve_points_trimmed(
634        &self,
635        curve: &DecodedEntity,
636        decoder: &mut EntityDecoder,
637        start_param: Option<f64>,
638        end_param: Option<f64>,
639    ) -> Result<Vec<Point3<f64>>> {
640        let segments_attr = curve
641            .get(0)
642            .ok_or_else(|| Error::geometry("CompositeCurve missing Segments".to_string()))?;
643        let segments = decoder.resolve_ref_list(segments_attr)?;
644        let num_segments = segments.len();
645        if num_segments == 0 {
646            return Ok(Vec::new());
647        }
648
649        let start = start_param.unwrap_or(0.0).max(0.0);
650        let end = end_param.unwrap_or(num_segments as f64).min(num_segments as f64);
651        if end <= start {
652            return Ok(Vec::new());
653        }
654
655        let mut result: Vec<Point3<f64>> = Vec::new();
656        for (idx, segment) in segments.into_iter().enumerate() {
657            let seg_start = idx as f64;
658            let seg_end = seg_start + 1.0;
659            // Skip segments fully outside the trim window
660            if seg_end <= start || seg_start >= end {
661                continue;
662            }
663
664            let parent_curve_attr = segment.get(2).ok_or_else(|| {
665                Error::geometry("CompositeCurveSegment missing ParentCurve".to_string())
666            })?;
667            let parent_curve = decoder
668                .resolve_ref(parent_curve_attr)?
669                .ok_or_else(|| Error::geometry("Failed to resolve ParentCurve".to_string()))?;
670            let same_sense = segment
671                .get(1)
672                .and_then(|v| match v {
673                    ifc_lite_core::AttributeValue::Enum(e) => Some(e.as_str()),
674                    _ => None,
675                })
676                .map(|e| e == "T" || e == "TRUE")
677                .unwrap_or(true);
678
679            let mut seg_points = self.get_curve_points_with_depth(&parent_curve, decoder, 1)?;
680            if !same_sense {
681                seg_points.reverse();
682            }
683            if seg_points.len() < 2 {
684                continue;
685            }
686
687            // Map global trim window to this segment's local [0,1] domain
688            let local_start = (start - seg_start).clamp(0.0, 1.0);
689            let local_end = (end - seg_start).clamp(0.0, 1.0);
690            if local_end <= local_start {
691                continue;
692            }
693
694            let trimmed = if local_start == 0.0 && local_end == 1.0 {
695                seg_points
696            } else {
697                trim_polyline(&seg_points, local_start, local_end)
698            };
699
700            if trimmed.is_empty() {
701                continue;
702            }
703            // Drop the first point of the next segment ONLY when it coincides with
704            // the last point already in `result` — i.e. the segments share their
705            // junction vertex and concatenating verbatim would duplicate it.
706            // Composite curves whose adjacent segments are not coordinate-identical
707            // at the boundary (e.g. floating-point drift, or segments stitched
708            // together at deliberately distinct points) must keep the first vertex
709            // or the directrix gets distorted.
710            const JUNCTION_EPS: f64 = 1e-6;
711            let mut iter = trimmed.into_iter();
712            if let Some(first) = iter.next() {
713                let coincident = result.last().is_some_and(|last| {
714                    (first.x - last.x).abs() < JUNCTION_EPS
715                        && (first.y - last.y).abs() < JUNCTION_EPS
716                        && (first.z - last.z).abs() < JUNCTION_EPS
717                });
718                if !coincident {
719                    result.push(first);
720                }
721                result.extend(iter);
722            }
723        }
724
725        Ok(result)
726    }
727
728    /// Process trimmed curve
729    /// IfcTrimmedCurve: BasisCurve, Trim1, Trim2, SenseAgreement, MasterRepresentation
730    fn process_trimmed_curve_with_depth(
731        &self,
732        curve: &DecodedEntity,
733        decoder: &mut EntityDecoder,
734        depth: u32,
735    ) -> Result<Vec<Point2<f64>>> {
736        // Get basis curve (attribute 0)
737        let basis_attr = curve
738            .get(0)
739            .ok_or_else(|| Error::geometry("TrimmedCurve missing BasisCurve".to_string()))?;
740
741        let basis_curve = decoder
742            .resolve_ref(basis_attr)?
743            .ok_or_else(|| Error::geometry("Failed to resolve BasisCurve".to_string()))?;
744
745        // MasterRepresentation (attribute 4) selects which trim flavour wins when
746        // both an IfcParameterValue and an IfcCartesianPoint are supplied for the
747        // same Trim*. `.CARTESIAN.` means resolve the bounds from the points;
748        // anything else (`.PARAMETER.`, `.UNSPECIFIED.`, or missing) keeps the
749        // parameter-first behaviour. Either way `extract_trim_select` falls back
750        // to whichever flavour is actually present.
751        let prefer_cartesian = curve
752            .get(4)
753            .and_then(|v| v.as_enum())
754            .map(|m| m == "CARTESIAN")
755            .unwrap_or(false);
756
757        // Get trim parameters
758        let trim1 = curve
759            .get(1)
760            .and_then(|v| self.extract_trim_select(v, prefer_cartesian, decoder));
761        let trim2 = curve
762            .get(2)
763            .and_then(|v| self.extract_trim_select(v, prefer_cartesian, decoder));
764
765        // Get sense agreement (attribute 3) - default true
766        let sense = curve
767            .get(3)
768            .and_then(|v| match v {
769                ifc_lite_core::AttributeValue::Enum(s) => Some(s == "T"),
770                _ => None,
771            })
772            .unwrap_or(true);
773
774        // Process basis curve based on type
775        match basis_curve.ifc_type {
776            IfcType::IfcCircle | IfcType::IfcEllipse => {
777                self.process_trimmed_conic(&basis_curve, trim1, trim2, sense, decoder)
778            }
779            IfcType::IfcLine => {
780                // Apply the trim parametrically in 3D, then project to 2D. The
781                // generic fallback would call process_curve_with_depth on the raw
782                // line and silently drop Trim1/Trim2 (the unit-length segment).
783                Ok(self
784                    .process_trimmed_line_3d(curve, &basis_curve, decoder)?
785                    .into_iter()
786                    .map(|p| Point2::new(p.x, p.y))
787                    .collect())
788            }
789            _ => {
790                // Fallback: try to process as a regular curve (with depth tracking)
791                self.process_curve_with_depth(&basis_curve, decoder, depth + 1)
792            }
793        }
794    }
795
796    /// Process composite curve into 2D points
797    /// IfcCompositeCurve: Segments (list of IfcCompositeCurveSegment), SelfIntersect
798    fn process_composite_curve_with_depth(
799        &self,
800        curve: &DecodedEntity,
801        decoder: &mut EntityDecoder,
802        depth: u32,
803    ) -> Result<Vec<Point2<f64>>> {
804        // Get segments list (attribute 0)
805        let segments_attr = curve
806            .get(0)
807            .ok_or_else(|| Error::geometry("CompositeCurve missing Segments".to_string()))?;
808
809        let segments = decoder.resolve_ref_list(segments_attr)?;
810
811        let mut all_points = Vec::new();
812
813        for segment in segments {
814            // IfcCompositeCurveSegment: Transition, SameSense, ParentCurve
815            if segment.ifc_type != IfcType::IfcCompositeCurveSegment {
816                continue;
817            }
818
819            // Get ParentCurve (attribute 2)
820            let parent_curve_attr = segment.get(2).ok_or_else(|| {
821                Error::geometry("CompositeCurveSegment missing ParentCurve".to_string())
822            })?;
823
824            let parent_curve = decoder
825                .resolve_ref(parent_curve_attr)?
826                .ok_or_else(|| Error::geometry("Failed to resolve ParentCurve".to_string()))?;
827
828            // Get SameSense (attribute 1) - whether to reverse the curve
829            // Note: IFC enum values like ".T." are parsed/stored as "T" without dots
830            let same_sense = segment
831                .get(1)
832                .and_then(|v| match v {
833                    ifc_lite_core::AttributeValue::Enum(s) => Some(s == "T" || s == "TRUE"),
834                    _ => None,
835                })
836                .unwrap_or(true);
837
838            // Process the parent curve (with depth tracking)
839            let mut segment_points =
840                self.process_curve_with_depth(&parent_curve, decoder, depth + 1)?;
841
842            if !same_sense {
843                segment_points.reverse();
844            }
845
846            // Append to result, avoiding duplicates at connection points
847            for pt in segment_points {
848                if all_points.last() != Some(&pt) {
849                    all_points.push(pt);
850                }
851            }
852        }
853
854        Ok(all_points)
855    }
856
857    /// Process composite profile (combination of profiles)
858    /// IfcCompositeProfileDef: ProfileType, ProfileName, Profiles, Label
859    fn process_composite_with_depth(
860        &self,
861        profile: &DecodedEntity,
862        decoder: &mut EntityDecoder,
863        depth: u32,
864    ) -> Result<Profile2D> {
865        // Get profiles list (attribute 2)
866        let profiles_attr = profile
867            .get(2)
868            .ok_or_else(|| Error::geometry("Composite profile missing Profiles".to_string()))?;
869
870        let sub_profiles = decoder.resolve_ref_list(profiles_attr)?;
871
872        if sub_profiles.is_empty() {
873            return Err(Error::geometry(
874                "Composite profile has no sub-profiles".to_string(),
875            ));
876        }
877
878        // Process first profile as base
879        let mut result = self.process_with_depth(&sub_profiles[0], decoder, depth + 1)?;
880
881        // Add remaining profiles as holes (simplified - assumes they're holes)
882        for sub_profile in &sub_profiles[1..] {
883            let hole = self.process_with_depth(sub_profile, decoder, depth + 1)?;
884            result.add_hole(hole.outer);
885        }
886
887        Ok(result)
888    }
889}
890
891/// Resolve an `IfcAxis2Placement2D` or `IfcAxis2Placement3D` into its
892/// origin point AND local X-axis (RefDirection) as a unit vector. Used to
893/// extrapolate the last `IfcCurveSegment`'s terminal point:
894/// `origin + x_axis * SegmentLength` is exact for straight segments and a
895/// tangent approximation for arcs / clothoids — both strictly better than
896/// dropping the terminal sample entirely, which caused
897/// `sample_polyline_at_distance` to clamp any product whose
898/// `DistanceAlong` fell inside the final segment to its start station.
899///
900/// IFC4x3 attribute layout:
901///   IfcAxis2Placement2D: 0 Location, 1 RefDirection
902///   IfcAxis2Placement3D: 0 Location, 1 Axis (local Z), 2 RefDirection (local X)
903///
904/// Returns `(origin, x_axis)` with `x_axis` defaulting to +X when the
905/// RefDirection is absent or zero-length (matches the EXPRESS default).
906fn axis2_placement_location_and_x_axis_3d(
907    placement: &DecodedEntity,
908    decoder: &mut EntityDecoder,
909) -> Option<(Point3<f64>, nalgebra::Vector3<f64>)> {
910    let is_3d = placement.ifc_type == IfcType::IfcAxis2Placement3D;
911    let is_2d = placement.ifc_type == IfcType::IfcAxis2Placement2D;
912    if !is_2d && !is_3d {
913        return None;
914    }
915    let location_attr = placement.get(0)?;
916    if location_attr.is_null() {
917        return None;
918    }
919    let location = decoder.resolve_ref(location_attr).ok().flatten()?;
920    if location.ifc_type != IfcType::IfcCartesianPoint {
921        return None;
922    }
923    let coords = location.get(0)?.as_list()?;
924    let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
925    let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
926    let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
927    let origin = Point3::new(x, y, z);
928
929    // RefDirection slot: index 2 on 3D, index 1 on 2D.
930    let ref_dir_idx = if is_3d { 2 } else { 1 };
931    let mut x_axis = nalgebra::Vector3::x();
932    if let Some(dir_attr) = placement.get(ref_dir_idx) {
933        if !dir_attr.is_null() {
934            if let Some(dir) = decoder.resolve_ref(dir_attr).ok().flatten() {
935                if dir.ifc_type == IfcType::IfcDirection {
936                    if let Some(ratios) = dir.get(0).and_then(|a| a.as_list()) {
937                        let dx = ratios.first().and_then(|v| v.as_float()).unwrap_or(0.0);
938                        let dy = ratios.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
939                        let dz = ratios.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
940                        let v = nalgebra::Vector3::new(dx, dy, dz);
941                        if v.norm() > 1e-12 {
942                            x_axis = v.normalize();
943                        }
944                    }
945                }
946            }
947        }
948    }
949    Some((origin, x_axis))
950}
951
952#[cfg(test)]
953#[path = "curve_fanout_tests.rs"]
954mod curve_fanout_tests;