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::{Circle2, Curve2, 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::spiral::{is_exactly_lowerable, spiral_curve};
24use crate::error::{AlignmentError, AlignmentResult};
25use crate::horizontal::{
26    read_horizontal_segment, AlignmentUnits, HorizontalSegment, HorizontalSegmentType,
27};
28use crate::vertical::{read_vertical_segment, VerticalSegment, VerticalSegmentType};
29use crate::view::AlignmentView;
30
31/// Exact neutral curve graph for one or more IFC alignment segments.
32#[derive(Debug, Clone, PartialEq)]
33#[non_exhaustive]
34pub struct LoweredAlignmentCurve {
35    /// The neutral geometry graph holding the lowered curve and its
36    /// supporting nodes (trims, composites).
37    pub graph: GeometryGraph,
38    /// The graph node the lowered curve is rooted at.
39    pub root: NodeId,
40    /// The segment(s) this graph was lowered from, in authored order.
41    pub sources: Vec<EntityId>,
42}
43
44/// How tightly two consecutive lowered segments actually connect.
45///
46/// IFC alignment segments carry no explicit continuity attribute (unlike
47/// `IfcCompositeCurveSegment.Transition`); it is a geometric fact about the
48/// lowered curves, not something the source states. Only exact equality
49/// counts here -- floating error from unrelated upstream authoring is not
50/// this crate's problem to paper over, so the tolerance is a parameter the
51/// caller controls rather than a silent default.
52fn observed_transition(end: Point2, next_start: Point2, tolerance: f64) -> Option<Transition> {
53    if end.distance(next_start) <= tolerance {
54        Some(Transition::Continuous)
55    } else {
56        None
57    }
58}
59
60/// Exact end point of a segment this crate can lower, in closed form.
61///
62/// `None` for every transition-spiral family: their end point is a
63/// Fresnel-type integral, so there is no closed form to return and this
64/// crate will not quadrature one into existence. That is the same boundary
65/// [`lower_horizontal_segment`] enforces, expressed as data so a caller can
66/// see where a run has to stop rather than only that it failed.
67fn closed_form_end_point(segment: &HorizontalSegment) -> Option<Point2> {
68    match segment.segment_type {
69        HorizontalSegmentType::Line => {
70            let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
71            Some(segment.start_point + direction * segment.segment_length)
72        }
73        HorizontalSegmentType::CircularArc if segment.start_radius != 0.0 => {
74            let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
75            let left = Vec2::new(-direction.y, direction.x);
76            let centre = segment.start_point + left * segment.start_radius;
77            let sweep = segment.segment_length / segment.start_radius;
78            let radial = segment.start_point - centre;
79            let (sin_s, cos_s) = sweep.sin_cos();
80            Some(
81                centre
82                    + Vec2::new(
83                        radial.x * cos_s - radial.y * sin_s,
84                        radial.x * sin_s + radial.y * cos_s,
85                    ),
86            )
87        }
88        _ => None,
89    }
90}
91
92/// One segment this crate declined to lower, and why.
93///
94/// Carries the authored type name verbatim so a caller can report
95/// `CLOTHOID` rather than "some unsupported segment", and the entity id so
96/// it can point at the offending line of the source file.
97#[derive(Debug, Clone, PartialEq)]
98#[non_exhaustive]
99pub struct RefusedSegment {
100    /// The `IfcAlignmentHorizontalSegment` entity that was refused.
101    pub entity: EntityId,
102    /// The segment's authored `PredefinedType`, preserved exactly.
103    pub type_name: String,
104    /// Why this segment could not be lowered exactly.
105    pub reason: AlignmentError,
106}
107
108/// A horizontal layout lowered as far as exactness allows.
109///
110/// Real railway and highway alignments interleave transition spirals between
111/// their lines and arcs, so an all-or-nothing lowering refuses essentially
112/// every production file. This result keeps what is exactly lowerable and
113/// names what is not, instead of collapsing both into one opaque error.
114///
115/// `runs` holds maximal stretches of consecutive lowerable segments, each
116/// assembled into a single composite exactly as [`lower_horizontal_layout`]
117/// would. A run ends wherever a segment is refused: continuity across a
118/// segment this crate did not lower is not a fact it is entitled to assert.
119#[derive(Debug, Clone, PartialEq)]
120#[non_exhaustive]
121pub struct PartialHorizontalLayout {
122    /// Maximal runs of consecutive exactly-lowered segments, in authored
123    /// order.
124    pub runs: Vec<LoweredAlignmentCurve>,
125    /// Refused segments in authored order.
126    pub refused: Vec<RefusedSegment>,
127    /// Total segments nested by the layout, lowered or not.
128    pub segment_count: usize,
129}
130
131impl PartialHorizontalLayout {
132    /// Whether every segment lowered exactly.
133    ///
134    /// When true the layout is covered by exactly one run, matching what
135    /// [`lower_horizontal_layout`] returns.
136    #[must_use]
137    pub fn is_complete(&self) -> bool {
138        self.refused.is_empty()
139    }
140
141    /// Number of segments lowered exactly.
142    #[must_use]
143    pub fn lowered_count(&self) -> usize {
144        self.segment_count - self.refused.len()
145    }
146}
147
148/// Lower one `IfcAlignmentHorizontalSegment` to an exact neutral curve.
149///
150/// Fails if `id` is missing or malformed, or the segment is a transition
151/// spiral family not in `[is_exactly_lowerable]`'s set, or `CIRCULARARC`
152/// with unequal/zero/non-finite start and end radii, or `LINE` with a
153/// non-zero radius -- the pinned neutral curve vocabulary has no exact
154/// primitive for those cases.
155pub fn lower_horizontal_segment(
156    model: &Model,
157    id: EntityId,
158    units: AlignmentUnits,
159) -> AlignmentResult<LoweredAlignmentCurve> {
160    let segment = read_horizontal_segment(model, id, units)?;
161    let mut builder = GeometryGraphBuilder::new();
162    let root =
163        match &segment.segment_type {
164            HorizontalSegmentType::Line => push_line(&mut builder, &segment)?,
165            HorizontalSegmentType::CircularArc => push_arc(&mut builder, &segment)?,
166            HorizontalSegmentType::Transition(name) if is_exactly_lowerable(name, false) => {
167                push_spiral(&mut builder, &segment, name, None, 0.0)?
168            }
169            kind => return Err(AlignmentError::Unsupported {
170                entity: id,
171                type_name: kind.source_name().to_owned(),
172                detail:
173                    "the pinned neutral curve vocabulary has no exact transition-curve primitive",
174            }),
175        };
176    finish(builder, root, vec![id])
177}
178
179/// Lower one `IfcAlignmentVerticalSegment` to an exact neutral curve.
180///
181/// Fails if `id` is missing or malformed, or the segment is not
182/// `CONSTANTGRADIENT`, or has a curvature radius, or unequal start/end
183/// gradients -- exact neutral vertical lowering does not yet cover arcs or
184/// parabolas.
185pub fn lower_vertical_segment(
186    model: &Model,
187    id: EntityId,
188    units: AlignmentUnits,
189) -> AlignmentResult<LoweredAlignmentCurve> {
190    let segment = read_vertical_segment(model, id, units)?;
191    let mut builder = GeometryGraphBuilder::new();
192    let root = push_constant_gradient(&mut builder, &segment)?;
193    finish(builder, root, vec![id])
194}
195
196/// Push a `IfcAlignmentHorizontalSegment.LINE` as a trimmed neutral line.
197fn push_line(
198    builder: &mut GeometryGraphBuilder,
199    segment: &HorizontalSegment,
200) -> AlignmentResult<NodeId> {
201    if segment.start_radius != 0.0 || segment.end_radius != 0.0 {
202        return Err(AlignmentError::InvalidSegment {
203            entity: segment.entity,
204            detail: "LINE requires zero start and end radii",
205        });
206    }
207    let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
208    let basis = push(
209        builder,
210        GeometryNode::Curve2(Curve2::Line(Line2 {
211            origin: segment.start_point,
212            direction,
213        })),
214    )?;
215    push(
216        builder,
217        GeometryNode::CurveRelation(CurveRelation::Trimmed {
218            basis,
219            start: vec![TrimSelector::Parameter(0.0)],
220            end: vec![TrimSelector::Parameter(segment.segment_length)],
221            sense_agreement: true,
222            preference: TrimmingPreference::Parameter,
223        }),
224    )
225}
226
227/// Push a `IfcAlignmentHorizontalSegment.CIRCULARARC` as a trimmed neutral
228/// circle.
229fn push_arc(
230    builder: &mut GeometryGraphBuilder,
231    segment: &HorizontalSegment,
232) -> AlignmentResult<NodeId> {
233    if segment.start_radius == 0.0
234        || segment.start_radius != segment.end_radius
235        || !segment.start_radius.is_finite()
236    {
237        return Err(AlignmentError::InvalidSegment {
238            entity: segment.entity,
239            detail: "CIRCULARARC requires equal, finite, non-zero start and end radii",
240        });
241    }
242    let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
243    let left = Vec2::new(-direction.y, direction.x);
244    let signed_radius = segment.start_radius;
245    let radius = signed_radius.abs();
246    let centre = segment.start_point + left * signed_radius;
247    // Derive the radial frame from the source tangent and curvature sign. Using
248    // `(start - centre) / radius` loses the direction when a tiny radius is
249    // added to a large global coordinate.
250    let x = left * -signed_radius.signum();
251    // Keep the frame right-handed. A negative radius then has a negative end
252    // parameter, which preserves the source traversal direction exactly.
253    let y = Vec2::new(-x.y, x.x);
254    let sweep = segment.segment_length / signed_radius;
255    if [centre.x, centre.y, x.x, x.y, y.x, y.y, radius, sweep]
256        .iter()
257        .any(|value| !value.is_finite())
258    {
259        return Err(AlignmentError::InvalidSegment {
260            entity: segment.entity,
261            detail: "CIRCULARARC derived frame and trim parameters must be finite",
262        });
263    }
264    let basis = push(
265        builder,
266        GeometryNode::Curve2(Curve2::Circle(Circle2 {
267            frame: Frame2 {
268                origin: centre,
269                x,
270                y,
271            },
272            radius,
273        })),
274    )?;
275    push(
276        builder,
277        GeometryNode::CurveRelation(CurveRelation::Trimmed {
278            basis,
279            start: vec![TrimSelector::Parameter(0.0)],
280            end: vec![TrimSelector::Parameter(sweep)],
281            sense_agreement: true,
282            preference: TrimmingPreference::Parameter,
283        }),
284    )
285}
286
287/// Push a `IfcAlignmentVerticalSegment.CONSTANTGRADIENT` as a trimmed
288/// neutral line in the (distance-along, height) plane.
289fn push_constant_gradient(
290    builder: &mut GeometryGraphBuilder,
291    segment: &VerticalSegment,
292) -> AlignmentResult<NodeId> {
293    if !matches!(
294        segment.predefined_type,
295        VerticalSegmentType::ConstantGradient
296    ) {
297        return Err(AlignmentError::Unsupported {
298            entity: segment.entity,
299            type_name: segment.predefined_type.source_name().to_owned(),
300            detail: "exact neutral vertical lowering is currently limited to constant gradient",
301        });
302    }
303    if segment.radius_of_curvature.is_some() || segment.start_gradient != segment.end_gradient {
304        return Err(AlignmentError::InvalidSegment {
305            entity: segment.entity,
306            detail: "CONSTANTGRADIENT requires equal gradients and no curvature radius",
307        });
308    }
309    let basis = push(
310        builder,
311        GeometryNode::Curve2(Curve2::Line(Line2 {
312            origin: Point2::new(segment.start_dist_along, segment.start_height),
313            direction: Vec2::new(1.0, segment.start_gradient),
314        })),
315    )?;
316    push(
317        builder,
318        GeometryNode::CurveRelation(CurveRelation::Trimmed {
319            basis,
320            start: vec![TrimSelector::Parameter(0.0)],
321            end: vec![TrimSelector::Parameter(segment.horizontal_length)],
322            sense_agreement: true,
323            preference: TrimmingPreference::Parameter,
324        }),
325    )
326}
327
328/// Push a transition spiral as an exact intrinsic curve, trimmed to its
329/// authored length.
330fn push_spiral(
331    builder: &mut GeometryGraphBuilder,
332    segment: &HorizontalSegment,
333    name: &str,
334    cant: Option<&CantLayout>,
335    start_distance: f64,
336) -> AlignmentResult<NodeId> {
337    let curve = spiral_curve(segment, name, cant, start_distance)?;
338    let basis = push(builder, GeometryNode::Curve2(curve))?;
339    push(
340        builder,
341        GeometryNode::CurveRelation(CurveRelation::Trimmed {
342            basis,
343            start: vec![TrimSelector::Parameter(0.0)],
344            end: vec![TrimSelector::Parameter(segment.segment_length)],
345            sense_agreement: true,
346            preference: TrimmingPreference::Parameter,
347        }),
348    )
349}
350
351fn push(builder: &mut GeometryGraphBuilder, node: GeometryNode) -> AlignmentResult<NodeId> {
352    builder.push(node).map_err(|error| AlignmentError::Graph {
353        detail: error.to_string(),
354    })
355}
356
357pub(super) fn finish(
358    builder: GeometryGraphBuilder,
359    root: NodeId,
360    sources: Vec<EntityId>,
361) -> AlignmentResult<LoweredAlignmentCurve> {
362    let graph = builder
363        .finish(vec![root])
364        .map_err(|error| AlignmentError::Graph {
365            detail: error.to_string(),
366        })?;
367    Ok(LoweredAlignmentCurve {
368        graph,
369        root,
370        sources,
371    })
372}
373
374/// Lower an `IfcAlignmentHorizontal`'s nested segment chain to one exact
375/// neutral curve, refusing (rather than approximating) any segment that
376/// `lower_horizontal_segment` cannot lower exactly.
377pub fn lower_horizontal_layout(
378    model: &Model,
379    entity: EntityId,
380    units: AlignmentUnits,
381    cant: Option<&CantLayout>,
382) -> AlignmentResult<LoweredAlignmentCurve> {
383    let view = AlignmentView::for_model(model)?;
384    let horizontal_entity = model
385        .get(entity)
386        .ok_or(AlignmentError::MissingEntity { entity })?;
387    if !view
388        .schema
389        .is_a(&horizontal_entity.type_name, "IfcAlignmentHorizontal")
390    {
391        return Err(AlignmentError::WrongType {
392            entity,
393            expected: "IfcAlignmentHorizontal",
394            actual: horizontal_entity.type_name.to_string(),
395        });
396    }
397    let ids = view.segment_chain(entity, "IfcAlignmentHorizontalSegment")?;
398    if ids.is_empty() {
399        return Err(AlignmentError::SemanticViolation {
400            entity: Some(entity),
401            rule: "IfcAlignmentHorizontal must nest at least one IfcAlignmentSegment",
402        });
403    }
404
405    let mut segments = Vec::with_capacity(ids.len());
406    for id in &ids {
407        segments.push(read_horizontal_segment(model, *id, units)?);
408    }
409
410    let mut builder = GeometryGraphBuilder::new();
411    let mut composite_segments = Vec::with_capacity(segments.len());
412    let mut station = 0.0_f64;
413    for (index, segment) in segments.iter().enumerate() {
414        let curve = match &segment.segment_type {
415            HorizontalSegmentType::Line => push_line(&mut builder, segment)?,
416            HorizontalSegmentType::CircularArc => push_arc(&mut builder, segment)?,
417            HorizontalSegmentType::Transition(name)
418                if is_exactly_lowerable(name, cant.is_some()) =>
419            {
420                push_spiral(&mut builder, segment, name, cant, station)?
421            }
422            kind => return Err(AlignmentError::Unsupported {
423                entity: segment.entity,
424                type_name: kind.source_name().to_owned(),
425                detail:
426                    "the pinned neutral curve vocabulary has no exact transition-curve primitive",
427            }),
428        };
429        let transition = if index == 0 {
430            Transition::Discontinuous
431        } else {
432            let previous = &segments[index - 1];
433            // A transition spiral's end point is a Fresnel-type integral, so
434            // continuity across it is not provable in closed form. The strict
435            // entry point promises a fully continuity-checked composite, so it
436            // refuses here rather than asserting a transition it cannot verify.
437            let previous_end =
438                closed_form_end_point(previous).ok_or(AlignmentError::Unsupported {
439                    entity: previous.entity,
440                    type_name: previous.segment_type.source_name().to_owned(),
441                    detail: "continuity across a transition spiral is not provable in closed form",
442                })?;
443            observed_transition(previous_end, segment.start_point, 1e-6).ok_or(
444                AlignmentError::SemanticViolation {
445                    entity: Some(segment.entity),
446                    rule: "consecutive horizontal segments must share an endpoint exactly",
447                },
448            )?
449        };
450        composite_segments.push(CurveSegment {
451            curve,
452            same_sense: true,
453            transition,
454        });
455        station += segment.segment_length;
456    }
457
458    let root = push(
459        &mut builder,
460        GeometryNode::CurveRelation(CurveRelation::Composite {
461            segments: composite_segments,
462        }),
463    )?;
464    finish(builder, root, ids)
465}
466
467/// Lower a horizontal layout as far as exactness allows, reporting refusals.
468///
469/// Unlike [`lower_horizontal_layout`], a transition spiral does not abort the
470/// whole layout. Lines and circular arcs around it still lower exactly; the
471/// spiral is recorded in [`PartialHorizontalLayout::refused`] with its
472/// authored type name and entity id.
473///
474/// Nothing here is approximated. A refused segment stays refused -- this
475/// reports the boundary per segment instead of collapsing an entire
476/// production alignment into one opaque error.
477///
478/// Errors that are not a lowering refusal (a wrong entity type, a malformed
479/// attribute, an empty layout) still fail the whole call, because they mean
480/// the layout could not be read at all rather than that one segment resisted
481/// exact lowering.
482pub fn lower_horizontal_layout_partial(
483    model: &Model,
484    entity: EntityId,
485    units: AlignmentUnits,
486    cant: Option<&CantLayout>,
487) -> AlignmentResult<PartialHorizontalLayout> {
488    let view = AlignmentView::for_model(model)?;
489    let horizontal_entity = model
490        .get(entity)
491        .ok_or(AlignmentError::MissingEntity { entity })?;
492    if !view
493        .schema
494        .is_a(&horizontal_entity.type_name, "IfcAlignmentHorizontal")
495    {
496        return Err(AlignmentError::WrongType {
497            entity,
498            expected: "IfcAlignmentHorizontal",
499            actual: horizontal_entity.type_name.to_string(),
500        });
501    }
502    let ids = view.segment_chain(entity, "IfcAlignmentHorizontalSegment")?;
503    if ids.is_empty() {
504        return Err(AlignmentError::SemanticViolation {
505            entity: Some(entity),
506            rule: "IfcAlignmentHorizontal must nest at least one IfcAlignmentSegment",
507        });
508    }
509
510    let mut segments = Vec::with_capacity(ids.len());
511    for id in &ids {
512        segments.push(read_horizontal_segment(model, *id, units)?);
513    }
514
515    let mut runs = Vec::new();
516    let mut refused = Vec::new();
517    // Segments accumulated since the last refusal, as (index, node) pairs in a
518    // builder that is discarded and restarted whenever a run ends.
519    let mut builder = GeometryGraphBuilder::new();
520    let mut pending: Vec<(usize, CurveSegment)> = Vec::new();
521    let mut pending_ids: Vec<EntityId> = Vec::new();
522    let mut station = 0.0_f64;
523
524    for (index, segment) in segments.iter().enumerate() {
525        // Decide the run boundary BEFORE lowering: `flush_run` swaps in a new
526        // builder, so a node pushed beforehand would dangle in a finished
527        // graph. A predecessor whose end point is not closed form cannot
528        // support any continuity claim, so the run ends here.
529        if let Some((previous_index, _)) = pending.last() {
530            if closed_form_end_point(&segments[*previous_index]).is_none() {
531                flush_run(&mut runs, &mut builder, &mut pending, &mut pending_ids)?;
532            }
533        }
534        let lowered = match &segment.segment_type {
535            HorizontalSegmentType::Line => push_line(&mut builder, segment),
536            HorizontalSegmentType::CircularArc => push_arc(&mut builder, segment),
537            HorizontalSegmentType::Transition(name)
538                if is_exactly_lowerable(name, cant.is_some()) =>
539            {
540                push_spiral(&mut builder, segment, name, cant, station)
541            }
542            kind => Err(AlignmentError::Unsupported {
543                entity: segment.entity,
544                type_name: kind.source_name().to_owned(),
545                detail:
546                    "the pinned neutral curve vocabulary has no exact transition-curve primitive",
547            }),
548        };
549        // Advance before the refusal branch below: that path `continue`s, and
550        // advancing at the loop tail would mis-station every later segment.
551        station += segment.segment_length;
552        let curve = match lowered {
553            Ok(curve) => curve,
554            Err(reason) => {
555                // The run ends here: continuity across a segment this crate
556                // did not lower is not a fact it can assert.
557                flush_run(&mut runs, &mut builder, &mut pending, &mut pending_ids)?;
558                refused.push(RefusedSegment {
559                    entity: segment.entity,
560                    type_name: segment.segment_type.source_name().to_owned(),
561                    reason,
562                });
563                continue;
564            }
565        };
566        // Continuity is only claimed against the immediately preceding
567        // segment when that segment is in the same run.
568        let transition = match pending.last() {
569            None => Transition::Discontinuous,
570            Some((previous_index, _)) => {
571                let previous = &segments[*previous_index];
572                // Within a run the predecessor always has a closed-form end
573                // point: the boundary check above ended the run otherwise.
574                let previous_end = closed_form_end_point(previous)
575                    .expect("run boundary guarantees a closed-form predecessor end point");
576                match observed_transition(previous_end, segment.start_point, 1e-6) {
577                    Some(transition) => transition,
578                    None => {
579                        return Err(AlignmentError::SemanticViolation {
580                            entity: Some(segment.entity),
581                            rule: "consecutive horizontal segments must share an endpoint exactly",
582                        })
583                    }
584                }
585            }
586        };
587        pending.push((
588            index,
589            CurveSegment {
590                curve,
591                same_sense: true,
592                transition,
593            },
594        ));
595        pending_ids.push(segment.entity);
596    }
597    flush_run(&mut runs, &mut builder, &mut pending, &mut pending_ids)?;
598
599    Ok(PartialHorizontalLayout {
600        runs,
601        refused,
602        segment_count: segments.len(),
603    })
604}
605
606/// Close the current run, if any, into its own composite curve.
607///
608/// Takes the builder by mutable reference and replaces it, so each run owns a
609/// self-contained graph whose node ids are meaningful within that graph.
610fn flush_run(
611    runs: &mut Vec<LoweredAlignmentCurve>,
612    builder: &mut GeometryGraphBuilder,
613    pending: &mut Vec<(usize, CurveSegment)>,
614    pending_ids: &mut Vec<EntityId>,
615) -> AlignmentResult<()> {
616    if pending.is_empty() {
617        // Nothing accumulated; drop whatever partial nodes exist so a refused
618        // segment does not leak orphan nodes into the next run.
619        *builder = GeometryGraphBuilder::new();
620        pending_ids.clear();
621        return Ok(());
622    }
623    let mut finished = GeometryGraphBuilder::new();
624    core::mem::swap(builder, &mut finished);
625    let composite_segments: Vec<CurveSegment> =
626        pending.drain(..).map(|(_, segment)| segment).collect();
627    let root = push(
628        &mut finished,
629        GeometryNode::CurveRelation(CurveRelation::Composite {
630            segments: composite_segments,
631        }),
632    )?;
633    let sources = core::mem::take(pending_ids);
634    runs.push(finish(finished, root, sources)?);
635    Ok(())
636}