ifc_lite_geometry/profiles/
directrix.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//! Swept-solid directrix sampling that honours a solid's `StartParam` /
6//! `EndParam` (`IfcSweptDiskSolid`, `IfcSurfaceCurveSweptAreaSolid`). One home,
7//! so the two solids cannot read the same parameters differently (#5566).
8
9use super::outline::trim_polyline;
10use super::ProfileProcessor;
11use crate::tessellation::TessellationQuality;
12use crate::{Error, Point3, Result, Vector3};
13use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
14use std::f64::consts::TAU;
15
16/// Relative parameter tolerance when mapping a trim window onto one segment.
17const PARAM_EPS: f64 = 1e-9;
18
19impl ProfileProcessor {
20    /// Sample a swept solid's directrix in 3D over the solid's
21    /// `[StartParam, EndParam]`, in the directrix's own IFC parametrisation:
22    ///   - `IfcCompositeCurve`: the running sum of each segment's parameter
23    ///     span (see [`Self::get_composite_curve_points_trimmed`]).
24    ///   - `IfcPolyline`: point index, `[0, N-1]`.
25    ///   - `IfcLine`: `P(u) = Pnt + u·V`.
26    ///   - `IfcCircle` / `IfcEllipse`: angle in the project's plane-angle unit.
27    ///
28    /// Any other directrix (an `IfcTrimmedCurve` carries its own Trim1/Trim2,
29    /// so a redundant solid-level range is a no-op; a spline's knot-based
30    /// parameter is not supported) is sampled whole.
31    pub fn get_directrix_points(
32        &self,
33        directrix: &DecodedEntity,
34        decoder: &mut EntityDecoder,
35        start_param: Option<f64>,
36        end_param: Option<f64>,
37        quality: TessellationQuality,
38    ) -> Result<Vec<Point3<f64>>> {
39        self.set_tessellation_quality(quality);
40        if start_param.is_none() && end_param.is_none() {
41            return self.get_curve_points(directrix, decoder, quality);
42        }
43        if directrix.ifc_type.is_subtype_of(IfcType::IfcCompositeCurve) {
44            return self
45                .get_composite_curve_points_trimmed(directrix, decoder, start_param, end_param);
46        }
47        match directrix.ifc_type {
48            IfcType::IfcPolyline => {
49                self.get_polyline_points_trimmed(directrix, decoder, start_param, end_param)
50            }
51            // Without this the line samples over its unit range [0,1] only and
52            // the swept extent collapses to the (tool-emitted) vector magnitude.
53            IfcType::IfcLine => self.get_line_points_3d(
54                directrix,
55                decoder,
56                start_param.unwrap_or(0.0),
57                end_param.unwrap_or(1.0),
58            ),
59            IfcType::IfcCircle | IfcType::IfcEllipse => {
60                self.get_conic_points_trimmed(directrix, decoder, start_param, end_param)
61            }
62            _ => self.get_curve_points(directrix, decoder, quality),
63        }
64    }
65
66    /// Sample a bare `IfcCircle` / `IfcEllipse` directrix over the angular
67    /// range `[start, end]` (plane-angle units; `start` defaults to 0, `end`
68    /// to the conic's domain end at one full turn). A range whose end precedes
69    /// its start wraps forward through the 0/360° seam; a range longer than
70    /// one turn is one turn.
71    fn get_conic_points_trimmed(
72        &self,
73        conic: &DecodedEntity,
74        decoder: &mut EntityDecoder,
75        start_param: Option<f64>,
76        end_param: Option<f64>,
77    ) -> Result<Vec<Point3<f64>>> {
78        let scale = decoder.plane_angle_to_radians();
79        let start = start_param.map_or(0.0, |s| s * scale);
80        let mut end = end_param.map_or(TAU, |e| e * scale);
81        if end < start {
82            end += TAU;
83        }
84        end = end.min(start + TAU);
85        if !(start.is_finite() && end.is_finite()) || end <= start {
86            return Ok(Vec::new());
87        }
88        // An explicit full parameter range denotes the same circle as omitted
89        // bounds. Keep its established tessellation (and mesh payload) stable.
90        if conic.ifc_type == IfcType::IfcCircle
91            && start.abs() <= PARAM_EPS
92            && (end - TAU).abs() <= PARAM_EPS
93        {
94            return self.process_circle_3d(conic, decoder);
95        }
96        let radius = conic
97            .get_float(1)
98            .ok_or_else(|| Error::geometry("Conic missing radius".to_string()))?;
99        let radius2 = if conic.ifc_type == IfcType::IfcEllipse {
100            conic.get_float(2).unwrap_or(radius)
101        } else {
102            radius
103        };
104        let frame = self.read_conic_placement_3d(conic, decoder)?;
105        Ok(self.sample_conic_arc_3d(frame, (radius, radius2), start, end, decoder))
106    }
107
108    /// Process composite curve into 3D points, honoring a swept solid's
109    /// `StartParam`/`EndParam`. Per ISO 10303-42 a composite curve's parameter
110    /// is the running sum of its segments' parameter spans, each span that of
111    /// the segment's `ParentCurve` ([`crate::analytic`] reads it: `N - 1` for a
112    /// polyline, the trimmed length in `IfcVector` units for a trimmed line,
113    /// the swept angle in plane-angle units for a trimmed circle). A Tekla
114    /// U-bar's `EndParam` is exactly that sum; reading each segment as a unit
115    /// interval instead swept the wrong extent for any partial range (#5566).
116    ///
117    /// Segments fully outside `[start, end]` are dropped; boundary segments are
118    /// truncated by linearly interpolating along their sampled point list, which
119    /// is exact in each supported parent's parameter (a polyline is sampled at
120    /// its vertices, an arc uniformly in angle). An out-of-range bound clamps
121    /// to the curve's domain. When any segment's parametrisation is unsupported
122    /// the composite has no well-defined parameter and is sampled whole.
123    pub fn get_composite_curve_points_trimmed(
124        &self,
125        curve: &DecodedEntity,
126        decoder: &mut EntityDecoder,
127        start_param: Option<f64>,
128        end_param: Option<f64>,
129    ) -> Result<Vec<Point3<f64>>> {
130        let segments_attr = curve
131            .get(0)
132            .ok_or_else(|| Error::geometry("CompositeCurve missing Segments".to_string()))?;
133        let segments = decoder.resolve_ref_list(segments_attr)?;
134        if segments.is_empty() {
135            return Ok(Vec::new());
136        }
137
138        let mut parts = Vec::with_capacity(segments.len());
139        let mut spans_known = true;
140        for segment in segments {
141            let parent_curve_attr = segment.get(2).ok_or_else(|| {
142                Error::geometry("CompositeCurveSegment missing ParentCurve".to_string())
143            })?;
144            let parent_curve = decoder
145                .resolve_ref(parent_curve_attr)?
146                .ok_or_else(|| Error::geometry("Failed to resolve ParentCurve".to_string()))?;
147            let same_sense = segment
148                .get(1)
149                .and_then(|v| match v {
150                    ifc_lite_core::AttributeValue::Enum(e) => Some(e.as_str()),
151                    _ => None,
152                })
153                .map(|e| e == "T" || e == "TRUE")
154                .unwrap_or(true);
155            // The analytic reader is stricter than the sampler (it rejects,
156            // e.g., a negative radius the sampler still draws). A parent it
157            // cannot read has no known span; it must not fail the solid.
158            let span = crate::analytic::composite_parent_parameter_span(&parent_curve, decoder)
159                .ok()
160                .flatten();
161            spans_known &= span.is_some();
162            parts.push((parent_curve, same_sense, span.unwrap_or(0.0)));
163        }
164
165        let (start, end) = if spans_known {
166            let total: f64 = parts.iter().map(|(_, _, span)| span).sum();
167            (
168                start_param.unwrap_or(0.0).max(0.0),
169                end_param.unwrap_or(total).min(total),
170            )
171        } else {
172            for part in &mut parts {
173                part.2 = 1.0;
174            }
175            (0.0, parts.len() as f64)
176        };
177        if end <= start {
178            return Ok(Vec::new());
179        }
180
181        let mut result: Vec<Point3<f64>> = Vec::new();
182        let mut seg_start = 0.0;
183        for (parent_curve, same_sense, span) in parts {
184            let seg_end = seg_start + span;
185            let window = (start - seg_start, end - seg_start);
186            seg_start = seg_end;
187            // Skip degenerate segments and those fully outside the trim window
188            if span <= 0.0 || window.1 <= 0.0 || window.0 >= span {
189                continue;
190            }
191
192            let mut seg_points = self.get_curve_points_with_depth(&parent_curve, decoder, 1)?;
193            if !same_sense {
194                seg_points.reverse();
195            }
196            if seg_points.len() < 2 {
197                continue;
198            }
199
200            // Map the global trim window to this segment's local [0,1] domain.
201            // A bound within parameter round-off of a segment end IS that end:
202            // the running sum of spans need not reproduce an authored total
203            // bit-for-bit, and a whole segment must stay its untouched samples.
204            let snap = |t: f64| {
205                if t <= PARAM_EPS {
206                    0.0
207                } else if t >= 1.0 - PARAM_EPS {
208                    1.0
209                } else {
210                    t
211                }
212            };
213            let local_start = snap(window.0 / span);
214            let local_end = snap(window.1 / span);
215            if local_end <= local_start {
216                continue;
217            }
218
219            let trimmed = if local_start == 0.0 && local_end == 1.0 {
220                seg_points
221            } else {
222                trim_polyline(&seg_points, local_start, local_end)
223            };
224
225            // Drop the first point of the next segment ONLY when it coincides with
226            // the last point already in `result` — i.e. the segments share their
227            // junction vertex and concatenating verbatim would duplicate it.
228            // Composite curves whose adjacent segments are not coordinate-identical
229            // at the boundary (e.g. floating-point drift, or segments stitched
230            // together at deliberately distinct points) must keep the first vertex
231            // or the directrix gets distorted.
232            const JUNCTION_EPS: f64 = 1e-6;
233            let mut iter = trimmed.into_iter();
234            if let Some(first) = iter.next() {
235                let coincident = result.last().is_some_and(|last| {
236                    (first.x - last.x).abs() < JUNCTION_EPS
237                        && (first.y - last.y).abs() < JUNCTION_EPS
238                        && (first.z - last.z).abs() < JUNCTION_EPS
239                });
240                if !coincident {
241                    result.push(first);
242                }
243                result.extend(iter);
244            }
245        }
246
247        Ok(result)
248    }
249
250    /// Sample a conic arc from `start_angle` to `end_angle` (radians in the
251    /// conic's local frame; `end < start` runs clockwise). Segment count uses
252    /// the same angular floor + chord-deviation budget as the 2D conic sampler
253    /// so density matches across the codebase.
254    pub(super) fn sample_conic_arc_3d(
255        &self,
256        (center, x_axis, y_axis): (Point3<f64>, Vector3<f64>, Vector3<f64>),
257        (radius, radius2): (f64, f64),
258        start_angle: f64,
259        end_angle: f64,
260        decoder: &mut EntityDecoder,
261    ) -> Vec<Point3<f64>> {
262        let arc_angle = (end_angle - start_angle).abs();
263        let by_angle = (arc_angle / std::f64::consts::FRAC_PI_2 * 8.0).ceil() as usize;
264        let by_chord = {
265            const CHORD_TOL_M: f64 = 5.0e-4; // 0.5 mm absolute deviation budget
266            let r_eff = radius.abs().max(radius2.abs());
267            let radius_m = r_eff * decoder.length_unit_scale();
268            if radius_m > CHORD_TOL_M {
269                let rel = (CHORD_TOL_M / radius_m).clamp(1e-9, 0.5);
270                let max_step = 2.0 * (1.0 - rel).acos();
271                if max_step > 1e-9 {
272                    (arc_angle / max_step).ceil() as usize
273                } else {
274                    0
275                }
276            } else {
277                0
278            }
279        };
280        let num_segments = self
281            .quality()
282            .profile_arc_segments(by_angle.max(by_chord), 2)
283            .min(128);
284
285        let mut points = Vec::with_capacity(num_segments + 1);
286        for i in 0..=num_segments {
287            let t = i as f64 / num_segments as f64;
288            let angle = start_angle + t * (end_angle - start_angle);
289            let p = center
290                + x_axis * (radius * angle.cos())
291                + y_axis * (radius2 * angle.sin());
292            points.push(p);
293        }
294        points
295    }
296}