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