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