Skip to main content

ifc_alignment/curve/
assemble.rs

1//! Exact neutral graph assembly for alignment segments and their layouts.
2//!
3//! # Stations
4//!
5//! An `IfcAlignmentHorizontalSegment` does not state its own distance along;
6//! it follows from chaining, and a Viennese bend needs it to read the cant
7//! swing across itself. Both chain walks, [`lower_horizontal_layout`] and
8//! [`lower_horizontal_layout_partial`], therefore keep their own station
9//! accumulator. They are two accumulators, so a test that exercises only one
10//! walk leaves the other unguarded; test both. A single-segment lowering has
11//! no chain context and refuses a Viennese bend rather than assuming station
12//! zero.
13
14use axiolid_core::{Frame2, Point2, Vec2};
15use axiolid_curve::{BSplineCurve2, Circle2, Curve2, ElevationLaw, KnotSpec, Line2};
16use axiolid_model::{
17    CurveRelation, CurveSegment, GeometryGraph, GeometryGraphBuilder, GeometryNode, NodeId,
18    Transition, TrimSelector, TrimmingPreference,
19};
20use ifc_model::{EntityId, Model};
21
22use crate::cant::CantLayout;
23use crate::curve::elevation::elevation_law;
24use crate::curve::seam::{check_position, HorizontalSeam, SeamCheck};
25use crate::curve::spiral::{is_exactly_lowerable, refuse_unlowerable, spiral_curve};
26use crate::curve::terminal::split_closing;
27use crate::error::{AlignmentError, AlignmentResult};
28use crate::horizontal::{
29    read_horizontal_segment, AlignmentUnits, HorizontalSegment, HorizontalSegmentType,
30};
31use crate::vertical::{read_vertical_segment, VerticalSegment};
32use crate::view::AlignmentView;
33
34/// Exact neutral curve graph for one or more IFC alignment segments.
35#[derive(Debug, Clone, PartialEq)]
36#[non_exhaustive]
37pub struct LoweredAlignmentCurve {
38    /// The neutral geometry graph holding the lowered curve and its
39    /// supporting nodes (trims, composites).
40    pub graph: GeometryGraph,
41    /// The graph node the lowered curve is rooted at.
42    pub root: NodeId,
43    /// The segment(s) this graph was lowered from, in authored order.
44    pub sources: Vec<EntityId>,
45    /// Seams between consecutive horizontal segments and how each was
46    /// checked, in authored order. Empty for a single segment and for a
47    /// vertical lowering.
48    pub seams: Vec<HorizontalSeam>,
49}
50
51/// The composite transition a checked seam supports.
52///
53/// IFC alignment segments carry no explicit continuity attribute (unlike
54/// `IfcCompositeCurveSegment.Transition`); it is a geometric fact about the
55/// lowered curves. Only a seam whose position was verified in closed form
56/// claims `Continuous`. A seam after a transition spiral claims nothing:
57/// `Discontinuous` is this crate's no-claim value, the same one the first
58/// segment carries, and the seam itself is reported in
59/// [`LoweredAlignmentCurve::seams`] as [`SeamCheck::Authored`].
60fn seam_transition(seam: &HorizontalSeam) -> Transition {
61    match seam.position {
62        SeamCheck::Verified => Transition::Continuous,
63        _ => Transition::Discontinuous,
64    }
65}
66
67/// One segment this crate declined to lower, and why.
68///
69/// Carries the authored type name verbatim so a caller can report
70/// `CLOTHOID` rather than "some unsupported segment", and the entity id so
71/// it can point at the offending line of the source file.
72#[derive(Debug, Clone, PartialEq)]
73#[non_exhaustive]
74pub struct RefusedSegment {
75    /// The `IfcAlignmentHorizontalSegment` entity that was refused.
76    pub entity: EntityId,
77    /// The segment's authored `PredefinedType`, preserved exactly.
78    pub type_name: String,
79    /// Why this segment could not be lowered exactly.
80    pub reason: AlignmentError,
81}
82
83/// A horizontal layout lowered as far as exactness allows.
84///
85/// Real railway and highway alignments interleave transition spirals between
86/// their lines and arcs, so an all-or-nothing lowering refuses essentially
87/// every production file. This result keeps what is exactly lowerable and
88/// names what is not, instead of collapsing both into one opaque error.
89///
90/// `runs` holds maximal stretches of consecutive lowerable segments, each
91/// assembled into a single composite exactly as [`lower_horizontal_layout`]
92/// would, seams included. A run ends only where a segment is refused:
93/// continuity across a segment this crate did not lower is not a fact it is
94/// entitled to assert. A seam after a transition spiral does not end a run;
95/// it is recorded in that run's `seams` as unverified.
96#[derive(Debug, Clone, PartialEq)]
97#[non_exhaustive]
98pub struct PartialHorizontalLayout {
99    /// Maximal runs of consecutive exactly-lowered segments, in authored
100    /// order.
101    pub runs: Vec<LoweredAlignmentCurve>,
102    /// Refused segments in authored order.
103    pub refused: Vec<RefusedSegment>,
104    /// Total segments nested by the layout, lowered or not.
105    pub segment_count: usize,
106}
107
108impl PartialHorizontalLayout {
109    /// Whether every segment lowered exactly.
110    ///
111    /// When true the layout is covered by exactly one run, matching what
112    /// [`lower_horizontal_layout`] returns: runs split only at refused
113    /// segments, never at a seam after a spiral.
114    #[must_use]
115    pub fn is_complete(&self) -> bool {
116        self.refused.is_empty()
117    }
118
119    /// Number of segments lowered exactly.
120    #[must_use]
121    pub fn lowered_count(&self) -> usize {
122        self.segment_count - self.refused.len()
123    }
124}
125
126/// Lower one `IfcAlignmentHorizontalSegment` to an exact neutral curve.
127///
128/// Fails if `id` is missing or malformed, or the segment is a transition
129/// spiral family not in `[is_exactly_lowerable]`'s set, or `CIRCULARARC`
130/// with unequal/zero/non-finite start and end radii, or `LINE` with a
131/// non-zero radius -- the pinned neutral curve vocabulary has no exact
132/// primitive for those cases. A zero-length segment, which IFC4.3 allows
133/// only as a layout's closing segment, has no geometry to lower and is
134/// refused as [`AlignmentError::InvalidSegment`].
135pub fn lower_horizontal_segment(
136    model: &Model,
137    id: EntityId,
138    units: AlignmentUnits,
139) -> AlignmentResult<LoweredAlignmentCurve> {
140    let segment = read_horizontal_segment(model, id, units)?;
141    if segment.segment_length == 0.0 {
142        return Err(no_geometry(id));
143    }
144    let mut builder = GeometryGraphBuilder::new();
145    let root = match &segment.segment_type {
146        HorizontalSegmentType::Line => push_line(&mut builder, &segment)?,
147        HorizontalSegmentType::CircularArc => push_arc(&mut builder, &segment)?,
148        HorizontalSegmentType::Transition(name) if is_exactly_lowerable(name, false) => {
149            push_spiral(&mut builder, &segment, name, None, 0.0)?
150        }
151        _ => return Err(refuse_unlowerable(&segment)),
152    };
153    finish(builder, root, vec![id])
154}
155
156/// Lower one `IfcAlignmentVerticalSegment` to an exact neutral curve in the
157/// (distance along, height) plane.
158///
159/// The segment goes through [`elevation_law`], the same law the composed
160/// gradient curve uses, so the per-segment and composed paths accept,
161/// refuse and place exactly the same segments. The law is then written as a
162/// `Curve2` whose parameter is plan distance from `StartDistAlong`, trimmed
163/// to `0..HorizontalLength`:
164///
165/// - degree 1 (`CONSTANTGRADIENT`): a line through
166///   `(StartDistAlong, StartHeight)` with direction `(1, grade)`;
167/// - degree 2 (`PARABOLICARC`): a quadratic Bezier over the knot span
168///   `0..L` with control points `z0`, `z0 + g0 L / 2`, `z(L)` at stations
169///   `d0`, `d0 + L / 2`, `d0 + L`. Equally spaced stations make the station
170///   linear in the parameter, so this is the IFC parabola exactly, not a fit.
171///
172/// # Errors
173///
174/// Fails if `id` is missing or malformed, and with everything
175/// [`elevation_law`] refuses: `CIRCULARARC` and `CLOTHOID` (no exact law),
176/// and parameters that contradict their family. A zero-length segment (a
177/// layout's closing segment) has no geometry to lower and is refused as
178/// [`AlignmentError::InvalidSegment`].
179pub fn lower_vertical_segment(
180    model: &Model,
181    id: EntityId,
182    units: AlignmentUnits,
183) -> AlignmentResult<LoweredAlignmentCurve> {
184    let segment = read_vertical_segment(model, id, units)?;
185    if segment.horizontal_length == 0.0 {
186        return Err(no_geometry(id));
187    }
188    let law = elevation_law(&segment)?;
189    let mut builder = GeometryGraphBuilder::new();
190    let root = push_vertical_law(&mut builder, &segment, &law)?;
191    finish(builder, root, vec![id])
192}
193
194/// Push a `IfcAlignmentHorizontalSegment.LINE` as a trimmed neutral line.
195fn push_line(
196    builder: &mut GeometryGraphBuilder,
197    segment: &HorizontalSegment,
198) -> AlignmentResult<NodeId> {
199    if segment.start_radius != 0.0 || segment.end_radius != 0.0 {
200        return Err(AlignmentError::InvalidSegment {
201            entity: segment.entity,
202            detail: "LINE requires zero start and end radii",
203        });
204    }
205    let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
206    let basis = push(
207        builder,
208        GeometryNode::Curve2(Curve2::Line(Line2 {
209            origin: segment.start_point,
210            direction,
211        })),
212    )?;
213    push(
214        builder,
215        GeometryNode::CurveRelation(CurveRelation::Trimmed {
216            basis,
217            start: vec![TrimSelector::Parameter(0.0)],
218            end: vec![TrimSelector::Parameter(segment.segment_length)],
219            sense_agreement: true,
220            preference: TrimmingPreference::Parameter,
221        }),
222    )
223}
224
225/// Push a `IfcAlignmentHorizontalSegment.CIRCULARARC` as a trimmed neutral
226/// circle.
227fn push_arc(
228    builder: &mut GeometryGraphBuilder,
229    segment: &HorizontalSegment,
230) -> AlignmentResult<NodeId> {
231    if segment.start_radius == 0.0
232        || segment.start_radius != segment.end_radius
233        || !segment.start_radius.is_finite()
234    {
235        return Err(AlignmentError::InvalidSegment {
236            entity: segment.entity,
237            detail: "CIRCULARARC requires equal, finite, non-zero start and end radii",
238        });
239    }
240    let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
241    let left = Vec2::new(-direction.y, direction.x);
242    let signed_radius = segment.start_radius;
243    let radius = signed_radius.abs();
244    let centre = segment.start_point + left * signed_radius;
245    // Derive the radial frame from the source tangent and curvature sign. Using
246    // `(start - centre) / radius` loses the direction when a tiny radius is
247    // added to a large global coordinate.
248    let x = left * -signed_radius.signum();
249    // Keep the frame right-handed. A negative radius then has a negative end
250    // parameter, which preserves the source traversal direction exactly.
251    let y = Vec2::new(-x.y, x.x);
252    let sweep = segment.segment_length / signed_radius;
253    if [centre.x, centre.y, x.x, x.y, y.x, y.y, radius, sweep]
254        .iter()
255        .any(|value| !value.is_finite())
256    {
257        return Err(AlignmentError::InvalidSegment {
258            entity: segment.entity,
259            detail: "CIRCULARARC derived frame and trim parameters must be finite",
260        });
261    }
262    let basis = push(
263        builder,
264        GeometryNode::Curve2(Curve2::Circle(Circle2 {
265            frame: Frame2 {
266                origin: centre,
267                x,
268                y,
269            },
270            radius,
271        })),
272    )?;
273    push(
274        builder,
275        GeometryNode::CurveRelation(CurveRelation::Trimmed {
276            basis,
277            start: vec![TrimSelector::Parameter(0.0)],
278            end: vec![TrimSelector::Parameter(sweep)],
279            sense_agreement: true,
280            preference: TrimmingPreference::Parameter,
281        }),
282    )
283}
284
285/// Push a vertical segment's exact elevation law as a trimmed `Curve2` in
286/// the (distance along, height) plane, parameterised by plan distance.
287fn push_vertical_law(
288    builder: &mut GeometryGraphBuilder,
289    segment: &VerticalSegment,
290    law: &ElevationLaw,
291) -> AlignmentResult<NodeId> {
292    let unsupported = |detail| AlignmentError::Unsupported {
293        entity: segment.entity,
294        type_name: segment.predefined_type.source_name().to_owned(),
295        detail,
296    };
297    let ElevationLaw::Polynomial { coefficients } = law else {
298        return Err(unsupported(
299            "a single vertical segment lowers from one polynomial piece",
300        ));
301    };
302    let start = segment.start_dist_along;
303    let length = segment.horizontal_length;
304    let coefficient = |power: usize| coefficients.get(power).copied().unwrap_or(0.0);
305    let curve = match coefficients.len() {
306        0..=2 => Curve2::Line(Line2 {
307            origin: Point2::new(start, coefficient(0)),
308            direction: Vec2::new(1.0, coefficient(1)),
309        }),
310        3 => {
311            let (z0, g0, c2) = (coefficient(0), coefficient(1), coefficient(2));
312            let control_points = vec![
313                Point2::new(start, z0),
314                Point2::new(start + 0.5 * length, z0 + 0.5 * g0 * length),
315                Point2::new(start + length, z0 + g0 * length + c2 * length * length),
316            ];
317            if control_points
318                .iter()
319                .any(|p| !p.x.is_finite() || !p.y.is_finite())
320            {
321                return Err(AlignmentError::InvalidSegment {
322                    entity: segment.entity,
323                    detail: "vertical control points must be finite",
324                });
325            }
326            Curve2::BSpline(BSplineCurve2 {
327                degree: 2,
328                control_points,
329                knots: vec![0.0, length],
330                multiplicities: vec![3, 3],
331                weights: None,
332                closed: false,
333                self_intersect: Some(false),
334                knot_spec: KnotSpec::PiecewiseBezier,
335            })
336        }
337        _ => {
338            return Err(unsupported(
339                "the vertical law has no exact neutral curve of its degree",
340            ))
341        }
342    };
343    let basis = push(builder, GeometryNode::Curve2(curve))?;
344    push(
345        builder,
346        GeometryNode::CurveRelation(CurveRelation::Trimmed {
347            basis,
348            start: vec![TrimSelector::Parameter(0.0)],
349            end: vec![TrimSelector::Parameter(length)],
350            sense_agreement: true,
351            preference: TrimmingPreference::Parameter,
352        }),
353    )
354}
355
356/// Push a transition spiral as an exact intrinsic curve, trimmed to its
357/// authored length.
358fn push_spiral(
359    builder: &mut GeometryGraphBuilder,
360    segment: &HorizontalSegment,
361    name: &str,
362    cant: Option<&CantLayout>,
363    start_distance: f64,
364) -> AlignmentResult<NodeId> {
365    let curve = spiral_curve(segment, name, cant, start_distance)?;
366    let basis = push(builder, GeometryNode::Curve2(curve))?;
367    push(
368        builder,
369        GeometryNode::CurveRelation(CurveRelation::Trimmed {
370            basis,
371            start: vec![TrimSelector::Parameter(0.0)],
372            end: vec![TrimSelector::Parameter(segment.segment_length)],
373            sense_agreement: true,
374            preference: TrimmingPreference::Parameter,
375        }),
376    )
377}
378
379/// A zero-length segment lowered on its own: IFC4.3 allows one only to
380/// close a layout, where it contributes no geometry.
381fn no_geometry(entity: EntityId) -> AlignmentError {
382    AlignmentError::InvalidSegment {
383        entity,
384        detail: "a zero-length segment has no geometry to lower; IFC4.3 allows it only as the \
385                 closing segment of a layout",
386    }
387}
388
389fn push(builder: &mut GeometryGraphBuilder, node: GeometryNode) -> AlignmentResult<NodeId> {
390    builder.push(node).map_err(|error| AlignmentError::Graph {
391        detail: error.to_string(),
392    })
393}
394
395pub(super) fn finish(
396    builder: GeometryGraphBuilder,
397    root: NodeId,
398    sources: Vec<EntityId>,
399) -> AlignmentResult<LoweredAlignmentCurve> {
400    let graph = builder
401        .finish(vec![root])
402        .map_err(|error| AlignmentError::Graph {
403            detail: error.to_string(),
404        })?;
405    Ok(LoweredAlignmentCurve {
406        graph,
407        root,
408        sources,
409        seams: Vec::new(),
410    })
411}
412
413/// Lower an `IfcAlignmentHorizontal`'s nested segment chain to one exact
414/// neutral curve, refusing (rather than approximating) any segment that
415/// `lower_horizontal_segment` cannot lower exactly.
416///
417/// Each segment keeps its own authored start frame inside the composite.
418/// Seams are checked by the rule in [`SeamCheck`]: a position gap after a
419/// `LINE` or `CIRCULARARC` is refused, and a seam after a transition spiral,
420/// whose end point has no closed form, is accepted as authored and reported
421/// in [`LoweredAlignmentCurve::seams`].
422///
423/// The zero-length segment IFC4.3 requires at the end of a layout adds no
424/// composite segment; its seam is checked and reported last. A zero-length
425/// segment anywhere else is refused ([`AlignmentError::SemanticViolation`]).
426pub fn lower_horizontal_layout(
427    model: &Model,
428    entity: EntityId,
429    units: AlignmentUnits,
430    cant: Option<&CantLayout>,
431) -> AlignmentResult<LoweredAlignmentCurve> {
432    let view = AlignmentView::for_model(model)?;
433    let horizontal_entity = model
434        .get(entity)
435        .ok_or(AlignmentError::MissingEntity { entity })?;
436    if !view
437        .schema
438        .is_a(&horizontal_entity.type_name, "IfcAlignmentHorizontal")
439    {
440        return Err(AlignmentError::WrongType {
441            entity,
442            expected: "IfcAlignmentHorizontal",
443            actual: horizontal_entity.type_name.to_string(),
444        });
445    }
446    let ids = view.segment_chain(entity, "IfcAlignmentHorizontalSegment")?;
447    if ids.is_empty() {
448        return Err(AlignmentError::SemanticViolation {
449            entity: Some(entity),
450            rule: "IfcAlignmentHorizontal must nest at least one IfcAlignmentSegment",
451        });
452    }
453
454    let mut segments = Vec::with_capacity(ids.len());
455    for id in &ids {
456        segments.push(read_horizontal_segment(model, *id, units)?);
457    }
458
459    let (body, closing) = split_closing(&segments, |s| s.segment_length, |s| s.entity)?;
460
461    let mut builder = GeometryGraphBuilder::new();
462    let mut composite_segments = Vec::with_capacity(body.len());
463    let mut seams = Vec::with_capacity(segments.len().saturating_sub(1));
464    let mut station = 0.0_f64;
465    for (index, segment) in body.iter().enumerate() {
466        let curve = match &segment.segment_type {
467            HorizontalSegmentType::Line => push_line(&mut builder, segment)?,
468            HorizontalSegmentType::CircularArc => push_arc(&mut builder, segment)?,
469            HorizontalSegmentType::Transition(name)
470                if is_exactly_lowerable(name, cant.is_some()) =>
471            {
472                push_spiral(&mut builder, segment, name, cant, station)?
473            }
474            _ => return Err(refuse_unlowerable(segment)),
475        };
476        let transition = if index == 0 {
477            Transition::Discontinuous
478        } else {
479            let seam = check_position(&body[index - 1], segment, station)?;
480            let transition = seam_transition(&seam);
481            seams.push(seam);
482            transition
483        };
484        composite_segments.push(CurveSegment {
485            curve,
486            same_sense: true,
487            transition,
488        });
489        station += segment.segment_length;
490    }
491    // The closing segment adds no composite segment; its start is still a
492    // seam against the last piece.
493    if let (Some(closing), Some(last)) = (closing, body.last()) {
494        seams.push(check_position(last, closing, station)?);
495    }
496
497    let root = push(
498        &mut builder,
499        GeometryNode::CurveRelation(CurveRelation::Composite {
500            segments: composite_segments,
501        }),
502    )?;
503    let mut lowered = finish(builder, root, ids)?;
504    lowered.seams = seams;
505    Ok(lowered)
506}
507
508/// Lower a horizontal layout as far as exactness allows, reporting refusals.
509///
510/// Unlike [`lower_horizontal_layout`], a segment without an exact law (such
511/// as `CUBIC`) does not abort the whole layout. The segments around it still
512/// lower exactly; the refused one is recorded in
513/// [`PartialHorizontalLayout::refused`] with its authored type name and
514/// entity id. Seams are checked by the same rule as the strict path.
515///
516/// Nothing here is approximated. A refused segment stays refused -- this
517/// reports the boundary per segment instead of collapsing an entire
518/// production alignment into one opaque error.
519///
520/// Errors that are not a lowering refusal (a wrong entity type, a malformed
521/// attribute, an empty layout, a zero-length segment before the end) still
522/// fail the whole call, because they mean the layout could not be read at
523/// all rather than that one segment resisted exact lowering.
524///
525/// The closing zero-length segment IFC4.3 requires is neither lowered nor
526/// refused: it adds no geometry, counts in
527/// [`PartialHorizontalLayout::segment_count`] and
528/// [`PartialHorizontalLayout::lowered_count`], and its seam is reported on
529/// the last run when the segment before it was lowered.
530pub fn lower_horizontal_layout_partial(
531    model: &Model,
532    entity: EntityId,
533    units: AlignmentUnits,
534    cant: Option<&CantLayout>,
535) -> AlignmentResult<PartialHorizontalLayout> {
536    let view = AlignmentView::for_model(model)?;
537    let horizontal_entity = model
538        .get(entity)
539        .ok_or(AlignmentError::MissingEntity { entity })?;
540    if !view
541        .schema
542        .is_a(&horizontal_entity.type_name, "IfcAlignmentHorizontal")
543    {
544        return Err(AlignmentError::WrongType {
545            entity,
546            expected: "IfcAlignmentHorizontal",
547            actual: horizontal_entity.type_name.to_string(),
548        });
549    }
550    let ids = view.segment_chain(entity, "IfcAlignmentHorizontalSegment")?;
551    if ids.is_empty() {
552        return Err(AlignmentError::SemanticViolation {
553            entity: Some(entity),
554            rule: "IfcAlignmentHorizontal must nest at least one IfcAlignmentSegment",
555        });
556    }
557
558    let mut segments = Vec::with_capacity(ids.len());
559    for id in &ids {
560        segments.push(read_horizontal_segment(model, *id, units)?);
561    }
562
563    let (body, closing) = split_closing(&segments, |s| s.segment_length, |s| s.entity)?;
564
565    let mut runs = Vec::new();
566    let mut refused = Vec::new();
567    // Segments accumulated since the last refusal, as (index, node) pairs in a
568    // builder that is discarded and restarted whenever a run ends.
569    let mut builder = GeometryGraphBuilder::new();
570    let mut pending: Vec<(usize, CurveSegment)> = Vec::new();
571    let mut pending_ids: Vec<EntityId> = Vec::new();
572    let mut pending_seams: Vec<HorizontalSeam> = Vec::new();
573    let mut station = 0.0_f64;
574
575    for (index, segment) in body.iter().enumerate() {
576        let lowered = match &segment.segment_type {
577            HorizontalSegmentType::Line => push_line(&mut builder, segment),
578            HorizontalSegmentType::CircularArc => push_arc(&mut builder, segment),
579            HorizontalSegmentType::Transition(name)
580                if is_exactly_lowerable(name, cant.is_some()) =>
581            {
582                push_spiral(&mut builder, segment, name, cant, station)
583            }
584            _ => Err(refuse_unlowerable(segment)),
585        };
586        let start_station = station;
587        // Advance before the refusal branch below: that path `continue`s, and
588        // advancing at the loop tail would mis-station every later segment.
589        station += segment.segment_length;
590        let curve = match lowered {
591            Ok(curve) => curve,
592            Err(reason) => {
593                // The run ends here: continuity across a segment this crate
594                // did not lower is not a fact it can assert.
595                flush_run(
596                    &mut runs,
597                    &mut builder,
598                    &mut pending,
599                    &mut pending_ids,
600                    &mut pending_seams,
601                )?;
602                refused.push(RefusedSegment {
603                    entity: segment.entity,
604                    type_name: segment.segment_type.source_name().to_owned(),
605                    reason,
606                });
607                continue;
608            }
609        };
610        // Continuity is only claimed against the immediately preceding
611        // segment when that segment is in the same run.
612        let transition = match pending.last() {
613            None => Transition::Discontinuous,
614            Some((previous_index, _)) => {
615                let seam = check_position(&body[*previous_index], segment, start_station)?;
616                let transition = seam_transition(&seam);
617                pending_seams.push(seam);
618                transition
619            }
620        };
621        pending.push((
622            index,
623            CurveSegment {
624                curve,
625                same_sense: true,
626                transition,
627            },
628        ));
629        pending_ids.push(segment.entity);
630    }
631    // The closing segment joins the last run only when the piece before it
632    // was lowered: continuity across a refused segment is not asserted.
633    if let (Some(closing), Some((previous_index, _))) = (closing, pending.last()) {
634        pending_seams.push(check_position(&body[*previous_index], closing, station)?);
635    }
636    flush_run(
637        &mut runs,
638        &mut builder,
639        &mut pending,
640        &mut pending_ids,
641        &mut pending_seams,
642    )?;
643
644    Ok(PartialHorizontalLayout {
645        runs,
646        refused,
647        segment_count: segments.len(),
648    })
649}
650
651/// Close the current run, if any, into its own composite curve.
652///
653/// Takes the builder by mutable reference and replaces it, so each run owns a
654/// self-contained graph whose node ids are meaningful within that graph.
655fn flush_run(
656    runs: &mut Vec<LoweredAlignmentCurve>,
657    builder: &mut GeometryGraphBuilder,
658    pending: &mut Vec<(usize, CurveSegment)>,
659    pending_ids: &mut Vec<EntityId>,
660    pending_seams: &mut Vec<HorizontalSeam>,
661) -> AlignmentResult<()> {
662    if pending.is_empty() {
663        // Nothing accumulated; drop whatever partial nodes exist so a refused
664        // segment does not leak orphan nodes into the next run.
665        *builder = GeometryGraphBuilder::new();
666        pending_ids.clear();
667        pending_seams.clear();
668        return Ok(());
669    }
670    let mut finished = GeometryGraphBuilder::new();
671    core::mem::swap(builder, &mut finished);
672    let composite_segments: Vec<CurveSegment> =
673        pending.drain(..).map(|(_, segment)| segment).collect();
674    let root = push(
675        &mut finished,
676        GeometryNode::CurveRelation(CurveRelation::Composite {
677            segments: composite_segments,
678        }),
679    )?;
680    let sources = core::mem::take(pending_ids);
681    let mut run = finish(finished, root, sources)?;
682    run.seams = core::mem::take(pending_seams);
683    runs.push(run);
684    Ok(())
685}