Skip to main content

ifc_lite_geometry/profiles/
curves_3d.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
5use super::outline::{approximate_arc_3pt_3d, same_point_3d, trim_polyline};
6use super::ProfileProcessor;
7use crate::tessellation::scale_segments;
8use crate::{Error, Point3, Result, Vector3};
9use ifc_lite_core::{AttributeValue, DecodedEntity, EntityDecoder, IfcType};
10use std::f64::consts::PI;
11
12impl ProfileProcessor {
13    /// Read an `IfcLine`'s origin point and its geometric direction vector.
14    ///
15    /// `IfcLine` = (Pnt: IfcCartesianPoint, Dir: IfcVector). The IfcVector carries
16    /// an `Orientation` (IfcDirection) and a `Magnitude`; the line's geometric
17    /// direction is `Magnitude · normalize(Orientation)`, so the curve point at
18    /// parameter `u` is `Pnt + u · V`.
19    fn read_line_3d(
20        &self,
21        line: &DecodedEntity,
22        decoder: &mut EntityDecoder,
23    ) -> Result<(Point3<f64>, Vector3<f64>)> {
24        let pnt_attr = line
25            .get(0)
26            .ok_or_else(|| Error::geometry("Line missing Pnt".to_string()))?;
27        let pnt = decoder
28            .resolve_ref(pnt_attr)?
29            .ok_or_else(|| Error::geometry("Failed to resolve Line Pnt".to_string()))?;
30        let coords = pnt
31            .get(0)
32            .and_then(|v| v.as_list())
33            .ok_or_else(|| Error::geometry("Line Pnt missing coordinates".to_string()))?;
34        let origin = Point3::new(
35            coords.first().and_then(|v| v.as_float()).unwrap_or(0.0),
36            coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0),
37            coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0),
38        );
39
40        // Dir is an IfcVector: 0=Orientation (IfcDirection), 1=Magnitude.
41        let dir_attr = line
42            .get(1)
43            .ok_or_else(|| Error::geometry("Line missing Dir".to_string()))?;
44        let vector = decoder
45            .resolve_ref(dir_attr)?
46            .ok_or_else(|| Error::geometry("Failed to resolve Line Dir".to_string()))?;
47        let magnitude = vector.get(1).and_then(|v| v.as_float()).unwrap_or(1.0);
48        let orientation = vector
49            .get(0)
50            .and_then(|a| decoder.resolve_ref(a).ok().flatten())
51            .and_then(|d| {
52                let coords = d.get(0).and_then(|v| v.as_list())?;
53                Some(Vector3::new(
54                    coords.first().and_then(|v| v.as_float()).unwrap_or(0.0),
55                    coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0),
56                    coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0),
57                ))
58            })
59            .and_then(|v| v.try_normalize(1e-12))
60            .unwrap_or_else(|| Vector3::new(1.0, 0.0, 0.0));
61        Ok((origin, orientation * magnitude))
62    }
63
64    /// Sample a bare (untrimmed) `IfcLine` as the two-point segment spanning the
65    /// parameter range `[t_start, t_end]`. Public so the swept-disk processor can
66    /// apply a solid's `StartParam`/`EndParam` to a raw line directrix.
67    pub fn get_line_points_3d(
68        &self,
69        line: &DecodedEntity,
70        decoder: &mut EntityDecoder,
71        t_start: f64,
72        t_end: f64,
73    ) -> Result<Vec<Point3<f64>>> {
74        let (origin, v) = self.read_line_3d(line, decoder)?;
75        Ok(vec![origin + v * t_start, origin + v * t_end])
76    }
77
78    /// Resolve one `IfcTrimmingSelect` bound on a trimmed `IfcLine` to a 3D point.
79    /// A parameter bound `t` maps to `origin + t·v`; a cartesian bound is used as
80    /// authored. Mirrors [`Self::extract_trim_select`] but keeps the full 3D
81    /// coordinate (the 2D variant drops z, which a swept directrix must retain).
82    fn line_trim_point_3d(
83        &self,
84        attr: Option<&AttributeValue>,
85        origin: &Point3<f64>,
86        v: &Vector3<f64>,
87        prefer_cartesian: bool,
88        decoder: &mut EntityDecoder,
89    ) -> Option<Point3<f64>> {
90        let list = attr?.as_list()?;
91        let mut param: Option<f64> = None;
92        let mut point: Option<Point3<f64>> = None;
93        for item in list {
94            // IFCPARAMETERVALUE(value) is stored as List(["IFCPARAMETERVALUE", value]).
95            if let Some(inner) = item.as_list() {
96                if let Some(name) = inner.first().and_then(|v| v.as_string()) {
97                    if name == "IFCPARAMETERVALUE" {
98                        param = inner.get(1).and_then(|v| v.as_float());
99                        continue;
100                    }
101                }
102            }
103            if item.as_entity_ref().is_some() {
104                if let Ok(Some(pt)) = decoder.resolve_ref(item) {
105                    if pt.ifc_type == IfcType::IfcCartesianPoint {
106                        if let Some(coords) = pt.get(0).and_then(|v| v.as_list()) {
107                            point = Some(Point3::new(
108                                coords.first().and_then(|v| v.as_float()).unwrap_or(0.0),
109                                coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0),
110                                coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0),
111                            ));
112                        }
113                    }
114                }
115                continue;
116            }
117            if let Some(f) = item.as_float() {
118                param = Some(f);
119            }
120        }
121        match (prefer_cartesian, point, param) {
122            (true, Some(p), _) => Some(p),
123            (_, _, Some(t)) => Some(origin + v * t),
124            (_, Some(p), None) => Some(p),
125            _ => None,
126        }
127    }
128
129    /// Sample a trimmed `IfcLine` directrix in 3D, honoring Trim1/Trim2 (parameter
130    /// or cartesian) and SenseAgreement. Returns the segment endpoints in sweep
131    /// order.
132    pub(super) fn process_trimmed_line_3d(
133        &self,
134        trimmed: &DecodedEntity,
135        line: &DecodedEntity,
136        decoder: &mut EntityDecoder,
137    ) -> Result<Vec<Point3<f64>>> {
138        let (origin, v) = self.read_line_3d(line, decoder)?;
139        let prefer_cartesian = trimmed
140            .get(4)
141            .and_then(|m| m.as_enum())
142            .map(|m| m == "CARTESIAN")
143            .unwrap_or(false);
144        let p_start = self.line_trim_point_3d(trimmed.get(1), &origin, &v, prefer_cartesian, decoder);
145        let p_end = self.line_trim_point_3d(trimmed.get(2), &origin, &v, prefer_cartesian, decoder);
146        let sense = trimmed
147            .get(3)
148            .and_then(|x| match x {
149                AttributeValue::Enum(s) => Some(s == "T"),
150                _ => None,
151            })
152            .unwrap_or(true);
153        let start = p_start.unwrap_or(origin);
154        let end = p_end.unwrap_or(origin + v);
155        Ok(if sense {
156            vec![start, end]
157        } else {
158            vec![end, start]
159        })
160    }
161
162    /// Read a conic's placement (`IfcCircle`/`IfcEllipse` Position) as a full 3D
163    /// frame: `(center, x_axis, y_axis)`. Handles both `IfcAxis2Placement3D`
164    /// (Location + Axis(Z) + RefDirection(X), with Y = Z × X) and the planar
165    /// `IfcAxis2Placement2D` (Location + RefDirection(X) in the XY plane, Y the
166    /// 90° CCW perpendicular). Used by both the full-circle sampler and the
167    /// trimmed-arc sampler so a 3D-placed arc is never flattened to z=0.
168    fn read_conic_placement_3d(
169        &self,
170        curve: &DecodedEntity,
171        decoder: &mut EntityDecoder,
172    ) -> Result<(Point3<f64>, Vector3<f64>, Vector3<f64>)> {
173        let position_attr = curve
174            .get(0)
175            .ok_or_else(|| Error::geometry("Conic missing Position".to_string()))?;
176        let position = decoder
177            .resolve_ref(position_attr)?
178            .ok_or_else(|| Error::geometry("Failed to resolve conic position".to_string()))?;
179
180        if position.ifc_type == IfcType::IfcAxis2Placement3D {
181            // IfcAxis2Placement3D: Location, Axis (Z), RefDirection (X)
182            let loc_attr = position
183                .get(0)
184                .ok_or_else(|| Error::geometry("Axis2Placement3D missing Location".to_string()))?;
185            let loc = decoder
186                .resolve_ref(loc_attr)?
187                .ok_or_else(|| Error::geometry("Failed to resolve location".to_string()))?;
188            let coords = loc
189                .get(0)
190                .and_then(|v| v.as_list())
191                .ok_or_else(|| Error::geometry("Location missing coordinates".to_string()))?;
192            let center = Point3::new(
193                coords.first().and_then(|v| v.as_float()).unwrap_or(0.0),
194                coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0),
195                coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0),
196            );
197
198            // Get Z axis (Axis attribute)
199            let z_axis = if let Some(axis_attr) = position.get(1) {
200                if !axis_attr.is_null() {
201                    let axis = decoder.resolve_ref(axis_attr)?;
202                    if let Some(axis) = axis {
203                        let coords = axis.get(0).and_then(|v| v.as_list());
204                        if let Some(coords) = coords {
205                            Vector3::new(
206                                coords.first().and_then(|v| v.as_float()).unwrap_or(0.0),
207                                coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0),
208                                coords.get(2).and_then(|v| v.as_float()).unwrap_or(1.0),
209                            )
210                            .normalize()
211                        } else {
212                            Vector3::new(0.0, 0.0, 1.0)
213                        }
214                    } else {
215                        Vector3::new(0.0, 0.0, 1.0)
216                    }
217                } else {
218                    Vector3::new(0.0, 0.0, 1.0)
219                }
220            } else {
221                Vector3::new(0.0, 0.0, 1.0)
222            };
223
224            // Get X axis (RefDirection attribute)
225            let x_axis = if let Some(ref_attr) = position.get(2) {
226                if !ref_attr.is_null() {
227                    let ref_dir = decoder.resolve_ref(ref_attr)?;
228                    if let Some(ref_dir) = ref_dir {
229                        let coords = ref_dir.get(0).and_then(|v| v.as_list());
230                        if let Some(coords) = coords {
231                            Vector3::new(
232                                coords.first().and_then(|v| v.as_float()).unwrap_or(1.0),
233                                coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0),
234                                coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0),
235                            )
236                            .normalize()
237                        } else {
238                            Vector3::new(1.0, 0.0, 0.0)
239                        }
240                    } else {
241                        Vector3::new(1.0, 0.0, 0.0)
242                    }
243                } else {
244                    Vector3::new(1.0, 0.0, 0.0)
245                }
246            } else {
247                Vector3::new(1.0, 0.0, 0.0)
248            };
249
250            // Y axis = Z cross X
251            let y_axis = z_axis.cross(&x_axis).normalize();
252
253            Ok((center, x_axis, y_axis))
254        } else {
255            // Planar IfcAxis2Placement2D: Location + RefDirection (X) at index 1.
256            // Build the frame in the XY plane (z = 0), honouring RefDirection so a
257            // rotated arc keeps its orientation (matches the 2D conic sampler).
258            let loc_attr = position.get(0);
259            let (cx, cy) = if let Some(attr) = loc_attr {
260                let loc = decoder.resolve_ref(attr)?;
261                if let Some(loc) = loc {
262                    let coords = loc.get(0).and_then(|v| v.as_list());
263                    if let Some(coords) = coords {
264                        (
265                            coords.first().and_then(|v| v.as_float()).unwrap_or(0.0),
266                            coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0),
267                        )
268                    } else {
269                        (0.0, 0.0)
270                    }
271                } else {
272                    (0.0, 0.0)
273                }
274            } else {
275                (0.0, 0.0)
276            };
277            let x_axis = position
278                .get(1)
279                .filter(|a| !a.is_null())
280                .and_then(|a| decoder.resolve_ref(a).ok().flatten())
281                .and_then(|d| {
282                    let coords = d.get(0).and_then(|v| v.as_list())?;
283                    Some(Vector3::new(
284                        coords.first().and_then(|v| v.as_float()).unwrap_or(1.0),
285                        coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0),
286                        0.0,
287                    ))
288                })
289                .and_then(|v| v.try_normalize(1e-12))
290                .unwrap_or_else(|| Vector3::new(1.0, 0.0, 0.0));
291            // 90° CCW perpendicular in the XY plane.
292            let y_axis = Vector3::new(-x_axis.y, x_axis.x, 0.0);
293            Ok((Point3::new(cx, cy, 0.0), x_axis, y_axis))
294        }
295    }
296
297    /// Process circle curve in 3D space (for swept disk solid, etc.)
298    pub(super) fn process_circle_3d(
299        &self,
300        curve: &DecodedEntity,
301        decoder: &mut EntityDecoder,
302    ) -> Result<Vec<Point3<f64>>> {
303        // IfcCircle: Position (IfcAxis2Placement2D or 3D), Radius
304        let radius = curve
305            .get_float(1)
306            .ok_or_else(|| Error::geometry("Circle missing Radius".to_string()))?;
307
308        let (center, x_axis, y_axis) = self.read_conic_placement_3d(curve, decoder)?;
309
310        // Generate circle points in 3D (24 at Medium, scaled by quality)
311        let segments = scale_segments(24, 8, 96, self.quality());
312        let mut points = Vec::with_capacity(segments + 1);
313
314        for i in 0..=segments {
315            let angle = 2.0 * std::f64::consts::PI * i as f64 / segments as f64;
316            let p = center + x_axis * (radius * angle.cos()) + y_axis * (radius * angle.sin());
317            points.push(p);
318        }
319
320        Ok(points)
321    }
322
323    /// Sample an `IfcTrimmedCurve` whose basis is an `IfcCircle`/`IfcEllipse` in
324    /// FULL 3D, honouring the conic's 3D placement.
325    ///
326    /// The old path lifted the 2D `process_trimmed_conic` result with `z = 0`,
327    /// which silently dropped any out-of-plane component of the arc. Rebar bend
328    /// arcs (Tekla `IfcSweptDiskSolid` directrices) live in the XZ plane, so the
329    /// flattened arc landed at the wrong place and twisted the swept tube — the
330    /// L/U-bar corruption in issue #1348. Sampling against the placement's real
331    /// X/Y axes keeps the arc in its true plane.
332    pub(super) fn process_trimmed_conic_3d(
333        &self,
334        trimmed: &DecodedEntity,
335        basis: &DecodedEntity,
336        decoder: &mut EntityDecoder,
337    ) -> Result<Vec<Point3<f64>>> {
338        let radius = basis.get_float(1).unwrap_or(1.0);
339        let radius2 = if basis.ifc_type == IfcType::IfcEllipse {
340            basis.get_float(2).unwrap_or(radius)
341        } else {
342            radius
343        };
344
345        let (center, x_axis, y_axis) = self.read_conic_placement_3d(basis, decoder)?;
346
347        // MasterRepresentation (attr 4): `.CARTESIAN.` resolves bounds from the
348        // trim points; otherwise parameter angles win (matching the 2D sampler).
349        let prefer_cartesian = trimmed
350            .get(4)
351            .and_then(|v| v.as_enum())
352            .map(|m| m == "CARTESIAN")
353            .unwrap_or(false);
354
355        let sense = trimmed
356            .get(3)
357            .and_then(|v| match v {
358                AttributeValue::Enum(s) => Some(s == "T"),
359                _ => None,
360            })
361            .unwrap_or(true);
362
363        let angle_scale = decoder.plane_angle_to_radians();
364        let start_angle = self
365            .trim_to_angle_3d(
366                trimmed.get(1),
367                prefer_cartesian,
368                &center,
369                &x_axis,
370                &y_axis,
371                radius,
372                radius2,
373                angle_scale,
374                decoder,
375            )
376            .unwrap_or(0.0);
377        let mut end_angle = self
378            .trim_to_angle_3d(
379                trimmed.get(2),
380                prefer_cartesian,
381                &center,
382                &x_axis,
383                &y_axis,
384                radius,
385                radius2,
386                angle_scale,
387                decoder,
388            )
389            .unwrap_or(2.0 * PI);
390
391        // Wrap so a sense-T arc whose end angle dropped below the start (crossed
392        // the 0/360° seam) stays the short arc, not its ~360° complement.
393        if sense && end_angle < start_angle {
394            end_angle += 2.0 * PI;
395        } else if !sense && end_angle > start_angle {
396            end_angle -= 2.0 * PI;
397        }
398
399        // Segment count: same angular floor + chord-deviation budget as the 2D
400        // conic sampler so density matches across the codebase.
401        let arc_angle = (end_angle - start_angle).abs();
402        let by_angle = (arc_angle / std::f64::consts::FRAC_PI_2 * 8.0).ceil() as usize;
403        let by_chord = {
404            const CHORD_TOL_M: f64 = 5.0e-4; // 0.5 mm absolute deviation budget
405            let r_eff = radius.abs().max(radius2.abs());
406            let radius_m = r_eff * decoder.length_unit_scale();
407            if radius_m > CHORD_TOL_M {
408                let rel = (CHORD_TOL_M / radius_m).clamp(1e-9, 0.5);
409                let max_step = 2.0 * (1.0 - rel).acos();
410                if max_step > 1e-9 {
411                    (arc_angle / max_step).ceil() as usize
412                } else {
413                    0
414                }
415            } else {
416                0
417            }
418        };
419        let num_segments = self
420            .quality()
421            .profile_arc_segments(by_angle.max(by_chord), 2)
422            .min(128);
423
424        let angle_range = if sense {
425            end_angle - start_angle
426        } else {
427            start_angle - end_angle
428        };
429
430        let mut points = Vec::with_capacity(num_segments + 1);
431        for i in 0..=num_segments {
432            let t = i as f64 / num_segments as f64;
433            let angle = if sense {
434                start_angle + t * angle_range
435            } else {
436                start_angle - t * angle_range.abs()
437            };
438            let p = center
439                + x_axis * (radius * angle.cos())
440                + y_axis * (radius2 * angle.sin());
441            points.push(p);
442        }
443
444        Ok(points)
445    }
446
447    /// Resolve one bound of an `IfcTrimmingSelect` on a 3D-placed conic to an
448    /// angle in the conic's local frame. `IfcParameterValue` bounds are angles in
449    /// the project's PLANEANGLEUNIT (scaled by `angle_scale`); `IfcCartesianPoint`
450    /// bounds are projected onto the placement's X/Y axes and read off as
451    /// `atan2`. Mirrors `extract_trim_select` + the 2D `to_angle` closure, but
452    /// keeps the full 3D point so out-of-plane placements resolve correctly.
453    #[allow(clippy::too_many_arguments)]
454    fn trim_to_angle_3d(
455        &self,
456        attr: Option<&AttributeValue>,
457        prefer_cartesian: bool,
458        center: &Point3<f64>,
459        x_axis: &Vector3<f64>,
460        y_axis: &Vector3<f64>,
461        radius: f64,
462        radius2: f64,
463        angle_scale: f64,
464        decoder: &mut EntityDecoder,
465    ) -> Option<f64> {
466        let list = attr?.as_list()?;
467        let mut param: Option<f64> = None;
468        let mut point: Option<Point3<f64>> = None;
469
470        for item in list {
471            // IFCPARAMETERVALUE(value) is stored as List(["IFCPARAMETERVALUE", value]).
472            if let Some(inner_list) = item.as_list() {
473                if let Some(type_name) = inner_list.first().and_then(|v| v.as_string()) {
474                    if type_name == "IFCPARAMETERVALUE" {
475                        param = inner_list.get(1).and_then(|v| v.as_float());
476                        continue;
477                    }
478                }
479            }
480            // A reference to an IfcCartesianPoint (kept in full 3D).
481            if item.as_entity_ref().is_some() {
482                if let Ok(Some(pt)) = decoder.resolve_ref(item) {
483                    if pt.ifc_type == IfcType::IfcCartesianPoint {
484                        if let Some(coords) = pt.get(0).and_then(|v| v.as_list()) {
485                            point = Some(Point3::new(
486                                coords.first().and_then(|v| v.as_float()).unwrap_or(0.0),
487                                coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0),
488                                coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0),
489                            ));
490                        }
491                    }
492                }
493                continue;
494            }
495            // Bare numeric fallback: a parameter without the IFCPARAMETERVALUE wrapper.
496            if let Some(f) = item.as_float() {
497                param = Some(f);
498            }
499        }
500
501        let use_point = (prefer_cartesian && point.is_some()) || param.is_none();
502        if use_point {
503            if let Some(p) = point {
504                let d = p - center;
505                let lx = d.dot(x_axis);
506                let ly = d.dot(y_axis);
507                return Some((ly / radius2).atan2(lx / radius));
508            }
509        }
510        param.map(|v| v * angle_scale)
511    }
512
513    /// Process polyline into 3D points
514    pub(super) fn process_polyline_3d(
515        &self,
516        curve: &DecodedEntity,
517        decoder: &mut EntityDecoder,
518    ) -> Result<Vec<Point3<f64>>> {
519        // IfcPolyline: Points
520        let points_attr = curve
521            .get(0)
522            .ok_or_else(|| Error::geometry("Polyline missing Points".to_string()))?;
523
524        let points = decoder.resolve_ref_list(points_attr)?;
525        let mut result = Vec::with_capacity(points.len());
526
527        for point in points {
528            // IfcCartesianPoint: Coordinates
529            let coords_attr = point
530                .get(0)
531                .ok_or_else(|| Error::geometry("CartesianPoint missing Coordinates".to_string()))?;
532
533            let coords = coords_attr
534                .as_list()
535                .ok_or_else(|| Error::geometry("Coordinates is not a list".to_string()))?;
536
537            let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
538            let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
539            let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
540
541            result.push(Point3::new(x, y, z));
542        }
543
544        Ok(result)
545    }
546
547    /// Sample an `IfcPolyline` directrix and trim by parameter range.
548    /// IFC parameterises a polyline as `[0, N-1]` where `N` is the number of points
549    /// and each segment between consecutive points contributes 1.0 to the parameter.
550    /// `StartParam` / `EndParam` are converted to a fraction of the polyline and
551    /// `trim_polyline` does the actual cutting (linear interpolation between sampled
552    /// vertices, which is exact for piecewise-linear input).
553    pub fn get_polyline_points_trimmed(
554        &self,
555        curve: &DecodedEntity,
556        decoder: &mut EntityDecoder,
557        start_param: Option<f64>,
558        end_param: Option<f64>,
559    ) -> Result<Vec<Point3<f64>>> {
560        let points = self.process_polyline_3d(curve, decoder)?;
561        if points.len() < 2 {
562            return Ok(points);
563        }
564        let max_param = (points.len() - 1) as f64;
565        let s = start_param.unwrap_or(0.0).clamp(0.0, max_param);
566        let e = end_param.unwrap_or(max_param).clamp(0.0, max_param);
567        if e <= s {
568            return Ok(Vec::new());
569        }
570        // Convert IFC parameter (0..N-1) to trim_polyline's local [0,1] domain
571        Ok(trim_polyline(&points, s / max_param, e / max_param))
572    }
573
574    /// Process indexed polycurve in 3D space.
575    ///
576    /// IfcIndexedPolyCurve(Points, Segments, SelfIntersect) where Points is an
577    /// IfcCartesianPointList (2D or 3D). The 2D-only sibling at
578    /// `process_indexed_polycurve` is used for profile-defining curves; this
579    /// version is used as a directrix for IfcSweptDiskSolid and similar 3D
580    /// sweeps (issue #631 — IfcReinforcingBar stirrups).
581    ///
582    /// `IfcCartesianPointList2D` inputs are treated as planar at z=0 so the
583    /// behavior matches the 2D path on existing fixtures.
584    pub(super) fn process_indexed_polycurve_3d(
585        &self,
586        curve: &DecodedEntity,
587        decoder: &mut EntityDecoder,
588    ) -> Result<Vec<Point3<f64>>> {
589        let points_attr = curve
590            .get(0)
591            .ok_or_else(|| Error::geometry("IndexedPolyCurve missing Points".to_string()))?;
592
593        let points_list = decoder
594            .resolve_ref(points_attr)?
595            .ok_or_else(|| Error::geometry("Failed to resolve Points list".to_string()))?;
596
597        let coord_list = points_list
598            .get(0)
599            .and_then(|a| a.as_list())
600            .ok_or_else(|| Error::geometry("CartesianPointList missing CoordList".to_string()))?;
601
602        // IfcCartesianPointList3D has 3-tuples; IfcCartesianPointList2D has
603        // 2-tuples. Read whatever is there and default missing components to 0.
604        let all_points: Vec<Point3<f64>> = coord_list
605            .iter()
606            .filter_map(|coord| {
607                coord.as_list().map(|coords| {
608                    let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
609                    let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
610                    let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
611                    Point3::new(x, y, z)
612                })
613            })
614            .collect();
615
616        let segments_attr = curve.get(1);
617        if segments_attr.is_none() || segments_attr.map(|a| a.is_null()).unwrap_or(true) {
618            return Ok(all_points);
619        }
620
621        let segments = segments_attr
622            .unwrap()
623            .as_list()
624            .ok_or_else(|| Error::geometry("Expected segments list".to_string()))?;
625
626        let mut result: Vec<Point3<f64>> = Vec::new();
627        for segment in segments {
628            // Each segment is IFCLINEINDEX((i1,i2,...)) or IFCARCINDEX((i1,i2,i3)).
629            // Typed values arrive as List([String("IFCLINEINDEX"), List([indices...])]).
630            let (is_arc, indices) = if let Some(segment_list) = segment.as_list() {
631                if segment_list.len() >= 2 {
632                    let type_name = segment_list
633                        .first()
634                        .and_then(|v| v.as_string())
635                        .unwrap_or("");
636                    let is_arc_type = type_name.to_uppercase().contains("ARC");
637                    if let Some(AttributeValue::List(indices_list)) = segment_list.get(1) {
638                        (is_arc_type, Some(indices_list.as_slice()))
639                    } else {
640                        (false, Some(segment_list))
641                    }
642                } else {
643                    (false, Some(segment_list))
644                }
645            } else {
646                (false, None)
647            };
648
649            let Some(indices) = indices else { continue };
650            let idx_values: Vec<usize> = indices
651                .iter()
652                .filter_map(|v| v.as_float())
653                // 1-indexed to 0-indexed; reject non-finite, <1, or fractional
654                // values (e.g. 1.9) instead of truncating them to a wrong vertex.
655                .filter_map(|f| {
656                    if !f.is_finite() || f < 1.0 || f.fract() != 0.0 {
657                        return None;
658                    }
659                    (f as usize).checked_sub(1)
660                })
661                .collect();
662
663            if is_arc && idx_values.len() == 3 {
664                let p1 = all_points.get(idx_values[0]).copied();
665                let p2 = all_points.get(idx_values[1]).copied();
666                let p3 = all_points.get(idx_values[2]).copied();
667                if let (Some(start), Some(mid), Some(end)) = (p1, p2, p3) {
668                    // Adaptive segment count: estimate sweep from chord vs.
669                    // mid-deviation, same heuristic as the 2D path.
670                    let chord = end - start;
671                    let chord_len = chord.norm();
672                    let mid_offset = mid - Point3::new(
673                        0.5 * (start.x + end.x),
674                        0.5 * (start.y + end.y),
675                        0.5 * (start.z + end.z),
676                    );
677                    let mid_dev = mid_offset.norm();
678                    // Sweep angle phi = 4*atan(2s/c) (s = sagitta, c = chord);
679                    // the old 2*acos(s/c) was inverted. Same fix as the 2D path.
680                    let arc_estimate = if chord_len > 1e-10 {
681                        4.0 * (2.0 * mid_dev / chord_len).atan()
682                    } else {
683                        0.5
684                    };
685                    let arc_base =
686                        (arc_estimate / std::f64::consts::FRAC_PI_2 * 8.0).ceil() as usize;
687                    let num_segments = scale_segments(arc_base, 4, 16, self.quality());
688                    let arc_points = approximate_arc_3pt_3d(start, mid, end, num_segments);
689                    for pt in arc_points {
690                        if !same_point_3d(result.last(), &pt) {
691                            result.push(pt);
692                        }
693                    }
694                }
695            } else {
696                // Line segment — IfcLineIndex permits any number of indices
697                for &idx in &idx_values {
698                    if let Some(&pt) = all_points.get(idx) {
699                        if !same_point_3d(result.last(), &pt) {
700                            result.push(pt);
701                        }
702                    }
703                }
704            }
705        }
706
707        Ok(result)
708    }
709}