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