use axiolid_core::Vec3;
use axiolid_curve::{BankConvention, Curve3};
use axiolid_model::GeometryNode;
use ifc_alignment::{
alignment, alignment_segment, cant_layout, cant_segment, gradient_curve3, horizontal_layout,
horizontal_segment, lower_segmented_reference_curve, segmented_reference_curve3,
vertical_layout, vertical_segment, AlignmentError, AlignmentUnits, CantLayout,
CantSegmentDraft, HorizontalSegmentDraft, VerticalSegmentDraft,
};
use ifc_model::{Entity, EntityId, Model, Transaction, Value};
fn metres() -> AlignmentUnits {
AlignmentUnits {
length_to_metres: 1.0,
angle_to_radians: 1.0,
}
}
fn guid(n: u32) -> String {
format!("{n:0>22}")
}
fn nest(tx: &mut Transaction, parent: EntityId, children: Vec<EntityId>) {
tx.create(Entity::new(
"IFCRELNESTS",
vec![
Value::Text("nest".into()),
Value::Null,
Value::Null,
Value::Null,
Value::Ref(parent),
Value::List(children.into_iter().map(Value::Ref).collect()),
],
));
}
fn fixture(cant_layouts: usize) -> (Model, EntityId) {
let mut model = Model::default();
model.header_mut().schema = vec!["IFC4X3_ADD2".to_owned()];
let mut tx = Transaction::new(&model);
let mut n = 0;
let mut next_guid = || {
n += 1;
guid(n)
};
let start = tx.create(Entity::new(
"IFCCARTESIANPOINT",
vec![Value::List(vec![Value::Real(0.0), Value::Real(0.0)])],
));
let h_params = horizontal_segment(
&mut tx,
&HorizontalSegmentDraft::new(start, 0.0, 0.0, 0.0, 100.0, "LINE"),
)
.expect("line");
let h_seg = alignment_segment(&mut tx, &next_guid(), h_params).expect("h segment");
let h = horizontal_layout(&mut tx, &next_guid(), Some("H")).expect("h");
nest(&mut tx, h, vec![h_seg]);
let v_params = vertical_segment(
&mut tx,
&VerticalSegmentDraft::new(0.0, 100.0, 10.0, 0.02, 0.02, "CONSTANTGRADIENT"),
)
.expect("grade");
let v_seg = alignment_segment(&mut tx, &next_guid(), v_params).expect("v segment");
let v = vertical_layout(&mut tx, &next_guid(), Some("V")).expect("v");
nest(&mut tx, v, vec![v_seg]);
let mut layouts = vec![h, v];
for _ in 0..cant_layouts {
let ramp = cant_segment(
&mut tx,
&CantSegmentDraft::new(0.0, 60.0, 0.0, 0.0, "LINEARTRANSITION")
.end_cant_left(0.12)
.end_cant_right(0.0),
)
.expect("ramp");
let hold = cant_segment(
&mut tx,
&CantSegmentDraft::new(60.0, 40.0, 0.12, 0.0, "CONSTANTCANT"),
)
.expect("hold");
let ramp_seg = alignment_segment(&mut tx, &next_guid(), ramp).expect("ramp segment");
let hold_seg = alignment_segment(&mut tx, &next_guid(), hold).expect("hold segment");
let c = cant_layout(&mut tx, &next_guid(), Some("C"), 1.5).expect("cant");
nest(&mut tx, c, vec![ramp_seg, hold_seg]);
layouts.push(c);
}
let a = alignment(&mut tx, &next_guid(), Some("A"), None).expect("alignment");
nest(&mut tx, a, layouts);
tx.commit(&mut model).expect("commit");
(model, a)
}
fn close(actual: f64, expected: f64, what: &str) {
assert!(
(actual - expected).abs() < 1e-12,
"{what}: {actual} != {expected}"
);
}
#[test]
fn cant_values_at_chosen_stations_match_the_ifc_definitions() {
let (model, a) = fixture(1);
let cant = CantLayout::for_alignment(&model, a, metres()).expect("one cant layout");
for (station, left) in [(30.0, 0.06), (60.0, 0.12), (80.0, 0.12)] {
let frame = cant.frame_at_distance(station).expect("frame");
close(frame.distance_along, station, "station");
close(frame.left, left, "left rail");
close(frame.right, 0.0, "right rail");
close(frame.cant, left, "cant D = left - right");
close(frame.axis_elevation, left / 2.0, "axis elevation");
close(frame.bank_angle, (left / 1.5_f64).asin(), "bank angle");
close(frame.lateral.y, left / 1.5, "sin psi");
close(
frame.lateral.x,
(1.0 - (left / 1.5).powi(2)).sqrt(),
"cos psi",
);
close(frame.up.x, -left / 1.5, "-sin psi");
close(frame.up.y, frame.lateral.x, "cos psi");
}
}
#[test]
fn the_section_frame_on_the_evaluated_centreline_matches_the_hand_values() {
let (model, a) = fixture(1);
let cant = CantLayout::for_alignment(&model, a, metres()).expect("cant");
let curve = gradient_curve3(&model, a, metres()).expect("gradient curve");
let axiolid_curve::Curve3::Elevated(elevated) = &curve else {
panic!("expected an elevated curve, got {curve:?}");
};
let point = axiolid_evaluate::elevated_point(elevated, 80.0).expect("point");
let tangent = axiolid_evaluate::elevated_tangent(elevated, 80.0).expect("tangent");
let frame = cant
.frame_at_distance(80.0)
.expect("frame")
.orient(point, tangent)
.expect("oriented");
let s = 1.0004_f64.sqrt();
let cos = 0.9936_f64.sqrt();
let near = |actual: Vec3, expected: Vec3, what: &str| {
assert!(
(actual - expected).length() < 1e-12,
"{what}: {actual:?} != {expected:?}"
);
};
near(frame.origin, Vec3::new(80.0, 0.0, 11.66), "origin");
near(frame.x, Vec3::new(1.0 / s, 0.0, 0.02 / s), "tangent");
near(frame.y, Vec3::new(-0.0016 / s, cos, 0.08 / s), "rail axis");
near(
frame.z,
Vec3::new(-0.02 * cos / s, -0.08, cos / s),
"section up",
);
close(
(frame.origin + frame.y * 0.75).z - frame.origin.z,
0.06 / s,
"rise",
);
}
#[test]
fn the_low_rail_stays_on_the_profile_in_a_level_section() {
let (model, a) = fixture(1);
let frame = CantLayout::for_alignment(&model, a, metres())
.expect("cant")
.frame_at_distance(30.0)
.expect("frame");
let oriented = frame
.orient(Vec3::new(30.0, 0.0, 10.6), Vec3::X)
.expect("level frame");
let right_rail = oriented.origin - oriented.y * 0.75;
let left_rail = oriented.origin + oriented.y * 0.75;
close(right_rail.z, 10.6, "right rail on the profile");
close(left_rail.z, 10.66, "left rail raised by D = 0.06");
}
#[test]
fn a_missing_cant_layout_is_a_typed_refusal() {
let (model, a) = fixture(0);
let expected = AlignmentError::SemanticViolation {
entity: Some(a),
rule: "the alignment nests no IfcAlignmentCant layout",
};
assert_eq!(
CantLayout::for_alignment(&model, a, metres()),
Err(expected.clone())
);
assert_eq!(
lower_segmented_reference_curve(&model, a, metres()),
Err(expected)
);
}
#[test]
fn an_ambiguous_cant_layout_is_a_typed_refusal() {
let (model, a) = fixture(2);
let expected = AlignmentError::SemanticViolation {
entity: Some(a),
rule: "an alignment with several cant layouts is ambiguous to compose",
};
assert_eq!(
CantLayout::for_alignment(&model, a, metres()),
Err(expected.clone())
);
assert_eq!(
lower_segmented_reference_curve(&model, a, metres()),
Err(expected)
);
}
#[test]
fn the_cant_carrying_centreline_lowers_to_a_banked_curve() {
let (model, a) = fixture(1);
let cant = CantLayout::for_alignment(&model, a, metres()).expect("cant");
let lowered = lower_segmented_reference_curve(&model, a, metres()).expect("banked");
let Some(GeometryNode::Curve3(Curve3::Banked(banked))) = lowered.graph.get(lowered.root) else {
panic!("the root must be a banked curve");
};
assert_eq!(banked.convention, BankConvention::TangentRotation);
assert_eq!(banked.rail_head_distance, 1.5);
assert_eq!(banked.span(), 100.0);
assert!(
lowered.sources.contains(&cant.entity),
"names the cant layout"
);
for segment in cant.segments() {
assert!(lowered.sources.contains(&segment.entity));
}
for (station, left) in [(30.0, 0.06), (60.0, 0.12), (80.0, 0.12)] {
close(banked.cant_at(station).expect("cant"), left, "cant law");
close(
banked.pivot_at(station).expect("pivot").0,
left / 2.0,
"pivot law",
);
}
}
#[test]
fn the_banked_section_agrees_with_the_cant_frame() {
let (model, a) = fixture(1);
let curve = segmented_reference_curve3(&model, a, metres()).expect("banked");
let Curve3::Banked(banked) = &curve else {
panic!("expected a banked curve, got {curve:?}");
};
let elevated = gradient_curve3(&model, a, metres()).expect("gradient curve");
let Curve3::Elevated(elevated) = &elevated else {
panic!("expected an elevated curve");
};
let near = |actual: Vec3, expected: Vec3, what: &str| {
assert!(
(actual - expected).length() < 1e-12,
"{what}: {actual:?} != {expected:?}"
);
};
let section = axiolid_evaluate::banked_section(banked, 80.0).expect("section");
let point = axiolid_evaluate::elevated_point(elevated, 80.0).expect("point");
let tangent = axiolid_evaluate::elevated_tangent(elevated, 80.0).expect("tangent");
let frame = CantLayout::for_alignment(&model, a, metres())
.expect("cant")
.frame_at_distance(80.0)
.expect("frame")
.orient(point, tangent)
.expect("oriented");
near(section.point, frame.origin, "rotation point");
near(section.tangent, frame.x, "tangent");
near(section.lateral, frame.y, "rail axis");
near(section.up, frame.z, "section up");
close(section.roll, (0.12_f64 / 1.5).asin(), "roll = psi");
let axes = section.frame();
near(axes.x, section.tangent, "frame x: tangent (kernel#242)");
near(axes.y, section.up, "frame y: section up (kernel#242)");
near(axes.z, -section.lateral, "frame z: right (kernel#242)");
let section = axiolid_evaluate::banked_section(banked, 30.0).expect("section");
let profile = axiolid_evaluate::elevated_point(elevated, 30.0).expect("point");
near(
section.point,
profile + Vec3::Z * 0.03,
"pivot above the profile",
);
close(section.cant, 0.06, "cant mid-ramp");
}
#[test]
fn cant_exceeding_the_rail_head_distance_is_refused() {
let mut model = Model::default();
model.header_mut().schema = vec!["IFC4X3_ADD2".to_owned()];
let mut tx = Transaction::new(&model);
let params = cant_segment(
&mut tx,
&CantSegmentDraft::new(0.0, 10.0, 1.6, 0.0, "CONSTANTCANT"),
)
.expect("cant segment");
let seg = alignment_segment(&mut tx, &guid(1), params).expect("segment");
let c = cant_layout(&mut tx, &guid(2), Some("C"), 1.5).expect("layout");
nest(&mut tx, c, vec![seg]);
tx.commit(&mut model).expect("commit");
let layout = CantLayout::resolve(&model, c, metres()).expect("layout");
assert!(matches!(
layout.frame_at_distance(5.0),
Err(AlignmentError::SemanticViolation { entity: Some(e), .. }) if e == c
));
}