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}