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