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