Skip to main content

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. These are not in our IFC4X3 codegen
9//! enum, so dispatch is via `IfcType::from_str` cached behind `OnceLock`.
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, EntityScanner, IfcType};
30use nalgebra::{Point3, Vector3};
31use std::sync::OnceLock;
32
33use crate::{Error, Result};
34
35// --- IFC type lookup (resolves IFC4x1 names not in our IFC4X3 enum) ---
36
37macro_rules! ifc_type_fn {
38    ($name:ident, $literal:expr) => {
39        fn $name() -> IfcType {
40            static T: OnceLock<IfcType> = OnceLock::new();
41            *T.get_or_init(|| IfcType::from_str($literal))
42        }
43    };
44}
45
46ifc_type_fn!(t_alignment_curve, "IFCALIGNMENTCURVE");
47ifc_type_fn!(t_alignment_2d_horizontal, "IFCALIGNMENT2DHORIZONTAL");
48ifc_type_fn!(t_alignment_2d_horizontal_segment, "IFCALIGNMENT2DHORIZONTALSEGMENT");
49ifc_type_fn!(t_alignment_2d_vertical, "IFCALIGNMENT2DVERTICAL");
50ifc_type_fn!(t_line_segment_2d, "IFCLINESEGMENT2D");
51ifc_type_fn!(t_circular_arc_segment_2d, "IFCCIRCULARARCSEGMENT2D");
52ifc_type_fn!(t_transition_curve_segment_2d, "IFCTRANSITIONCURVESEGMENT2D");
53ifc_type_fn!(t_ver_seg_line, "IFCALIGNMENT2DVERSEGLINE");
54ifc_type_fn!(t_ver_seg_parabolic, "IFCALIGNMENT2DVERSEGPARABOLICARC");
55ifc_type_fn!(t_ver_seg_circular, "IFCALIGNMENT2DVERSEGCIRCULARARC");
56ifc_type_fn!(t_alignment_2d_cant, "IFCALIGNMENT2DCANT");
57ifc_type_fn!(t_cant_seg_const, "IFCALIGNMENT2DCANTSEGLINE");
58ifc_type_fn!(t_cant_seg_transition, "IFCALIGNMENT2DCANTSEGTRANSITION");
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/// Public spec for a cant segment. Callers building cant by hand (e.g.
124/// from an alternative schema traversal) pass a `Vec<CantSegSpec>` to
125/// `AlignmentCurve::with_cant_segments`.
126#[derive(Debug, Clone, Copy)]
127pub struct CantSegSpec {
128    /// Cumulative station along the directrix where this segment
129    /// begins, in file length units.
130    pub start: f64,
131    /// Horizontal length of the segment.
132    pub length: f64,
133    /// Cant angle (radians) at the start of the segment.
134    pub roll_start: f64,
135    /// Cant angle (radians) at the end of the segment. Equal to
136    /// `roll_start` for constant-cant segments.
137    pub roll_end: f64,
138}
139
140/// Internal cant segment. We keep `Const` and `Linear` distinct mainly
141/// for clarity in `cant_angle`; both could be folded into the linear
142/// case at the cost of a couple of extra multiplications.
143#[derive(Debug, Clone, Copy)]
144enum CantSeg {
145    /// Constant cant from `start` to `start+length`, with `roll` radians
146    /// of rotation about the tangent (positive = right rail lower).
147    Const { start: f64, length: f64, roll: f64 },
148    /// Linear transition from `roll_start` at station `start` to
149    /// `roll_end` at `start+length`.
150    Linear {
151        start: f64,
152        length: f64,
153        roll_start: f64,
154        roll_end: f64,
155    },
156}
157
158impl CantSeg {
159    fn from_spec(spec: CantSegSpec) -> Self {
160        if (spec.roll_end - spec.roll_start).abs() < 1e-12 {
161            CantSeg::Const {
162                start: spec.start,
163                length: spec.length,
164                roll: spec.roll_start,
165            }
166        } else {
167            CantSeg::Linear {
168                start: spec.start,
169                length: spec.length,
170                roll_start: spec.roll_start,
171                roll_end: spec.roll_end,
172            }
173        }
174    }
175}
176
177/// Parse an `IfcAlignment2DCant` entity into a list of
178/// `CantSegSpec`. The segments are taken in authored order; each
179/// segment is one of `IfcAlignment2DCantSegLine` (constant cant) or
180/// `IfcAlignment2DCantSegTransition` (linear cant transition).
181///
182/// IFC4x1 `IfcAlignment2DCant` attributes:
183///   0: RailHeadDistance (track gauge; not used by the renderer)
184///   1: Segments         (LIST of IfcAlignment2DCantSegment)
185///
186/// Inherited `IfcAlignment2DCantSegment` attrs:
187///   0: TangentialContinuity
188///   1: StartTag / 2: EndTag
189///   3: StartDistAlong
190///   4: HorizontalLength
191///   5: StartCantLeft
192///   6: StartCantRight
193/// Plus subtype-specific:
194///   7: EndCantLeft  (transition only)
195///   8: EndCantRight (transition only)
196///
197/// Track gauge / cant convention: the roll angle is computed as
198/// `atan2(StartCantRight − StartCantLeft, RailHeadDistance)`. The
199/// IFC4x1 convention is that cant values are positive vertical
200/// distances above the rail-head datum, so right-cant > left-cant
201/// rolls the cross-section CCW about the tangent looking down-track.
202pub fn parse_cant(entity: &DecodedEntity, decoder: &mut EntityDecoder) -> Result<Vec<CantSegSpec>> {
203    if entity.ifc_type != t_alignment_2d_cant() {
204        return Err(Error::geometry(format!(
205            "#{} is not IfcAlignment2DCant",
206            entity.id,
207        )));
208    }
209    let rail_head_distance = entity.get_float(0).unwrap_or(1.435); // standard gauge in m as a sane default
210    let segs_attr = entity
211        .get(1)
212        .ok_or_else(|| Error::geometry("IfcAlignment2DCant missing Segments".to_string()))?;
213    let seg_refs = segs_attr
214        .as_list()
215        .ok_or_else(|| Error::geometry("Cant Segments must be a list".to_string()))?;
216
217    let mut specs = Vec::with_capacity(seg_refs.len());
218    for r in seg_refs {
219        let sid = r.as_entity_ref().ok_or_else(|| {
220            Error::geometry("Cant segment ref is not an entity reference".to_string())
221        })?;
222        let seg = decoder.decode_by_id(sid)?;
223        let start = seg.get_float(3).ok_or_else(|| {
224            Error::geometry(format!("CantSegment #{} missing StartDistAlong", sid))
225        })?;
226        let length = seg.get_float(4).ok_or_else(|| {
227            Error::geometry(format!("CantSegment #{} missing HorizontalLength", sid))
228        })?;
229        let start_left = seg.get_float(5).unwrap_or(0.0);
230        let start_right = seg.get_float(6).unwrap_or(0.0);
231        let roll_start = ((start_right - start_left) / rail_head_distance.max(1e-9)).atan();
232        // Constant-cant subtype is `IfcAlignment2DCantSegLine`; the
233        // transition subtype is `IfcAlignment2DCantSegTransition` and
234        // additionally carries end-cant attributes at indices 7 / 8.
235        // We compare against both type IDs so future schema dialects
236        // that add more constant-cant subtypes still default to "no
237        // change in cant" rather than misreading attrs 7/8.
238        let is_transition = seg.ifc_type == t_cant_seg_transition();
239        let is_const = seg.ifc_type == t_cant_seg_const();
240        let (roll_end_left, roll_end_right) = if is_transition {
241            (
242                seg.get_float(7).unwrap_or(start_left),
243                seg.get_float(8).unwrap_or(start_right),
244            )
245        } else if is_const {
246            (start_left, start_right)
247        } else {
248            // Unknown subtype — default to constant cant.
249            (start_left, start_right)
250        };
251        let roll_end = ((roll_end_right - roll_end_left) / rail_head_distance.max(1e-9)).atan();
252        specs.push(CantSegSpec {
253            start,
254            length,
255            roll_start,
256            roll_end,
257        });
258    }
259    Ok(specs)
260}
261
262/// `True` if the attribute is an IFC boolean enum `.T.`. Anything else
263/// (including `.F.`, `.U.`, missing, or wrong shape) reads as `false`.
264fn read_bool(attr: Option<&AttributeValue>) -> bool {
265    attr.and_then(|v| v.as_enum()).map(|s| s == "T").unwrap_or(false)
266}
267
268/// Cumulative-station-keyed horizontal directrix segment.
269#[derive(Debug, Clone, Copy)]
270enum HSeg {
271    Line {
272        sx: f64,
273        sy: f64,
274        heading: f64,
275        length: f64,
276        cum_start: f64,
277    },
278    Arc {
279        sx: f64,
280        sy: f64,
281        heading: f64,
282        radius: f64,
283        length: f64,
284        ccw: bool,
285        cum_start: f64,
286    },
287    /// Transition curve. Curvature varies smoothly from `start_curv` to
288    /// `end_curv` along arc length according to the `kind`'s blend
289    /// profile. Position evaluated by numerical integration of
290    /// `(cos h(s), sin h(s))` ds — no closed form for the Fresnel-style
291    /// integrals these curves produce.
292    Transition {
293        sx: f64,
294        sy: f64,
295        heading: f64,
296        length: f64,
297        start_curv: f64,
298        end_curv: f64,
299        kind: TransitionKind,
300        cum_start: f64,
301    },
302}
303
304#[derive(Debug, Clone, Copy)]
305enum VSeg {
306    Line {
307        start: f64,
308        length: f64,
309        h0: f64,
310        g0: f64,
311    },
312    Parabolic {
313        start: f64,
314        length: f64,
315        h0: f64,
316        g0: f64,
317        parabola_constant: f64,
318        is_convex: bool,
319    },
320    /// Circular vertical curve. For typical highway radii (>500m) over
321    /// segment lengths in the tens of metres, the parabolic approximation
322    /// `z ≈ z0 + g0·s + ±s²/(2R)` is accurate to sub-millimetre — same
323    /// formula as the parabolic segment with `parabola_constant = R`.
324    CircularArc {
325        start: f64,
326        length: f64,
327        h0: f64,
328        g0: f64,
329        radius: f64,
330        is_convex: bool,
331    },
332}
333
334/// Cross-section placement frame at a station.
335#[derive(Debug, Clone, Copy)]
336pub struct AlignmentFrame {
337    /// Origin of the cross-section's local 2D coords (px=0, py=0).
338    pub origin: Point3<f64>,
339    /// Right of travel in the horizontal plane. Profile's local +X
340    /// when `FixedAxisVertical = true`. Always lies in the world XY
341    /// plane: `(sin h, −cos h, 0)`.
342    pub right: Vector3<f64>,
343    /// Up (global +Z). Profile's local +Y when `FixedAxisVertical = true`.
344    pub up: Vector3<f64>,
345    /// Unit tangent of the 3D directrix at this station. Includes the
346    /// longitudinal slope from the vertical alignment.
347    pub tangent: Vector3<f64>,
348}
349
350/// Parsed alignment curve. Holds horizontal and vertical segments in
351/// authored order with cumulative-start stations precomputed.
352pub struct AlignmentCurve {
353    horizontal: Vec<HSeg>,
354    vertical: Vec<VSeg>,
355    cant: Vec<CantSeg>,
356}
357
358impl AlignmentCurve {
359    /// Parse `IfcAlignmentCurve` (or any directrix we can reduce to a
360    /// piecewise alignment). Recognised cases:
361    ///
362    /// - `IfcAlignmentCurve` — full horizontal + vertical + (optional)
363    ///   cant parsing.
364    /// - `IfcPolyline` — synthesised as a chain of line segments. Each
365    ///   polyline edge becomes one `HSeg::Line` (in XY) and one
366    ///   `VSeg::Line` (with gradient = dz / horizontal length). This
367    ///   covers the relatively rare case of a sectioned-solid authored
368    ///   with a polyline directrix, which is spec-allowed but uncommon.
369    ///
370    /// Returns `Ok(None)` for any other directrix so the caller can
371    /// fall back to a straight-line sweep. Errors only on malformed
372    /// recognised input (e.g. an `IfcAlignmentCurve` missing
373    /// `Horizontal`).
374    pub fn parse(directrix: &DecodedEntity, decoder: &mut EntityDecoder) -> Result<Option<Self>> {
375        if directrix.ifc_type == IfcType::IfcPolyline {
376            return Self::from_polyline(directrix, decoder).map(Some);
377        }
378        if directrix.ifc_type != t_alignment_curve() {
379            return Ok(None);
380        }
381
382        let angle_scale = decoder.plane_angle_to_radians();
383
384        // attr 0 = Horizontal (required)
385        let h_id = directrix.get_ref(0).ok_or_else(|| {
386            Error::geometry("IfcAlignmentCurve missing Horizontal".to_string())
387        })?;
388        let horizontal = parse_horizontal(h_id, decoder, angle_scale)?;
389
390        // attr 1 = Vertical (optional)
391        let vertical = match directrix.get(1) {
392            Some(v) if !v.is_null() => match v.as_entity_ref() {
393                Some(v_id) => parse_vertical(v_id, decoder)?,
394                None => Vec::new(),
395            },
396            _ => Vec::new(),
397        };
398
399        // Cant is an off-axis sibling entity (`IfcAlignment2DCant`).
400        // IFC4x1 doesn't have a direct attribute on `IfcAlignmentCurve`
401        // pointing at it; the auto-discovery (file-wide scan that
402        // walks IfcAlignment → Cant) is left as a hook via
403        // `with_cant_segments`. The current pipeline produces no cant
404        // because no fixture in the suite exercises it.
405        let cant = Vec::new();
406
407        Ok(Some(Self {
408            horizontal,
409            vertical,
410            cant,
411        }))
412    }
413
414    /// Attach a pre-parsed cant profile. Used by callers that have
415    /// already located an `IfcAlignment2DCant` entity for this
416    /// alignment (e.g. via an `IfcAlignment.AxisCant` traversal). The
417    /// segments are taken in authored order with cumulative-station
418    /// indexing handled by `cant_angle`.
419    pub fn with_cant_segments(mut self, segments: Vec<CantSegSpec>) -> Self {
420        self.cant = segments.into_iter().map(CantSeg::from_spec).collect();
421        self
422    }
423
424    /// Scan `content` for any `IfcAlignment2DCant` and attach the first
425    /// one found. This is a best-effort hook for callers that want the
426    /// auto-discovery convenience; in IFC4x1 the schema doesn't
427    /// constrain how the cant is bound to the alignment, so picking
428    /// the first one is the only generic option. Files with multiple
429    /// alignments + cants should use `with_cant_segments` explicitly.
430    pub fn auto_attach_cant(self, content: &str, decoder: &mut EntityDecoder) -> Result<Self> {
431        let mut scanner = EntityScanner::new(content);
432        while let Some((id, type_name, _, _)) = scanner.next_entity() {
433            if type_name == "IFCALIGNMENT2DCANT" {
434                let entity = decoder.decode_by_id(id)?;
435                let specs = parse_cant(&entity, decoder)?;
436                return Ok(self.with_cant_segments(specs));
437            }
438        }
439        Ok(self)
440    }
441
442    /// Total length of the horizontal alignment (sum of segment lengths).
443    pub fn horizontal_length(&self) -> f64 {
444        self.horizontal
445            .last()
446            .map(|s| h_cum_start(s) + h_length(s))
447            .unwrap_or(0.0)
448    }
449
450    /// Build an alignment from an `IfcPolyline` directrix. Each
451    /// polyline edge becomes one horizontal Line segment plus one
452    /// vertical Line segment so the unified `evaluate(station)` path
453    /// works without special-casing in the processor.
454    fn from_polyline(curve: &DecodedEntity, decoder: &mut EntityDecoder) -> Result<Self> {
455        let points_attr = curve
456            .get(0)
457            .ok_or_else(|| Error::geometry("IfcPolyline missing Points".to_string()))?;
458        let point_refs = points_attr
459            .as_list()
460            .ok_or_else(|| Error::geometry("IfcPolyline Points is not a list".to_string()))?;
461        if point_refs.len() < 2 {
462            return Err(Error::geometry(
463                "IfcPolyline directrix needs ≥ 2 points".to_string(),
464            ));
465        }
466        let mut pts: Vec<(f64, f64, f64)> = Vec::with_capacity(point_refs.len());
467        for r in point_refs {
468            let pid = r
469                .as_entity_ref()
470                .ok_or_else(|| Error::geometry("Polyline point is not an entity ref".to_string()))?;
471            let p = decoder.decode_by_id(pid)?;
472            let coords = p
473                .get_list(0)
474                .ok_or_else(|| Error::geometry("CartesianPoint missing Coordinates".to_string()))?;
475            let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
476            let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
477            let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
478            pts.push((x, y, z));
479        }
480
481        let mut horizontal: Vec<HSeg> = Vec::with_capacity(pts.len() - 1);
482        let mut vertical: Vec<VSeg> = Vec::with_capacity(pts.len() - 1);
483        let mut cum_xy = 0.0;
484        for w in pts.windows(2) {
485            let (x0, y0, z0) = w[0];
486            let (x1, y1, z1) = w[1];
487            let dx = x1 - x0;
488            let dy = y1 - y0;
489            let dz = z1 - z0;
490            let len_xy = (dx * dx + dy * dy).sqrt();
491            if len_xy < 1e-12 {
492                // Pure-vertical edge — skip; would create degenerate
493                // horizontal segment. The vertical segment for the
494                // adjacent edges still carries the elevation change.
495                continue;
496            }
497            let heading = dy.atan2(dx);
498            horizontal.push(HSeg::Line {
499                sx: x0,
500                sy: y0,
501                heading,
502                length: len_xy,
503                cum_start: cum_xy,
504            });
505            let gradient = dz / len_xy;
506            vertical.push(VSeg::Line {
507                start: cum_xy,
508                length: len_xy,
509                h0: z0,
510                g0: gradient,
511            });
512            cum_xy += len_xy;
513        }
514        if horizontal.is_empty() {
515            return Err(Error::geometry(
516                "IfcPolyline directrix degenerated to zero horizontal length".to_string(),
517            ));
518        }
519        Ok(Self {
520            horizontal,
521            vertical,
522            cant: Vec::new(),
523        })
524    }
525
526    /// Evaluate the placement frame at the given station (cumulative
527    /// distance along the horizontal alignment, in file length units).
528    /// Extrapolates linearly past either end.
529    pub fn evaluate(&self, station: f64) -> AlignmentFrame {
530        let (x, y, heading) = self.evaluate_horizontal(station);
531        let z = self.evaluate_vertical(station);
532        let slope = self.evaluate_vertical_slope(station);
533        let cos_h = heading.cos();
534        let sin_h = heading.sin();
535        // Right of travel: rotate horizontal tangent (cos h, sin h) by
536        // −90° → (sin h, −cos h). Stays horizontal regardless of slope.
537        let right = Vector3::new(sin_h, -cos_h, 0.0);
538        let up = Vector3::new(0.0, 0.0, 1.0);
539        // 3D tangent: (cos h, sin h) scaled by cos(atan slope) plus a
540        // sin(atan slope) vertical component. Equivalent to taking the
541        // unit-length 3D derivative of (x(s), y(s), z(s)) w.r.t. station.
542        let inv_norm = (1.0 + slope * slope).sqrt();
543        let tangent = Vector3::new(cos_h / inv_norm, sin_h / inv_norm, slope / inv_norm);
544        AlignmentFrame {
545            origin: Point3::new(x, y, z),
546            right,
547            up,
548            tangent,
549        }
550    }
551
552    /// Cant (roll about the 3D tangent) at the given station, in
553    /// radians. Returns 0 when no cant is authored.
554    pub fn cant_angle(&self, station: f64) -> f64 {
555        for seg in &self.cant {
556            match seg {
557                CantSeg::Const {
558                    start,
559                    length,
560                    roll,
561                } => {
562                    if station >= *start - 1e-9 && station <= start + length + 1e-9 {
563                        return *roll;
564                    }
565                }
566                CantSeg::Linear {
567                    start,
568                    length,
569                    roll_start,
570                    roll_end,
571                } => {
572                    if station >= *start - 1e-9 && station <= start + length + 1e-9 {
573                        let t = ((station - start) / length.max(1e-12)).clamp(0.0, 1.0);
574                        return roll_start * (1.0 - t) + roll_end * t;
575                    }
576                }
577            }
578        }
579        0.0
580    }
581
582    fn evaluate_vertical_slope(&self, station: f64) -> f64 {
583        if self.vertical.is_empty() {
584            return 0.0;
585        }
586        for seg in &self.vertical {
587            let start = v_start(seg);
588            let length = v_length(seg);
589            if station <= start + length + 1e-9 {
590                let local = (station - start).max(0.0).min(length);
591                return v_eval(seg, local).1;
592            }
593        }
594        let last = self.vertical.last().unwrap();
595        v_eval(last, v_length(last)).1
596    }
597
598    fn evaluate_horizontal(&self, station: f64) -> (f64, f64, f64) {
599        if self.horizontal.is_empty() {
600            return (0.0, 0.0, 0.0);
601        }
602        // The IFC schema is silent on whether segments must form a
603        // continuous chain (`TangentialContinuity` is per-segment and
604        // optional), so we treat each segment's own StartPoint /
605        // StartDirection as authoritative and use the cumulative
606        // SegmentLength sum as the station axis.
607        for seg in &self.horizontal {
608            let len = h_length(seg);
609            let cum = h_cum_start(seg);
610            if station <= cum + len + 1e-9 {
611                let local = (station - cum).max(0.0).min(len);
612                return h_eval(seg, local);
613            }
614        }
615        // Past the end → extrapolate tangentially from the last segment.
616        let last = self.horizontal.last().unwrap();
617        let len = h_length(last);
618        let (x, y, h) = h_eval(last, len);
619        let extra = station - (h_cum_start(last) + len);
620        (x + extra * h.cos(), y + extra * h.sin(), h)
621    }
622
623    fn evaluate_vertical(&self, station: f64) -> f64 {
624        if self.vertical.is_empty() {
625            return 0.0;
626        }
627        for seg in &self.vertical {
628            let start = v_start(seg);
629            let length = v_length(seg);
630            if station <= start + length + 1e-9 {
631                let local = (station - start).max(0.0).min(length);
632                return v_eval_height(seg, local);
633            }
634        }
635        // Past the end → extrapolate with the last segment's exit slope.
636        let last = self.vertical.last().unwrap();
637        let length = v_length(last);
638        let (z_end, slope) = v_eval(last, length);
639        let extra = station - (v_start(last) + length);
640        z_end + slope * extra
641    }
642}
643
644fn parse_horizontal(
645    h_id: u32,
646    decoder: &mut EntityDecoder,
647    angle_scale: f64,
648) -> Result<Vec<HSeg>> {
649    let h_entity = decoder.decode_by_id(h_id)?;
650    if h_entity.ifc_type != t_alignment_2d_horizontal() {
651        return Err(Error::geometry(format!(
652            "AlignmentCurve.Horizontal #{} is not IfcAlignment2DHorizontal",
653            h_id,
654        )));
655    }
656    // attr 0 = StartDistAlong (optional); attr 1 = Segments.
657    let _start_dist_along = h_entity.get_float(0).unwrap_or(0.0);
658    let segs_attr = h_entity
659        .get(1)
660        .ok_or_else(|| Error::geometry("IfcAlignment2DHorizontal missing Segments".to_string()))?;
661    let seg_refs = segs_attr
662        .as_list()
663        .ok_or_else(|| Error::geometry("Horizontal Segments must be a list".to_string()))?;
664
665    let mut segments = Vec::with_capacity(seg_refs.len());
666    let mut cumulative = 0.0;
667    for seg_ref in seg_refs {
668        let seg_id = seg_ref.as_entity_ref().ok_or_else(|| {
669            Error::geometry("Horizontal segment ref is not an entity reference".to_string())
670        })?;
671        let seg = decoder.decode_by_id(seg_id)?;
672        if seg.ifc_type != t_alignment_2d_horizontal_segment() {
673            return Err(Error::geometry(format!(
674                "#{} is not IfcAlignment2DHorizontalSegment",
675                seg_id,
676            )));
677        }
678        // attr 3 = CurveGeometry (the IfcCurveSegment2D subtype).
679        let curve_id = seg.get_ref(3).ok_or_else(|| {
680            Error::geometry(format!(
681                "IfcAlignment2DHorizontalSegment #{} missing CurveGeometry",
682                seg_id,
683            ))
684        })?;
685        let curve = decoder.decode_by_id(curve_id)?;
686
687        // Inherited IfcCurveSegment2D attributes:
688        //   0: StartPoint (IfcCartesianPoint)
689        //   1: StartDirection (IfcPlaneAngleMeasure — scale via plane_angle_to_radians)
690        //   2: SegmentLength (IfcPositiveLengthMeasure)
691        let sp_id = curve.get_ref(0).ok_or_else(|| {
692            Error::geometry(format!("CurveSegment #{} missing StartPoint", curve_id))
693        })?;
694        let sp = decoder.decode_by_id(sp_id)?;
695        let coords = sp
696            .get_list(0)
697            .ok_or_else(|| Error::geometry("StartPoint missing Coordinates".to_string()))?;
698        let sx = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
699        let sy = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
700        let heading_raw = curve.get_float(1).ok_or_else(|| {
701            Error::geometry(format!(
702                "CurveSegment #{} missing StartDirection",
703                curve_id,
704            ))
705        })?;
706        let heading = heading_raw * angle_scale;
707        let length = curve.get_float(2).ok_or_else(|| {
708            Error::geometry(format!("CurveSegment #{} missing SegmentLength", curve_id))
709        })?;
710
711        let hseg = if curve.ifc_type == t_line_segment_2d() {
712            HSeg::Line {
713                sx,
714                sy,
715                heading,
716                length,
717                cum_start: cumulative,
718            }
719        } else if curve.ifc_type == t_circular_arc_segment_2d() {
720            // Own attrs: 3 = Radius, 4 = IsCCW.
721            let radius = curve.get_float(3).ok_or_else(|| {
722                Error::geometry(format!("CircularArcSegment2D #{} missing Radius", curve_id))
723            })?;
724            if radius < 1e-12 {
725                return Err(Error::geometry(format!(
726                    "CircularArcSegment2D #{} has non-positive radius {}",
727                    curve_id, radius,
728                )));
729            }
730            let ccw = read_bool(curve.get(4));
731            HSeg::Arc {
732                sx,
733                sy,
734                heading,
735                radius,
736                length,
737                ccw,
738                cum_start: cumulative,
739            }
740        } else if curve.ifc_type == t_transition_curve_segment_2d() {
741            // Own attrs:
742            //   3: StartRadius (optional — infinity = straight)
743            //   4: EndRadius   (optional)
744            //   5: IsStartRadiusCCW
745            //   6: IsEndRadiusCCW
746            //   7: TransitionCurveType (enum — dispatches κ(s) profile)
747            let start_radius = curve.get_float(3);
748            let end_radius = curve.get_float(4);
749            let start_ccw = read_bool(curve.get(5));
750            let end_ccw = read_bool(curve.get(6));
751            let start_curv = match start_radius {
752                Some(r) if r.abs() > 1e-12 => (if start_ccw { 1.0 } else { -1.0 }) / r,
753                _ => 0.0,
754            };
755            let end_curv = match end_radius {
756                Some(r) if r.abs() > 1e-12 => (if end_ccw { 1.0 } else { -1.0 }) / r,
757                _ => 0.0,
758            };
759            let kind = curve
760                .get(7)
761                .and_then(|v| v.as_enum())
762                .map(TransitionKind::from_enum)
763                .unwrap_or(TransitionKind::Clothoid);
764            HSeg::Transition {
765                sx,
766                sy,
767                heading,
768                length,
769                start_curv,
770                end_curv,
771                kind,
772                cum_start: cumulative,
773            }
774        } else {
775            return Err(Error::geometry(format!(
776                "Unsupported horizontal curve geometry at #{}: {}",
777                curve_id, curve.ifc_type,
778            )));
779        };
780        cumulative += length;
781        segments.push(hseg);
782    }
783    Ok(segments)
784}
785
786fn parse_vertical(v_id: u32, decoder: &mut EntityDecoder) -> Result<Vec<VSeg>> {
787    let v_entity = decoder.decode_by_id(v_id)?;
788    if v_entity.ifc_type != t_alignment_2d_vertical() {
789        return Err(Error::geometry(format!(
790            "AlignmentCurve.Vertical #{} is not IfcAlignment2DVertical",
791            v_id,
792        )));
793    }
794    // attr 0 = Segments.
795    let segs_attr = v_entity
796        .get(0)
797        .ok_or_else(|| Error::geometry("IfcAlignment2DVertical missing Segments".to_string()))?;
798    let seg_refs = segs_attr
799        .as_list()
800        .ok_or_else(|| Error::geometry("Vertical Segments must be a list".to_string()))?;
801
802    let mut segments = Vec::with_capacity(seg_refs.len());
803    for seg_ref in seg_refs {
804        let seg_id = seg_ref.as_entity_ref().ok_or_else(|| {
805            Error::geometry("Vertical segment ref is not an entity reference".to_string())
806        })?;
807        let seg = decoder.decode_by_id(seg_id)?;
808        // Inherited IfcAlignment2DVerticalSegment attrs (all required):
809        //   0: TangentialContinuity
810        //   1: StartTag (optional)
811        //   2: EndTag (optional)
812        //   3: StartDistAlong
813        //   4: HorizontalLength
814        //   5: StartHeight
815        //   6: StartGradient
816        let start = seg.get_float(3).ok_or_else(|| {
817            Error::geometry(format!(
818                "VerticalSegment #{} missing StartDistAlong",
819                seg_id,
820            ))
821        })?;
822        let length = seg.get_float(4).ok_or_else(|| {
823            Error::geometry(format!(
824                "VerticalSegment #{} missing HorizontalLength",
825                seg_id,
826            ))
827        })?;
828        let h0 = seg
829            .get_float(5)
830            .ok_or_else(|| Error::geometry(format!("VerticalSegment #{} missing StartHeight", seg_id)))?;
831        let g0 = seg.get_float(6).ok_or_else(|| {
832            Error::geometry(format!(
833                "VerticalSegment #{} missing StartGradient",
834                seg_id,
835            ))
836        })?;
837
838        let vseg = if seg.ifc_type == t_ver_seg_line() {
839            VSeg::Line {
840                start,
841                length,
842                h0,
843                g0,
844            }
845        } else if seg.ifc_type == t_ver_seg_parabolic() {
846            // Own attrs: 7 = ParabolaConstant, 8 = IsConvex.
847            let parabola_constant = seg.get_float(7).ok_or_else(|| {
848                Error::geometry(format!(
849                    "ParabolicVerSeg #{} missing ParabolaConstant",
850                    seg_id,
851                ))
852            })?;
853            let is_convex = read_bool(seg.get(8));
854            VSeg::Parabolic {
855                start,
856                length,
857                h0,
858                g0,
859                parabola_constant,
860                is_convex,
861            }
862        } else if seg.ifc_type == t_ver_seg_circular() {
863            // Own attrs: 7 = Radius, 8 = IsConvex.
864            let radius = seg.get_float(7).ok_or_else(|| {
865                Error::geometry(format!("CircularVerSeg #{} missing Radius", seg_id))
866            })?;
867            let is_convex = read_bool(seg.get(8));
868            VSeg::CircularArc {
869                start,
870                length,
871                h0,
872                g0,
873                radius,
874                is_convex,
875            }
876        } else {
877            // Unknown vertical subtype — degrade to a straight gradient
878            // segment so the sweep at least continues sensibly through
879            // it. Logged below.
880            VSeg::Line {
881                start,
882                length,
883                h0,
884                g0,
885            }
886        };
887        segments.push(vseg);
888    }
889    Ok(segments)
890}
891
892// --- Segment evaluation ---
893
894fn h_cum_start(seg: &HSeg) -> f64 {
895    match seg {
896        HSeg::Line { cum_start, .. }
897        | HSeg::Arc { cum_start, .. }
898        | HSeg::Transition { cum_start, .. } => *cum_start,
899    }
900}
901
902fn h_length(seg: &HSeg) -> f64 {
903    match seg {
904        HSeg::Line { length, .. }
905        | HSeg::Arc { length, .. }
906        | HSeg::Transition { length, .. } => *length,
907    }
908}
909
910/// Evaluate horizontal segment at local arc length `s ∈ [0, length]`.
911/// Returns `(x, y, heading)`.
912fn h_eval(seg: &HSeg, s: f64) -> (f64, f64, f64) {
913    match seg {
914        HSeg::Line {
915            sx,
916            sy,
917            heading,
918            ..
919        } => (sx + s * heading.cos(), sy + s * heading.sin(), *heading),
920        HSeg::Arc {
921            sx,
922            sy,
923            heading,
924            radius,
925            ccw,
926            ..
927        } => {
928            let sign = if *ccw { 1.0 } else { -1.0 };
929            let theta = s / radius;
930            let new_heading = heading + sign * theta;
931            // Centre lies perpendicular to heading at distance radius.
932            // CCW → perpendicular-left = (−sin h, cos h);
933            // CW  → perpendicular-right = (sin h, −cos h).
934            let (nx, ny) = if *ccw {
935                (-heading.sin(), heading.cos())
936            } else {
937                (heading.sin(), -heading.cos())
938            };
939            let cx = sx + radius * nx;
940            let cy = sy + radius * ny;
941            // Angle from centre to start point = atan2(−ny, −nx).
942            let start_angle = (-ny).atan2(-nx);
943            let new_angle = start_angle + sign * theta;
944            (
945                cx + radius * new_angle.cos(),
946                cy + radius * new_angle.sin(),
947                new_heading,
948            )
949        }
950        HSeg::Transition {
951            sx,
952            sy,
953            heading,
954            length,
955            start_curv,
956            end_curv,
957            kind,
958            ..
959        } => {
960            // heading(s) = h₀ + κ₀·s + (κ₁-κ₀)·L · g(s/L)
961            //   where g(u) is the integral of the curvature-blend
962            //   profile chosen by the `TransitionKind` (see
963            //   `TransitionKind::heading_integral`). x, y require
964            //   ∫cos(h(s)) ds, ∫sin(h(s)) ds — no closed form except
965            //   for the Clothoid (Fresnel integrals).
966            //
967            // Numerical integration via the composite trapezoidal rule.
968            // Step density scales with arc-length traversed so the
969            // sample interval is roughly constant per metre, with a
970            // minimum of 16 samples for stability.
971            let n = ((s.abs() * 0.5).ceil() as usize).max(16).min(4096);
972            let ds = s / n as f64;
973            let mut x = *sx;
974            let mut y = *sy;
975            let mut prev_cos = heading.cos();
976            let mut prev_sin = heading.sin();
977            for i in 1..=n {
978                let u = i as f64 * ds;
979                let h = *heading
980                    + start_curv * u
981                    + (end_curv - start_curv) * length * kind.heading_integral(u / length);
982                let cs = h.cos();
983                let sn = h.sin();
984                x += 0.5 * ds * (prev_cos + cs);
985                y += 0.5 * ds * (prev_sin + sn);
986                prev_cos = cs;
987                prev_sin = sn;
988            }
989            let final_h = *heading
990                + start_curv * s
991                + (end_curv - start_curv) * length * kind.heading_integral(s / length);
992            (x, y, final_h)
993        }
994    }
995}
996
997fn v_start(seg: &VSeg) -> f64 {
998    match seg {
999        VSeg::Line { start, .. }
1000        | VSeg::Parabolic { start, .. }
1001        | VSeg::CircularArc { start, .. } => *start,
1002    }
1003}
1004
1005fn v_length(seg: &VSeg) -> f64 {
1006    match seg {
1007        VSeg::Line { length, .. }
1008        | VSeg::Parabolic { length, .. }
1009        | VSeg::CircularArc { length, .. } => *length,
1010    }
1011}
1012
1013/// Evaluate vertical segment at local horizontal distance `s ∈ [0, length]`.
1014/// Returns `(height, slope)`.
1015fn v_eval(seg: &VSeg, s: f64) -> (f64, f64) {
1016    match seg {
1017        VSeg::Line { h0, g0, .. } => (h0 + g0 * s, *g0),
1018        VSeg::Parabolic {
1019            h0,
1020            g0,
1021            parabola_constant,
1022            is_convex,
1023            ..
1024        } => {
1025            // IFC4x1 convention: ParabolaConstant K = R (radius-equivalent
1026            // for a parabola in z(x) = z0 + g0·x ± x²/(2K)). `IsConvex=true`
1027            // is a crest curve (curvature downward); `false` is a sag.
1028            let sign = if *is_convex { -1.0 } else { 1.0 };
1029            let k = parabola_constant.abs().max(1e-12);
1030            let z = h0 + g0 * s + sign * (s * s) / (2.0 * k);
1031            let slope = g0 + sign * s / k;
1032            (z, slope)
1033        }
1034        VSeg::CircularArc {
1035            h0,
1036            g0,
1037            radius,
1038            is_convex,
1039            ..
1040        } => {
1041            // Parabolic approximation accurate to ~mm for typical highway
1042            // radii (R > 500 m) over realistic segment lengths.
1043            let sign = if *is_convex { -1.0 } else { 1.0 };
1044            let r = radius.abs().max(1e-12);
1045            let z = h0 + g0 * s + sign * (s * s) / (2.0 * r);
1046            let slope = g0 + sign * s / r;
1047            (z, slope)
1048        }
1049    }
1050}
1051
1052fn v_eval_height(seg: &VSeg, s: f64) -> f64 {
1053    v_eval(seg, s).0
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059
1060    /// Sanity-check straight-line evaluation: a line segment heading
1061    /// along +X must reach `(length, 0)` with unchanged heading.
1062    #[test]
1063    fn line_segment_evaluation() {
1064        let seg = HSeg::Line {
1065            sx: 0.0,
1066            sy: 0.0,
1067            heading: 0.0,
1068            length: 10.0,
1069            cum_start: 0.0,
1070        };
1071        let (x, y, h) = h_eval(&seg, 10.0);
1072        assert!((x - 10.0).abs() < 1e-9);
1073        assert!(y.abs() < 1e-9);
1074        assert!(h.abs() < 1e-9);
1075    }
1076
1077    /// Reproduce the issue #828 bridge fixture's first arc: start at
1078    /// origin, heading 13.36° (= 0.2332 rad), radius 9279, length 2965.68,
1079    /// CW. Per the file, the next segment starts at #103=(2945.13,216.39),
1080    /// so the arc end must land there within rounding error.
1081    #[test]
1082    fn fixture_828_arc_endpoint() {
1083        let seg = HSeg::Arc {
1084            sx: 0.0,
1085            sy: 0.0,
1086            heading: 13.35833333_f64.to_radians(),
1087            radius: 9279.0,
1088            length: 2965.68,
1089            ccw: false,
1090            cum_start: 0.0,
1091        };
1092        let (x, y, _) = h_eval(&seg, 2965.68);
1093        // ~5-inch tolerance accounts for the truncated 13.358333° heading
1094        // in the source file.
1095        assert!((x - 2945.13).abs() < 5.0, "x = {} expected ~2945.13", x);
1096        assert!((y - 216.39).abs() < 5.0, "y = {} expected ~216.39", y);
1097    }
1098
1099    /// Cant rolls the (right, up) axes around the tangent. Drive the
1100    /// roll from a hand-built segment and check that the frame rotates
1101    /// by the expected amount on a straight directrix where the
1102    /// pre-cant axes are easy to reason about.
1103    #[test]
1104    fn cant_rotates_frame_axes() {
1105        // Straight directrix along +X, no slope.
1106        let curve = AlignmentCurve {
1107            horizontal: vec![HSeg::Line {
1108                sx: 0.0,
1109                sy: 0.0,
1110                heading: 0.0,
1111                length: 100.0,
1112                cum_start: 0.0,
1113            }],
1114            vertical: vec![],
1115            cant: vec![],
1116        };
1117        let frame_no_cant = curve.evaluate(50.0);
1118        // Pre-cant axes: right = (0, -1, 0), up = (0, 0, 1).
1119        assert!((frame_no_cant.right.x).abs() < 1e-9);
1120        assert!((frame_no_cant.right.y + 1.0).abs() < 1e-9);
1121        assert!((frame_no_cant.up.z - 1.0).abs() < 1e-9);
1122
1123        let with_cant = curve.with_cant_segments(vec![CantSegSpec {
1124            start: 0.0,
1125            length: 100.0,
1126            roll_start: std::f64::consts::FRAC_PI_2,
1127            roll_end: std::f64::consts::FRAC_PI_2,
1128        }]);
1129        // The processor (not AlignmentCurve::evaluate) is what applies
1130        // the cant roll. We test the roll lookup directly here.
1131        let angle = with_cant.cant_angle(50.0);
1132        assert!((angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9);
1133        // Past the end of the segment → 0 again.
1134        assert!(with_cant.cant_angle(150.0).abs() < 1e-9);
1135    }
1136
1137    /// `from_polyline` builds a piecewise-linear directrix. Each edge
1138    /// becomes one horizontal Line segment + one vertical Line segment;
1139    /// `evaluate(station)` walks them in order.
1140    #[test]
1141    fn polyline_directrix_evaluates_piecewise() {
1142        // Build a 3-point polyline directly (we test the construction
1143        // logic, not the parsing — that's covered by integration tests).
1144        // Path: (0,0,0) → (10, 0, 1) → (10, 10, 2)
1145        // Edge 1: heading 0, length 10, gradient 0.1
1146        // Edge 2: heading π/2, length 10, gradient 0.1
1147        let curve = AlignmentCurve {
1148            horizontal: vec![
1149                HSeg::Line {
1150                    sx: 0.0,
1151                    sy: 0.0,
1152                    heading: 0.0,
1153                    length: 10.0,
1154                    cum_start: 0.0,
1155                },
1156                HSeg::Line {
1157                    sx: 10.0,
1158                    sy: 0.0,
1159                    heading: std::f64::consts::FRAC_PI_2,
1160                    length: 10.0,
1161                    cum_start: 10.0,
1162                },
1163            ],
1164            vertical: vec![
1165                VSeg::Line {
1166                    start: 0.0,
1167                    length: 10.0,
1168                    h0: 0.0,
1169                    g0: 0.1,
1170                },
1171                VSeg::Line {
1172                    start: 10.0,
1173                    length: 10.0,
1174                    h0: 1.0,
1175                    g0: 0.1,
1176                },
1177            ],
1178            cant: vec![],
1179        };
1180        // Mid-point of edge 1: station 5.
1181        let f1 = curve.evaluate(5.0);
1182        assert!((f1.origin.x - 5.0).abs() < 1e-9);
1183        assert!((f1.origin.y).abs() < 1e-9);
1184        assert!((f1.origin.z - 0.5).abs() < 1e-9);
1185        // Mid-point of edge 2: station 15.
1186        let f2 = curve.evaluate(15.0);
1187        assert!((f2.origin.x - 10.0).abs() < 1e-9);
1188        assert!((f2.origin.y - 5.0).abs() < 1e-9);
1189        assert!((f2.origin.z - 1.5).abs() < 1e-9);
1190    }
1191
1192    #[test]
1193    fn transition_kind_heading_integral_normalised() {
1194        // g(0) = 0, g(1) ∈ [0.4, 0.6] (depends on profile — all the
1195        // smoothstep-like profiles have ½ for the integral at the
1196        // midpoint, and the clothoid has ½ exactly).
1197        for kind in [
1198            TransitionKind::Clothoid,
1199            TransitionKind::Bloss,
1200            TransitionKind::Cosine,
1201            TransitionKind::Sine,
1202            TransitionKind::CubicParabola,
1203            TransitionKind::BiquadraticParabola,
1204        ] {
1205            assert!(kind.heading_integral(0.0).abs() < 1e-12, "{:?}", kind);
1206            let mid = kind.heading_integral(0.5);
1207            assert!(mid > 0.0 && mid < 0.5, "{:?} mid={}", kind, mid);
1208            // Clothoid: ½ · u² → ½ · 1 = ½ at u=1.
1209            // Bloss / others: each peaks below ½ as a smooth blend.
1210            let end = kind.heading_integral(1.0);
1211            assert!(end > 0.0 && end < 1.0, "{:?} end={}", kind, end);
1212        }
1213    }
1214
1215    #[test]
1216    fn parabolic_vertical_segment() {
1217        // From fixture #95: K=36000, sag (IsConvex=false), start gradient
1218        // 0.0579, start height 399. At local distance 1680:
1219        //   z = 399 + 0.0579·1680 + 1680²/(2·36000)
1220        //     = 399 + 97.272 + 39.20  = 535.47
1221        let seg = VSeg::Parabolic {
1222            start: 3600.0,
1223            length: 3685.68,
1224            h0: 399.0,
1225            g0: 0.0579,
1226            parabola_constant: 36000.0,
1227            is_convex: false,
1228        };
1229        let (z, slope) = v_eval(&seg, 1680.0);
1230        assert!((z - 535.472).abs() < 0.01, "z = {}", z);
1231        assert!((slope - 0.1046).abs() < 1e-3, "slope = {}", slope);
1232    }
1233}