Skip to main content

ifc_alignment/curve/
assemble.rs

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