ifc_lite_geometry/
alignment.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//! `IfcAlignmentCurve` evaluation — horizontal + vertical alignment
6//! curves used as the directrix of `IfcSectionedSolidHorizontal`.
7//!
8//! Scope: IFC4x1 alignment entities. The supported-schema enum retains their
9//! exact names; dispatch uses cached `IfcType::from_str` lookups.
10//!
11//! ## Horizontal segments
12//! - `IfcLineSegment2D`           — straight tangent
13//! - `IfcCircularArcSegment2D`    — constant radius arc, with `IsCCW`
14//! - `IfcTransitionCurveSegment2D` — clothoid / spiral (linear-curvature
15//!   transition); other transition curve subtypes (Bloss, cubic
16//!   parabola, sine, cosine) degrade to a clothoid with the same
17//!   end-curvatures, which is a known approximation but produces
18//!   continuous geometry instead of a discontinuity.
19//!
20//! ## Vertical segments (all parameterised on horizontal distance)
21//! - `IfcAlignment2DVerSegLine`         — constant gradient
22//! - `IfcAlignment2DVerSegCircularArc`  — circular profile
23//! - `IfcAlignment2DVerSegParabolicArc` — parabolic profile
24//!
25//! Output frame at station `s` has +X right of travel, +Z up (global
26//! vertical), +Y along travel. Used by `SectionedSolidHorizontalProcessor`
27//! to place each cross-section in 3D space.
28
29use ifc_lite_core::{AttributeValue, DecodedEntity, EntityDecoder, IfcType};
30use nalgebra::{Point3, Vector3};
31use std::sync::OnceLock;
32
33use crate::gradient::GradientProfile;
34use crate::profiles::ProfileProcessor;
35use crate::{Error, Result, TessellationQuality};
36use ifc_lite_core::IfcSchema;
37
38// --- Cached IFC type lookup for IFC4x1 alignment names ---
39
40macro_rules! ifc_type_fn {
41    ($name:ident, $literal:expr) => {
42        fn $name() -> IfcType {
43            static T: OnceLock<IfcType> = OnceLock::new();
44            T.get_or_init(|| IfcType::from_str($literal)).clone()
45        }
46    };
47}
48
49ifc_type_fn!(t_alignment_curve, "IFCALIGNMENTCURVE");
50ifc_type_fn!(t_alignment_2d_horizontal, "IFCALIGNMENT2DHORIZONTAL");
51ifc_type_fn!(t_alignment_2d_horizontal_segment, "IFCALIGNMENT2DHORIZONTALSEGMENT");
52ifc_type_fn!(t_alignment_2d_vertical, "IFCALIGNMENT2DVERTICAL");
53ifc_type_fn!(t_line_segment_2d, "IFCLINESEGMENT2D");
54ifc_type_fn!(t_circular_arc_segment_2d, "IFCCIRCULARARCSEGMENT2D");
55ifc_type_fn!(t_transition_curve_segment_2d, "IFCTRANSITIONCURVESEGMENT2D");
56ifc_type_fn!(t_ver_seg_line, "IFCALIGNMENT2DVERSEGLINE");
57ifc_type_fn!(t_ver_seg_parabolic, "IFCALIGNMENT2DVERSEGPARABOLICARC");
58ifc_type_fn!(t_ver_seg_circular, "IFCALIGNMENT2DVERSEGCIRCULARARC");
59
60/// IFC4x1 `IfcTransitionCurveType` enumeration. The curvature varies
61/// from `start_curv` at `s=0` to `end_curv` at `s=L` along a profile
62/// that depends on the subtype.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum TransitionKind {
65    /// Linear curvature: `κ(s) = κ₀ + (κ₁-κ₀)·(s/L)`. Euler spiral.
66    Clothoid,
67    /// Cubic smoothstep: `κ(s) = κ₀ + (κ₁-κ₀)·(3u² − 2u³)` (u = s/L).
68    Bloss,
69    /// Cosine taper: `κ(s) = κ₀ + (κ₁-κ₀)·(1 − cos(π·u))/2`.
70    Cosine,
71    /// Sine taper: `κ(s) = κ₀ + (κ₁-κ₀)·(u − sin(2π·u)/(2π))`.
72    Sine,
73    /// Approximated as clothoid — the canonical cubic-parabola
74    /// formulation `y = x³/(6RL)` is linear-in-x curvature, which is
75    /// `≈` linear-in-s for short transitions (the railway-engineering
76    /// regime where this subtype is authored).
77    CubicParabola,
78    /// Same approximation as `Bloss` (quintic smooth blend) — the
79    /// biquadratic-parabola subtype has two parabolic halves whose
80    /// curvature joins continuously at `s=L/2`, equivalent visually
81    /// to a Bloss blend for typical transition lengths.
82    BiquadraticParabola,
83}
84
85impl TransitionKind {
86    fn from_enum(name: &str) -> Self {
87        match name {
88            "CLOTHOIDCURVE" => Self::Clothoid,
89            "BLOSSCURVE" => Self::Bloss,
90            "COSINECURVE" => Self::Cosine,
91            "SINECURVE" => Self::Sine,
92            "CUBICPARABOLA" => Self::CubicParabola,
93            "BIQUADRATICPARABOLA" => Self::BiquadraticParabola,
94            // Unknown subtypes fall back to clothoid (most common).
95            _ => Self::Clothoid,
96        }
97    }
98
99    /// `g(u) = ∫₀ᵘ shape(v) dv` where `shape(v)` is the curvature blend
100    /// profile (=0 at u=0, =1 at u=1). Used to evaluate the heading
101    /// closed-form: `h(s) = h₀ + κ₀·s + (κ₁-κ₀)·L·g(s/L)`.
102    fn heading_integral(self, u: f64) -> f64 {
103        let u = u.clamp(0.0, 1.0);
104        match self {
105            Self::Clothoid | Self::CubicParabola => 0.5 * u * u,
106            Self::Bloss | Self::BiquadraticParabola => {
107                // ∫(3v² − 2v³)dv = v³ − v⁴/2
108                u * u * u - 0.5 * u * u * u * u
109            }
110            Self::Cosine => {
111                // ∫½(1 − cos(πv))dv = v/2 − sin(πv)/(2π)
112                0.5 * u - (std::f64::consts::PI * u).sin() / (2.0 * std::f64::consts::PI)
113            }
114            Self::Sine => {
115                // ∫(v − sin(2πv)/(2π))dv = v²/2 + cos(2πv)/(4π²) − 1/(4π²)
116                let two_pi = 2.0 * std::f64::consts::PI;
117                0.5 * u * u + ((two_pi * u).cos() - 1.0) / (4.0 * std::f64::consts::PI.powi(2))
118            }
119        }
120    }
121}
122
123/// `True` if the attribute is an IFC boolean enum `.T.`. Anything else
124/// (including `.F.`, `.U.`, missing, or wrong shape) reads as `false`.
125fn read_bool(attr: Option<&AttributeValue>) -> bool {
126    attr.and_then(|v| v.as_enum()).map(|s| s == "T").unwrap_or(false)
127}
128
129/// Cumulative-station-keyed horizontal directrix segment.
130#[derive(Debug, Clone, Copy)]
131enum HSeg {
132    Line {
133        sx: f64,
134        sy: f64,
135        heading: f64,
136        length: f64,
137        cum_start: f64,
138    },
139    Arc {
140        sx: f64,
141        sy: f64,
142        heading: f64,
143        radius: f64,
144        length: f64,
145        ccw: bool,
146        cum_start: f64,
147    },
148    /// Transition curve. Curvature varies smoothly from `start_curv` to
149    /// `end_curv` along arc length according to the `kind`'s blend
150    /// profile. Position evaluated by numerical integration of
151    /// `(cos h(s), sin h(s))` ds — no closed form for the Fresnel-style
152    /// integrals these curves produce.
153    Transition {
154        sx: f64,
155        sy: f64,
156        heading: f64,
157        length: f64,
158        start_curv: f64,
159        end_curv: f64,
160        kind: TransitionKind,
161        cum_start: f64,
162    },
163}
164
165#[derive(Debug, Clone, Copy)]
166enum VSeg {
167    Line {
168        start: f64,
169        length: f64,
170        h0: f64,
171        g0: f64,
172    },
173    Parabolic {
174        start: f64,
175        length: f64,
176        h0: f64,
177        g0: f64,
178        parabola_constant: f64,
179        is_convex: bool,
180    },
181    /// Circular vertical curve. For typical highway radii (>500m) over
182    /// segment lengths in the tens of metres, the parabolic approximation
183    /// `z ≈ z0 + g0·s + ±s²/(2R)` is accurate to sub-millimetre — same
184    /// formula as the parabolic segment with `parabola_constant = R`.
185    CircularArc {
186        start: f64,
187        length: f64,
188        h0: f64,
189        g0: f64,
190        radius: f64,
191        is_convex: bool,
192    },
193}
194
195/// Cross-section placement frame at a station.
196#[derive(Debug, Clone, Copy)]
197pub struct AlignmentFrame {
198    /// Origin of the cross-section's local 2D coords (px=0, py=0).
199    pub origin: Point3<f64>,
200    /// Right of travel in the horizontal plane. Profile's local +X
201    /// when `FixedAxisVertical = true`. Always lies in the world XY
202    /// plane: `(sin h, −cos h, 0)`.
203    pub right: Vector3<f64>,
204    /// Up (global +Z). Profile's local +Y when `FixedAxisVertical = true`.
205    pub up: Vector3<f64>,
206    /// Unit tangent of the 3D directrix at this station. Includes the
207    /// longitudinal slope from the vertical alignment.
208    pub tangent: Vector3<f64>,
209}
210
211/// Parsed alignment curve. Holds horizontal and vertical segments in
212/// authored order with cumulative-start stations precomputed.
213pub struct AlignmentCurve {
214    horizontal: Vec<HSeg>,
215    vertical: Vec<VSeg>,
216    /// IFC4x3 `IfcGradientCurve` vertical profile; when present it
217    /// replaces `vertical` (see [`Self::from_gradient_curve`]).
218    gradient: Option<GradientProfile>,
219}
220
221impl AlignmentCurve {
222    /// Parse `IfcAlignmentCurve` (or any directrix we can reduce to a
223    /// piecewise alignment). Recognised cases:
224    ///
225    /// - `IfcAlignmentCurve` — horizontal + (optional) vertical parsing.
226    /// - `IfcPolyline` — synthesised as a chain of line segments. Each
227    ///   polyline edge becomes one `HSeg::Line` (in XY) and one
228    ///   `VSeg::Line` (with gradient = dz / horizontal length). This
229    ///   covers the relatively rare case of a sectioned-solid authored
230    ///   with a polyline directrix, which is spec-allowed but uncommon.
231    ///
232    /// - `IfcGradientCurve` (IFC4x3) — see [`Self::from_gradient_curve`].
233    ///
234    /// Returns `Ok(None)` for any other directrix so the caller can
235    /// fall back to a straight-line sweep. Errors only on malformed
236    /// recognised input (e.g. an `IfcAlignmentCurve` missing
237    /// `Horizontal`).
238    pub fn parse(directrix: &DecodedEntity, decoder: &mut EntityDecoder) -> Result<Option<Self>> {
239        Self::parse_with_policy(directrix, decoder, false)
240    }
241
242    pub(crate) fn parse_for_sampling(directrix: &DecodedEntity, decoder: &mut EntityDecoder) -> Result<Option<Self>> {
243        Self::parse_with_policy(directrix, decoder, true)
244    }
245
246    fn parse_with_policy(directrix: &DecodedEntity, decoder: &mut EntityDecoder, strict: bool) -> Result<Option<Self>> {
247        if directrix.ifc_type == IfcType::IfcPolyline {
248            return Self::from_polyline(directrix, decoder, strict).map(Some);
249        }
250        if directrix.ifc_type == IfcType::IfcGradientCurve {
251            if strict {
252                crate::alignment_sampling_curve::validate_composite(directrix, decoder)?;
253                for attr in directrix.get_list(0).ok_or_else(|| Error::geometry("GradientCurve missing Segments"))? {
254                    let id = attr.as_entity_ref().ok_or_else(|| Error::geometry("Gradient segment must be a reference"))?;
255                    let segment = decoder.decode_by_id(id)?;
256                    crate::alignment_sampling_curve::validate_segment_placement(&segment, decoder)?;
257                }
258            }
259            return Self::from_gradient_curve(directrix, decoder);
260        }
261        if directrix.ifc_type == IfcType::IfcCompositeCurve {
262            if strict { crate::alignment_sampling_curve::validate_composite(directrix, decoder)?; }
263            return Self::from_sampled_curve(directrix, decoder);
264        }
265        if directrix.ifc_type != t_alignment_curve() {
266            return Ok(None);
267        }
268
269        let angle_scale = decoder.plane_angle_to_radians();
270
271        // attr 0 = Horizontal (required)
272        let h_id = directrix.get_ref(0).ok_or_else(|| {
273            Error::geometry("IfcAlignmentCurve missing Horizontal".to_string())
274        })?;
275        // `horizontal_base` is the horizontal alignment's `StartDistAlong`
276        // — the chainage value at the physical start of the alignment.
277        // The horizontal geometry itself is indexed by a cumulative
278        // segment-length station that starts at 0, so this base is only
279        // needed to rebase the vertical segments (whose `StartDistAlong`
280        // are authored as absolute chainages in the same domain) onto
281        // that same 0-origin axis. See `parse_vertical`.
282        let (horizontal, horizontal_base) = parse_horizontal(h_id, decoder, angle_scale, strict)?;
283
284        // attr 1 = Vertical (optional)
285        let vertical = match directrix.get(1) {
286            Some(v) if !v.is_null() => match v.as_entity_ref() {
287                Some(v_id) => parse_vertical(v_id, decoder, horizontal_base, strict)?,
288                None if strict => return Err(Error::geometry("Vertical must be a reference")),
289                None => Vec::new(),
290            },
291            _ => Vec::new(),
292        };
293
294        Ok(Some(Self { horizontal, vertical, gradient: None }))
295    }
296
297    pub(crate) fn uses_approximation(&self) -> bool {
298        self.gradient.is_some() || self.horizontal.iter().any(|segment| matches!(segment,
299            HSeg::Transition { kind: TransitionKind::CubicParabola | TransitionKind::BiquadraticParabola, .. }))
300    }
301
302    /// Total length of the horizontal alignment (sum of segment lengths).
303    pub fn horizontal_length(&self) -> f64 {
304        self.horizontal
305            .last()
306            .map(|s| h_cum_start(s) + h_length(s))
307            .unwrap_or(0.0)
308    }
309
310    /// Stations where the elevation derivative may change abruptly. Arc
311    /// length integration must split here, including polyline corners.
312    pub(crate) fn station_breaks(&self) -> Vec<f64> {
313        let mut breaks = vec![0.0, self.horizontal_length()];
314        for seg in &self.horizontal {
315            breaks.push(h_cum_start(seg));
316            breaks.push(h_cum_start(seg) + h_length(seg));
317        }
318        for seg in &self.vertical {
319            breaks.push(v_start(seg));
320            breaks.push(v_start(seg) + v_length(seg));
321        }
322        if let Some(profile) = &self.gradient {
323            breaks.extend(profile.station_breaks().map(|s| s - profile.first_station()));
324        }
325        breaks.retain(|s| s.is_finite() && *s >= 0.0 && *s <= self.horizontal_length());
326        breaks.sort_by(f64::total_cmp);
327        breaks.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
328        breaks
329    }
330
331    /// Build an alignment from an `IfcPolyline` directrix. Each
332    /// polyline edge becomes one horizontal Line segment plus one
333    /// vertical Line segment so the unified `evaluate(station)` path
334    /// works without special-casing in the processor.
335    fn from_polyline(curve: &DecodedEntity, decoder: &mut EntityDecoder, strict: bool) -> Result<Self> {
336        let points_attr = curve
337            .get(0)
338            .ok_or_else(|| Error::geometry("IfcPolyline missing Points".to_string()))?;
339        let point_refs = points_attr
340            .as_list()
341            .ok_or_else(|| Error::geometry("IfcPolyline Points is not a list".to_string()))?;
342        if point_refs.len() < 2 {
343            return Err(Error::geometry(
344                "IfcPolyline directrix needs ≥ 2 points".to_string(),
345            ));
346        }
347        let mut pts: Vec<(f64, f64, f64)> = Vec::with_capacity(point_refs.len());
348        for r in point_refs {
349            let pid = r
350                .as_entity_ref()
351                .ok_or_else(|| Error::geometry("Polyline point is not an entity ref".to_string()))?;
352            let p = decoder.decode_by_id(pid)?;
353            if strict && p.ifc_type != IfcType::IfcCartesianPoint {
354                return Err(Error::geometry(format!("Polyline point #{} is not IfcCartesianPoint", pid)));
355            }
356            let coords = p
357                .get_list(0)
358                .ok_or_else(|| Error::geometry("CartesianPoint missing Coordinates".to_string()))?;
359            if strict && (!(2..=3).contains(&coords.len()) || coords.iter().any(|v| v.as_float().is_none_or(|n| !n.is_finite()))) {
360                return Err(Error::geometry(format!("Malformed CartesianPoint #{}", pid)));
361            }
362            let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
363            let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
364            let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
365            pts.push((x, y, z));
366        }
367        if strict && pts.windows(2).any(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1) < 1e-12 && (p[1].2 - p[0].2).abs() > 1e-12) {
368            return Err(Error::geometry("A vertical polyline edge cannot use horizontal-distance stationing"));
369        }
370        Self::from_points(&pts)
371    }
372
373    /// IFC4x3 `IfcGradientCurve` directrix: the horizontal layout is its
374    /// 2D `BaseCurve` (attr 2), sampled densely through the shared curve
375    /// sampler (arcs and clothoids included, `curve_segment.rs`) into
376    /// line segments; the elevation is the curve's own vertical profile
377    /// (`gradient.rs`), both indexed by horizontal station. `Ok(None)`
378    /// when either half is missing, so the caller keeps its fallback.
379    fn from_gradient_curve(curve: &DecodedEntity, decoder: &mut EntityDecoder) -> Result<Option<Self>> {
380        let Some(profile) = GradientProfile::from_curve(curve, decoder) else {
381            return Ok(None);
382        };
383        let Some(base_id) = curve.get_ref(2) else {
384            return Ok(None);
385        };
386        let base = decoder.decode_by_id(base_id)?;
387        let samples = ProfileProcessor::new(IfcSchema::new()).get_curve_points(
388            &base,
389            decoder,
390            TessellationQuality::Medium,
391        )?;
392        if samples.len() < 2 {
393            return Ok(None);
394        }
395        let pts: Vec<(f64, f64, f64)> = samples.iter().map(|p| (p.x, p.y, 0.0)).collect();
396        let mut alignment = Self::from_points(&pts)?;
397        alignment.vertical.clear();
398        alignment.gradient = Some(profile);
399        Ok(Some(alignment))
400    }
401
402    /// Any other curve the shared sampler understands (e.g. an IFC4x3
403    /// `'FootPrint'` `IfcCompositeCurve` of line / arc / clothoid segments):
404    /// densely sampled into a point chain, elevation taken from the points.
405    fn from_sampled_curve(curve: &DecodedEntity, decoder: &mut EntityDecoder) -> Result<Option<Self>> {
406        let samples = ProfileProcessor::new(IfcSchema::new()).get_curve_points(
407            curve,
408            decoder,
409            TessellationQuality::Medium,
410        )?;
411        if samples.len() < 2 {
412            return Ok(None);
413        }
414        let pts: Vec<(f64, f64, f64)> = samples.iter().map(|p| (p.x, p.y, p.z)).collect();
415        Self::from_points(&pts).map(Some)
416    }
417
418    /// One horizontal + one vertical Line segment per edge of a 3D point
419    /// chain.
420    fn from_points(pts: &[(f64, f64, f64)]) -> Result<Self> {
421        let mut horizontal: Vec<HSeg> = Vec::with_capacity(pts.len() - 1);
422        let mut vertical: Vec<VSeg> = Vec::with_capacity(pts.len() - 1);
423        let mut cum_xy = 0.0;
424        for w in pts.windows(2) {
425            let (x0, y0, z0) = w[0];
426            let (x1, y1, z1) = w[1];
427            let dx = x1 - x0;
428            let dy = y1 - y0;
429            let dz = z1 - z0;
430            let len_xy = (dx * dx + dy * dy).sqrt();
431            if len_xy < 1e-12 {
432                // Pure-vertical edge — skip; would create degenerate
433                // horizontal segment. The vertical segment for the
434                // adjacent edges still carries the elevation change.
435                continue;
436            }
437            let heading = dy.atan2(dx);
438            horizontal.push(HSeg::Line {
439                sx: x0,
440                sy: y0,
441                heading,
442                length: len_xy,
443                cum_start: cum_xy,
444            });
445            let gradient = dz / len_xy;
446            vertical.push(VSeg::Line {
447                start: cum_xy,
448                length: len_xy,
449                h0: z0,
450                g0: gradient,
451            });
452            cum_xy += len_xy;
453        }
454        if horizontal.is_empty() {
455            return Err(Error::geometry(
456                "IfcPolyline directrix degenerated to zero horizontal length".to_string(),
457            ));
458        }
459        Ok(Self { horizontal, vertical, gradient: None })
460    }
461
462    /// Evaluate the placement frame at the given station (cumulative
463    /// distance along the horizontal alignment, in file length units).
464    /// Extrapolates linearly past either end.
465    pub fn evaluate(&self, station: f64) -> AlignmentFrame {
466        let (x, y, heading) = self.evaluate_horizontal(station);
467        let (z, slope) = self.evaluate_vertical_frame(station);
468        let cos_h = heading.cos();
469        let sin_h = heading.sin();
470        // Right of travel: rotate horizontal tangent (cos h, sin h) by
471        // −90° → (sin h, −cos h). Stays horizontal regardless of slope.
472        let right = Vector3::new(sin_h, -cos_h, 0.0);
473        let up = Vector3::new(0.0, 0.0, 1.0);
474        // 3D tangent: (cos h, sin h) scaled by cos(atan slope) plus a
475        // sin(atan slope) vertical component. Equivalent to taking the
476        // unit-length 3D derivative of (x(s), y(s), z(s)) w.r.t. station.
477        let inv_norm = (1.0 + slope * slope).sqrt();
478        let tangent = Vector3::new(cos_h / inv_norm, sin_h / inv_norm, slope / inv_norm);
479        AlignmentFrame {
480            origin: Point3::new(x, y, z),
481            right,
482            up,
483            tangent,
484        }
485    }
486
487    /// Cant (roll about the 3D tangent) at the given station, in
488    /// radians. Stable stub that always returns 0: the parser does not
489    /// traverse the off-axis `IfcAlignment2DCant` relationship, so no
490    /// cant is ever authored on the curve. Kept as the seam the
491    /// `SectionedSolidHorizontalProcessor` reads (its cross-section roll
492    /// step is a no-op while this returns 0) so cant can be wired later
493    /// without touching the processor.
494    pub fn cant_angle(&self, _station: f64) -> f64 {
495        0.0
496    }
497
498    /// `(height, slope)` at `station` on the vertical profile, or
499    /// `(0, 0)` when there is none.
500    ///
501    /// A station inside a segment evaluates that segment. Requiring
502    /// `station >= start` as well as `<= start + length` stops a station
503    /// BEFORE a segment's own start from silently clamping into it. A
504    /// station covered by no segment extrapolates from the NEAREST
505    /// segment end: before the first segment that is the first segment's
506    /// entry (its start height and grade, extended backwards), past the
507    /// last it is the last segment's exit, and in a gap it is whichever
508    /// bracketing end is closer. Previously every uncovered station fell
509    /// through to the last segment's exit, so a profile authored from
510    /// station 100 onward put station 0 at the LAST segment's exit grade
511    /// extrapolated backwards across the whole alignment (z = -15 where
512    /// the first segment's start height 10 was right).
513    fn evaluate_vertical_frame(&self, station: f64) -> (f64, f64) {
514        if let Some(profile) = &self.gradient {
515            return profile.evaluate(station + profile.first_station());
516        }
517        let Some(first) = self.vertical.first() else {
518            return (0.0, 0.0);
519        };
520        // The nearest segment END at or before `station`, if any.
521        let mut before: Option<&VSeg> = None;
522        for seg in &self.vertical {
523            let start = v_start(seg);
524            let length = v_length(seg);
525            if station >= start - 1e-9 && station <= start + length + 1e-9 {
526                let local = (station - start).max(0.0).min(length);
527                return v_eval(seg, local);
528            }
529            if start + length < station {
530                before = Some(seg);
531            }
532        }
533        // Nearest segment START after `station`, if any (segments are in
534        // station order, so the first one whose start is past the station).
535        let after = self.vertical.iter().find(|seg| v_start(seg) > station);
536        let seg = match (before, after) {
537            (Some(b), Some(a)) => {
538                let gap_before = station - (v_start(b) + v_length(b));
539                let gap_after = v_start(a) - station;
540                if gap_after < gap_before { a } else { b }
541            }
542            (Some(b), None) => b,
543            (None, Some(a)) => a,
544            (None, None) => first,
545        };
546        if v_start(seg) > station {
547            // Extrapolate backwards from the segment's entry.
548            let (z0, g0) = v_eval(seg, 0.0);
549            (z0 + g0 * (station - v_start(seg)), g0)
550        } else {
551            // Extrapolate forwards from the segment's exit.
552            let length = v_length(seg);
553            let (z_end, slope) = v_eval(seg, length);
554            (z_end + slope * (station - (v_start(seg) + length)), slope)
555        }
556    }
557
558    fn evaluate_horizontal(&self, station: f64) -> (f64, f64, f64) {
559        if self.horizontal.is_empty() {
560            return (0.0, 0.0, 0.0);
561        }
562        // The IFC schema is silent on whether segments must form a
563        // continuous chain (`TangentialContinuity` is per-segment and
564        // optional), so we treat each segment's own StartPoint /
565        // StartDirection as authoritative and use the cumulative
566        // SegmentLength sum as the station axis.
567        // Cumulative ends are monotone because segment lengths are
568        // non-negative. A sampled IFC4x3 base curve may have one segment
569        // per metre, and arc-length inversion evaluates it many times.
570        // Negating the old comparison also preserves its NaN fall-through.
571        let index = self.horizontal.partition_point(|seg| {
572            !(station <= h_cum_start(seg) + h_length(seg) + 1e-9)
573        });
574        if let Some(seg) = self.horizontal.get(index) {
575            let len = h_length(seg);
576            let local = (station - h_cum_start(seg)).max(0.0).min(len);
577            return h_eval(seg, local);
578        }
579        // Past the end → extrapolate tangentially from the last segment.
580        let last = self.horizontal.last().unwrap();
581        let len = h_length(last);
582        let (x, y, h) = h_eval(last, len);
583        let extra = station - (h_cum_start(last) + len);
584        (x + extra * h.cos(), y + extra * h.sin(), h)
585    }
586
587}
588
589/// Returns the parsed horizontal segments plus the alignment's
590/// `StartDistAlong` (the base chainage at the physical start). The
591/// segments are indexed by a cumulative-length station starting at 0;
592/// the base is returned so `parse_vertical` can rebase the vertical
593/// segments (authored as absolute chainages) onto that same axis.
594fn parse_horizontal(
595    h_id: u32,
596    decoder: &mut EntityDecoder,
597    angle_scale: f64,
598    strict: bool,
599) -> Result<(Vec<HSeg>, f64)> {
600    let h_entity = decoder.decode_by_id(h_id)?;
601    if h_entity.ifc_type != t_alignment_2d_horizontal() {
602        return Err(Error::geometry(format!(
603            "AlignmentCurve.Horizontal #{} is not IfcAlignment2DHorizontal",
604            h_id,
605        )));
606    }
607    // attr 0 = StartDistAlong (optional); attr 1 = Segments.
608    let start_dist_along = h_entity.get_float(0).unwrap_or(0.0);
609    if strict && h_entity.get(0).is_some_and(|a| !a.is_null() && a.as_float().is_none_or(|v| !v.is_finite())) {
610        return Err(Error::geometry("Invalid horizontal StartDistAlong"));
611    }
612    let segs_attr = h_entity
613        .get(1)
614        .ok_or_else(|| Error::geometry("IfcAlignment2DHorizontal missing Segments".to_string()))?;
615    let seg_refs = segs_attr
616        .as_list()
617        .ok_or_else(|| Error::geometry("Horizontal Segments must be a list".to_string()))?;
618
619    let mut segments = Vec::with_capacity(seg_refs.len());
620    let mut cumulative = 0.0;
621    for seg_ref in seg_refs {
622        let seg_id = seg_ref.as_entity_ref().ok_or_else(|| {
623            Error::geometry("Horizontal segment ref is not an entity reference".to_string())
624        })?;
625        let seg = decoder.decode_by_id(seg_id)?;
626        if seg.ifc_type != t_alignment_2d_horizontal_segment() {
627            return Err(Error::geometry(format!(
628                "#{} is not IfcAlignment2DHorizontalSegment",
629                seg_id,
630            )));
631        }
632        // attr 3 = CurveGeometry (the IfcCurveSegment2D subtype).
633        let curve_id = seg.get_ref(3).ok_or_else(|| {
634            Error::geometry(format!(
635                "IfcAlignment2DHorizontalSegment #{} missing CurveGeometry",
636                seg_id,
637            ))
638        })?;
639        let curve = decoder.decode_by_id(curve_id)?;
640
641        // Inherited IfcCurveSegment2D attributes:
642        //   0: StartPoint (IfcCartesianPoint)
643        //   1: StartDirection (IfcPlaneAngleMeasure — scale via plane_angle_to_radians)
644        //   2: SegmentLength (IfcPositiveLengthMeasure)
645        let sp_id = curve.get_ref(0).ok_or_else(|| {
646            Error::geometry(format!("CurveSegment #{} missing StartPoint", curve_id))
647        })?;
648        let sp = decoder.decode_by_id(sp_id)?;
649        let coords = sp
650            .get_list(0)
651            .ok_or_else(|| Error::geometry("StartPoint missing Coordinates".to_string()))?;
652        if strict && (sp.ifc_type != IfcType::IfcCartesianPoint || coords.len() != 2 || coords.iter().any(|v| v.as_float().is_none_or(|n| !n.is_finite()))) {
653            return Err(Error::geometry("Horizontal StartPoint must contain two finite CartesianPoint coordinates"));
654        }
655        let sx = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
656        let sy = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
657        let heading_raw = curve.get_float(1).ok_or_else(|| {
658            Error::geometry(format!(
659                "CurveSegment #{} missing StartDirection",
660                curve_id,
661            ))
662        })?;
663        let heading = heading_raw * angle_scale;
664        if strict && !heading.is_finite() { return Err(Error::geometry("Nonfinite horizontal heading")); }
665        let length = curve.get_float(2).ok_or_else(|| {
666            Error::geometry(format!("CurveSegment #{} missing SegmentLength", curve_id))
667        })?;
668        // A negative or non-finite SegmentLength drives (station - cum).min(length)
669        // negative and emits NaN world coordinates (sibling radius is guarded at
670        // ~538). Zero-length stubs are a legitimate stationing no-op, so allow them.
671        if !length.is_finite() || length < 0.0 {
672            return Err(Error::geometry(format!(
673                "CurveSegment #{} has negative/non-finite SegmentLength {}",
674                curve_id, length
675            )));
676        }
677
678        let hseg = if curve.ifc_type == t_line_segment_2d() {
679            HSeg::Line {
680                sx,
681                sy,
682                heading,
683                length,
684                cum_start: cumulative,
685            }
686        } else if curve.ifc_type == t_circular_arc_segment_2d() {
687            // Own attrs: 3 = Radius, 4 = IsCCW.
688            let radius = curve.get_float(3).ok_or_else(|| {
689                Error::geometry(format!("CircularArcSegment2D #{} missing Radius", curve_id))
690            })?;
691            if radius < 1e-12 {
692                return Err(Error::geometry(format!(
693                    "CircularArcSegment2D #{} has non-positive radius {}",
694                    curve_id, radius,
695                )));
696            }
697            let ccw = read_bool(curve.get(4));
698            HSeg::Arc {
699                sx,
700                sy,
701                heading,
702                radius,
703                length,
704                ccw,
705                cum_start: cumulative,
706            }
707        } else if curve.ifc_type == t_transition_curve_segment_2d() {
708            // Own attrs:
709            //   3: StartRadius (optional — infinity = straight)
710            //   4: EndRadius   (optional)
711            //   5: IsStartRadiusCCW
712            //   6: IsEndRadiusCCW
713            //   7: TransitionCurveType (enum — dispatches κ(s) profile)
714            if strict && !matches!(curve.get(7).and_then(|v| v.as_enum()), Some("CLOTHOIDCURVE" | "BLOSSCURVE" | "COSINECURVE" | "SINECURVE" | "CUBICPARABOLA" | "BIQUADRATICPARABOLA")) {
715                return Err(Error::geometry("Unknown TransitionCurveType"));
716            }
717            let start_radius = curve.get_float(3);
718            let end_radius = curve.get_float(4);
719            let start_ccw = read_bool(curve.get(5));
720            let end_ccw = read_bool(curve.get(6));
721            let start_curv = match start_radius {
722                Some(r) if r.abs() > 1e-12 => (if start_ccw { 1.0 } else { -1.0 }) / r,
723                _ => 0.0,
724            };
725            let end_curv = match end_radius {
726                Some(r) if r.abs() > 1e-12 => (if end_ccw { 1.0 } else { -1.0 }) / r,
727                _ => 0.0,
728            };
729            let kind = curve
730                .get(7)
731                .and_then(|v| v.as_enum())
732                .map(TransitionKind::from_enum)
733                .unwrap_or(TransitionKind::Clothoid);
734            HSeg::Transition {
735                sx,
736                sy,
737                heading,
738                length,
739                start_curv,
740                end_curv,
741                kind,
742                cum_start: cumulative,
743            }
744        } else {
745            return Err(Error::geometry(format!(
746                "Unsupported horizontal curve geometry at #{}: {}",
747                curve_id, curve.ifc_type,
748            )));
749        };
750        cumulative += length;
751        segments.push(hseg);
752    }
753    Ok((segments, start_dist_along))
754}
755
756/// `horizontal_base` is the horizontal alignment's `StartDistAlong`.
757/// Vertical segments carry their `StartDistAlong` as an absolute
758/// chainage in the same domain as the horizontal alignment, whereas the
759/// horizontal geometry (and the station passed to `evaluate`) is indexed
760/// from 0 at the alignment's physical start. Subtracting the base puts
761/// both axes on one 0-origin station axis; when `StartDistAlong == 0`
762/// (the common case) this is a no-op.
763fn parse_vertical(
764    v_id: u32,
765    decoder: &mut EntityDecoder,
766    horizontal_base: f64,
767    strict: bool,
768) -> Result<Vec<VSeg>> {
769    let v_entity = decoder.decode_by_id(v_id)?;
770    if v_entity.ifc_type != t_alignment_2d_vertical() {
771        return Err(Error::geometry(format!(
772            "AlignmentCurve.Vertical #{} is not IfcAlignment2DVertical",
773            v_id,
774        )));
775    }
776    // attr 0 = Segments.
777    let segs_attr = v_entity
778        .get(0)
779        .ok_or_else(|| Error::geometry("IfcAlignment2DVertical missing Segments".to_string()))?;
780    let seg_refs = segs_attr
781        .as_list()
782        .ok_or_else(|| Error::geometry("Vertical Segments must be a list".to_string()))?;
783
784    let mut segments = Vec::with_capacity(seg_refs.len());
785    for seg_ref in seg_refs {
786        let seg_id = seg_ref.as_entity_ref().ok_or_else(|| {
787            Error::geometry("Vertical segment ref is not an entity reference".to_string())
788        })?;
789        let seg = decoder.decode_by_id(seg_id)?;
790        // Inherited IfcAlignment2DVerticalSegment attrs (all required):
791        //   0: TangentialContinuity
792        //   1: StartTag (optional)
793        //   2: EndTag (optional)
794        //   3: StartDistAlong
795        //   4: HorizontalLength
796        //   5: StartHeight
797        //   6: StartGradient
798        // Rebase the absolute chainage onto the horizontal's 0-origin
799        // station axis (see the fn doc). No-op when `horizontal_base == 0`.
800        let start = seg.get_float(3).ok_or_else(|| {
801            Error::geometry(format!(
802                "VerticalSegment #{} missing StartDistAlong",
803                seg_id,
804            ))
805        })? - horizontal_base;
806        let length = seg.get_float(4).ok_or_else(|| {
807            Error::geometry(format!(
808                "VerticalSegment #{} missing HorizontalLength",
809                seg_id,
810            ))
811        })?;
812        // Negative/non-finite length emits NaN geometry; zero is a legitimate no-op.
813        if !length.is_finite() || length < 0.0 {
814            return Err(Error::geometry(format!(
815                "VerticalSegment #{} has negative/non-finite HorizontalLength {}",
816                seg_id, length
817            )));
818        }
819        let h0 = seg
820            .get_float(5)
821            .ok_or_else(|| Error::geometry(format!("VerticalSegment #{} missing StartHeight", seg_id)))?;
822        let g0 = seg.get_float(6).ok_or_else(|| {
823            Error::geometry(format!(
824                "VerticalSegment #{} missing StartGradient",
825                seg_id,
826            ))
827        })?;
828
829        let vseg = if seg.ifc_type == t_ver_seg_line() {
830            VSeg::Line {
831                start,
832                length,
833                h0,
834                g0,
835            }
836        } else if seg.ifc_type == t_ver_seg_parabolic() {
837            // Own attrs: 7 = ParabolaConstant, 8 = IsConvex.
838            let parabola_constant = seg.get_float(7).ok_or_else(|| {
839                Error::geometry(format!(
840                    "ParabolicVerSeg #{} missing ParabolaConstant",
841                    seg_id,
842                ))
843            })?;
844            if strict && (!parabola_constant.is_finite() || parabola_constant <= 0.0) {
845                return Err(Error::geometry(format!(
846                    "ParabolicVerSeg #{} has invalid ParabolaConstant", seg_id
847                )));
848            }
849            let is_convex = read_bool(seg.get(8));
850            VSeg::Parabolic {
851                start,
852                length,
853                h0,
854                g0,
855                parabola_constant,
856                is_convex,
857            }
858        } else if seg.ifc_type == t_ver_seg_circular() {
859            // Own attrs: 7 = Radius, 8 = IsConvex.
860            let radius = seg.get_float(7).ok_or_else(|| {
861                Error::geometry(format!("CircularVerSeg #{} missing Radius", seg_id))
862            })?;
863            if strict && (!radius.is_finite() || radius <= 0.0) {
864                return Err(Error::geometry(format!(
865                    "CircularVerSeg #{} has invalid Radius", seg_id
866                )));
867            }
868            let is_convex = read_bool(seg.get(8));
869            VSeg::CircularArc {
870                start,
871                length,
872                h0,
873                g0,
874                radius,
875                is_convex,
876            }
877        } else {
878            if strict {
879                return Err(Error::geometry(format!("Unsupported vertical segment #{}: {}", seg_id, seg.ifc_type)));
880            }
881            // Unknown vertical subtype — degrade to a straight gradient
882            // segment so the sweep at least continues sensibly through it.
883            // (The horizontal sibling hard-errors on an unknown curve
884            // subtype; a bad vertical profile shouldn't sink the whole
885            // solid, so we degrade instead.) A degraded fallback is a
886            // genuine anomaly, so warn rather than swallow it silently.
887            crate::diag::diag_warn!(
888                { vertical_segment = seg_id, ifc_type = %seg.ifc_type,
889                  "alignment: unknown vertical segment subtype, degrading to a straight gradient" }
890                else {
891                    eprintln!(
892                        "[ifc-lite][alignment] vertical segment #{} has unsupported subtype {}; \
893                         degrading to a straight gradient segment",
894                        seg_id, seg.ifc_type,
895                    );
896                }
897            );
898            VSeg::Line {
899                start,
900                length,
901                h0,
902                g0,
903            }
904        };
905        segments.push(vseg);
906    }
907    Ok(segments)
908}
909
910// --- Segment evaluation ---
911
912fn h_cum_start(seg: &HSeg) -> f64 {
913    match seg {
914        HSeg::Line { cum_start, .. }
915        | HSeg::Arc { cum_start, .. }
916        | HSeg::Transition { cum_start, .. } => *cum_start,
917    }
918}
919
920fn h_length(seg: &HSeg) -> f64 {
921    match seg {
922        HSeg::Line { length, .. }
923        | HSeg::Arc { length, .. }
924        | HSeg::Transition { length, .. } => *length,
925    }
926}
927
928/// Evaluate horizontal segment at local arc length `s ∈ [0, length]`.
929/// Returns `(x, y, heading)`.
930fn h_eval(seg: &HSeg, s: f64) -> (f64, f64, f64) {
931    match seg {
932        HSeg::Line {
933            sx,
934            sy,
935            heading,
936            ..
937        } => (sx + s * heading.cos(), sy + s * heading.sin(), *heading),
938        HSeg::Arc {
939            sx,
940            sy,
941            heading,
942            radius,
943            ccw,
944            ..
945        } => {
946            let sign = if *ccw { 1.0 } else { -1.0 };
947            let theta = s / radius;
948            let new_heading = heading + sign * theta;
949            // Centre lies perpendicular to heading at distance radius.
950            // CCW → perpendicular-left = (−sin h, cos h);
951            // CW  → perpendicular-right = (sin h, −cos h).
952            let (nx, ny) = if *ccw {
953                (-heading.sin(), heading.cos())
954            } else {
955                (heading.sin(), -heading.cos())
956            };
957            let cx = sx + radius * nx;
958            let cy = sy + radius * ny;
959            // Angle from centre to start point = atan2(−ny, −nx).
960            let start_angle = (-ny).atan2(-nx);
961            let new_angle = start_angle + sign * theta;
962            (
963                cx + radius * new_angle.cos(),
964                cy + radius * new_angle.sin(),
965                new_heading,
966            )
967        }
968        HSeg::Transition {
969            sx,
970            sy,
971            heading,
972            length,
973            start_curv,
974            end_curv,
975            kind,
976            ..
977        } => {
978            // The parser allows a zero-length stub (a stationing no-op).
979            // Line and Arc evaluate it to their start point for free, but
980            // the blend below divides by `length` (`u / length`, `s / L`),
981            // which at 0/0 is NaN in the heading and then in x and y. A
982            // zero-length segment has no extent: it IS its start.
983            if *length <= 0.0 {
984                return (*sx, *sy, *heading);
985            }
986            // heading(s) = h₀ + κ₀·s + (κ₁-κ₀)·L · g(s/L)
987            //   where g(u) is the integral of the curvature-blend
988            //   profile chosen by the `TransitionKind` (see
989            //   `TransitionKind::heading_integral`). x, y require
990            //   ∫cos(h(s)) ds, ∫sin(h(s)) ds — no closed form except
991            //   for the Clothoid (Fresnel integrals).
992            //
993            // Numerical integration via the composite trapezoidal rule.
994            // Step density scales with arc-length traversed so the
995            // sample interval is roughly constant per metre, with a
996            // minimum of 16 samples for stability.
997            let n = ((s.abs() * 0.5).ceil() as usize).max(16).min(4096);
998            let ds = s / n as f64;
999            let mut x = *sx;
1000            let mut y = *sy;
1001            let mut prev_cos = heading.cos();
1002            let mut prev_sin = heading.sin();
1003            for i in 1..=n {
1004                let u = i as f64 * ds;
1005                let h = *heading
1006                    + start_curv * u
1007                    + (end_curv - start_curv) * length * kind.heading_integral(u / length);
1008                let cs = h.cos();
1009                let sn = h.sin();
1010                x += 0.5 * ds * (prev_cos + cs);
1011                y += 0.5 * ds * (prev_sin + sn);
1012                prev_cos = cs;
1013                prev_sin = sn;
1014            }
1015            let final_h = *heading
1016                + start_curv * s
1017                + (end_curv - start_curv) * length * kind.heading_integral(s / length);
1018            (x, y, final_h)
1019        }
1020    }
1021}
1022
1023fn v_start(seg: &VSeg) -> f64 {
1024    match seg {
1025        VSeg::Line { start, .. }
1026        | VSeg::Parabolic { start, .. }
1027        | VSeg::CircularArc { start, .. } => *start,
1028    }
1029}
1030
1031fn v_length(seg: &VSeg) -> f64 {
1032    match seg {
1033        VSeg::Line { length, .. }
1034        | VSeg::Parabolic { length, .. }
1035        | VSeg::CircularArc { length, .. } => *length,
1036    }
1037}
1038
1039/// Evaluate vertical segment at local horizontal distance `s ∈ [0, length]`.
1040/// Returns `(height, slope)`.
1041fn v_eval(seg: &VSeg, s: f64) -> (f64, f64) {
1042    match seg {
1043        VSeg::Line { h0, g0, .. } => (h0 + g0 * s, *g0),
1044        VSeg::Parabolic {
1045            h0,
1046            g0,
1047            parabola_constant,
1048            is_convex,
1049            ..
1050        } => {
1051            // IFC4x1 convention: ParabolaConstant K = R (radius-equivalent
1052            // for a parabola in z(x) = z0 + g0·x ± x²/(2K)). `IsConvex=true`
1053            // is a crest curve (curvature downward); `false` is a sag.
1054            let sign = if *is_convex { -1.0 } else { 1.0 };
1055            let k = parabola_constant.abs().max(1e-12);
1056            let z = h0 + g0 * s + sign * (s * s) / (2.0 * k);
1057            let slope = g0 + sign * s / k;
1058            (z, slope)
1059        }
1060        VSeg::CircularArc {
1061            h0,
1062            g0,
1063            radius,
1064            is_convex,
1065            ..
1066        } => {
1067            // Parabolic approximation accurate to ~mm for typical highway
1068            // radii (R > 500 m) over realistic segment lengths.
1069            let sign = if *is_convex { -1.0 } else { 1.0 };
1070            let r = radius.abs().max(1e-12);
1071            let z = h0 + g0 * s + sign * (s * s) / (2.0 * r);
1072            let slope = g0 + sign * s / r;
1073            (z, slope)
1074        }
1075    }
1076}
1077
1078#[cfg(test)]
1079#[path = "alignment_tests.rs"]
1080mod tests;