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