use std::sync::Arc;
use axiolid_curve::{Curve2, ElevationLaw};
use axiolid_model::{CurveRelation, GeometryNode, TrimSelector};
use ifc_alignment::{
elevation_law, lower_vertical_segment, profile_law, read_vertical_segment, AlignmentError,
AlignmentUnits, LoweredAlignmentCurve,
};
use ifc_model::{Entity, EntityId, Model, Value};
fn metres() -> AlignmentUnits {
AlignmentUnits {
length_to_metres: 1.0,
angle_to_radians: 1.0,
}
}
fn record(
start: f64,
length: f64,
height: f64,
entry: f64,
exit: f64,
radius: Option<f64>,
kind: &str,
) -> Model {
let mut model = Model::new();
model.insert(
EntityId(1),
Entity::new(
"IFCALIGNMENTVERTICALSEGMENT",
vec![
Value::Null,
Value::Null,
Value::Real(start),
Value::Real(length),
Value::Real(height),
Value::Real(entry),
Value::Real(exit),
radius.map_or(Value::Null, Value::Real),
Value::Enum(Arc::from(kind)),
],
),
);
model
}
fn crest() -> Model {
record(
1100.0,
200.0,
52.0,
0.02,
-0.03,
Some(-4000.0),
"PARABOLICARC",
)
}
fn basis(lowered: &LoweredAlignmentCurve) -> (&Curve2, f64) {
let Some(GeometryNode::CurveRelation(CurveRelation::Trimmed { basis, end, .. })) =
lowered.graph.get(lowered.root)
else {
panic!("expected a trimmed vertical curve");
};
let [TrimSelector::Parameter(end)] = end.as_slice() else {
panic!("expected a parameter trim");
};
let Some(GeometryNode::Curve2(curve)) = lowered.graph.get(*basis) else {
panic!("expected a Curve2 basis");
};
(curve, *end)
}
const TABLE: [(f64, f64, f64); 4] = [
(0.0, 52.0, 0.02),
(50.0, 52.6875, 0.0075),
(100.0, 52.75, -0.005),
(200.0, 51.0, -0.03),
];
#[test]
fn a_parabolic_arc_lowers_per_segment_to_the_ifc_parabola() {
let lowered = lower_vertical_segment(&crest(), EntityId(1), metres()).expect("exact parabola");
let (curve, end) = basis(&lowered);
assert!(matches!(curve, Curve2::BSpline(b) if b.degree == 2 && b.weights.is_none()));
assert_eq!(end, 200.0, "trimmed to the horizontal length");
for (local, height, grade) in TABLE {
let point = axiolid_evaluate::evaluate2(curve, local).expect("point");
let derivative = axiolid_evaluate::derivative2(curve, local).expect("derivative");
assert!(
(point.x - (1100.0 + local)).abs() < 1e-9,
"station at {local}"
);
assert!(
(point.y - height).abs() < 1e-12,
"height at {local}: {} != {height}",
point.y
);
let slope = derivative.y / derivative.x;
assert!(
(slope - grade).abs() < 1e-14,
"grade at {local}: {slope} != {grade}"
);
}
}
#[test]
fn per_segment_and_composed_paths_agree() {
let model = crest();
let law = profile_law(&[read_vertical_segment(&model, EntityId(1), metres()).expect("reads")])
.expect("composed law");
let lowered = lower_vertical_segment(&model, EntityId(1), metres()).expect("per segment");
let (curve, _) = basis(&lowered);
for local in [0.0, 12.5, 77.0, 150.0, 200.0] {
let point = axiolid_evaluate::evaluate2(curve, local).expect("point");
let height = law.height_at(local).expect("height");
assert!(
(point.y - height).abs() < 1e-12,
"paths disagree at {local}: {} vs {height}",
point.y
);
}
}
#[test]
fn a_constant_gradient_still_lowers_to_a_line() {
let model = record(1000.0, 100.0, 50.0, 0.02, 0.02, None, "CONSTANTGRADIENT");
let lowered = lower_vertical_segment(&model, EntityId(1), metres()).expect("line");
let (curve, end) = basis(&lowered);
let Curve2::Line(line) = curve else {
panic!("expected a line, got {curve:?}");
};
assert_eq!(line.origin.x, 1000.0);
assert_eq!(line.origin.y, 50.0);
assert_eq!(line.direction.y, 0.02);
assert_eq!(end, 100.0);
let point = axiolid_evaluate::evaluate2(curve, end).expect("end");
assert!((point.y - 52.0).abs() < 1e-12);
}
#[test]
fn a_circular_arc_lowers_to_the_circle_on_both_paths() {
let model = record(
1100.0,
200.0,
52.0,
0.02,
-0.030_007,
Some(-4000.0),
"CIRCULARARC",
);
let segment = read_vertical_segment(&model, EntityId(1), metres()).expect("reads");
let law = elevation_law(&segment).expect("exact circle");
assert_eq!(law, ElevationLaw::circular_arc(52.0, 0.02, -4000.0));
let norm = 0.02_f64.hypot(1.0);
let centre = (1100.0 + 4000.0 * 0.02 / norm, 52.0 - 4000.0 / norm);
let circle =
|station: f64| centre.1 + (4000.0_f64.powi(2) - (station - centre.0).powi(2)).sqrt();
for local in [0.0, 50.0, 100.0, 150.0, 200.0] {
let height = law.height_at(local).expect("height");
let expected = circle(1100.0 + local);
assert!(
(height - expected).abs() < 1e-9,
"at {local}: {height} != {expected}"
);
}
let lowered = lower_vertical_segment(&model, EntityId(1), metres()).expect("per segment");
let (curve, end) = basis(&lowered);
let Curve2::Circle(arc) = curve else {
panic!("a vertical arc is a circle, got {curve:?}");
};
assert_eq!(arc.radius, 4000.0);
assert!((arc.frame.origin.x - centre.0).abs() < 1e-9);
assert!((arc.frame.origin.y - centre.1).abs() < 1e-9);
let start = axiolid_evaluate::evaluate2(curve, 0.0).expect("start");
assert!((start.x - 1100.0).abs() < 1e-9 && (start.y - 52.0).abs() < 1e-9);
let tangent = axiolid_evaluate::derivative2(curve, 0.0).expect("tangent");
assert!((tangent.y / tangent.x - 0.02).abs() < 1e-12, "start grade");
let finish = axiolid_evaluate::evaluate2(curve, end).expect("end");
assert!(
(finish.x - 1300.0).abs() < 1e-9,
"ends at the plan length: {}",
finish.x
);
assert!((finish.y - circle(1300.0)).abs() < 1e-9);
for fraction in [0.25, 0.5, 0.75] {
let point = axiolid_evaluate::evaluate2(curve, end * fraction).expect("point");
let height = law.height_at(point.x - 1100.0).expect("height");
assert!(
(point.y - height).abs() < 1e-9,
"paths disagree at {}",
point.x
);
}
}
#[test]
fn a_vertical_clothoid_is_refused_on_both_paths() {
let model = record(1100.0, 200.0, 52.0, 0.02, -0.03, None, "CLOTHOID");
let segment = read_vertical_segment(&model, EntityId(1), metres()).expect("reads");
let composed = elevation_law(&segment).expect_err("no stated curvature");
let per_segment =
lower_vertical_segment(&model, EntityId(1), metres()).expect_err("no stated curvature");
assert_eq!(per_segment, composed, "the paths must agree");
let AlignmentError::Unsupported {
entity,
type_name,
detail,
} = per_segment
else {
panic!("expected Unsupported, got {per_segment}");
};
assert_eq!(entity, EntityId(1));
assert_eq!(type_name, "CLOTHOID");
assert!(detail.contains("states neither"), "{detail}");
}
#[test]
fn a_contradictory_circular_arc_is_refused_on_both_paths() {
for (radius, needle) in [(4000.0, "turns against"), (-100.0, "turns vertical")] {
let model = record(0.0, 200.0, 52.0, 0.02, -0.03, Some(radius), "CIRCULARARC");
let segment = read_vertical_segment(&model, EntityId(1), metres()).expect("reads");
let composed = elevation_law(&segment).expect_err("contradiction");
assert_eq!(
lower_vertical_segment(&model, EntityId(1), metres()),
Err(composed.clone())
);
assert!(
matches!(&composed, AlignmentError::InvalidSegment { detail, .. } if detail.contains(needle)),
"R {radius}: {composed}"
);
}
}
#[test]
fn a_contradictory_constant_gradient_is_refused_on_both_paths() {
let model = record(0.0, 100.0, 50.0, 0.02, 0.03, None, "CONSTANTGRADIENT");
let segment = read_vertical_segment(&model, EntityId(1), metres()).expect("reads");
assert_eq!(
lower_vertical_segment(&model, EntityId(1), metres()),
Err(elevation_law(&segment).expect_err("unequal grades"))
);
}
#[test]
fn exporter_spellings_of_a_straight_grade_are_read_as_one() {
let zero_radius = record(
0.0,
100.0,
35.0,
-0.015,
-0.015,
Some(0.0),
"CONSTANTGRADIENT",
);
let lowered = lower_vertical_segment(&zero_radius, EntityId(1), metres()).expect("zero radius");
let (curve, _) = basis(&lowered);
let end = axiolid_evaluate::evaluate2(curve, 100.0).expect("end");
assert!((end.y - 33.5).abs() < 1e-12, "end height {}", end.y);
let last_bit = record(
47.123_889_803_846_9,
47.123_889_803_846_9,
-2.0,
0.042_441_318_157_838_8,
0.042_441_318_157_838_7,
None,
"CONSTANTGRADIENT",
);
lower_vertical_segment(&last_bit, EntityId(1), metres()).expect("one grade up to rounding");
for (radius, exit) in [(Some(1000.0), -0.015), (None, -0.015 + 1e-6)] {
let model = record(0.0, 100.0, 35.0, -0.015, exit, radius, "CONSTANTGRADIENT");
assert!(
lower_vertical_segment(&model, EntityId(1), metres()).is_err(),
"radius {radius:?}, exit {exit}"
);
}
}