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;