use axiolid_curve::Curve2;
use axiolid_model::{CurveRelation, GeometryNode};
use ifc_model::{EntityId, Model, Value};
use crate::lower::curve::lower_curve_node;
use crate::lower::session::LoweringSession;
use crate::solid::testkit::{entity, n, r};
use crate::transform::Transform;
use crate::units::UnitScale;
use axiolid_model::TrimSelector;
fn millimetres() -> UnitScale {
UnitScale {
length_to_metres: 0.001,
angle_to_radians: 1.0,
}
}
fn point(x: f64, y: f64, z: f64) -> ifc_model::Entity {
entity(
"IFCCARTESIANPOINT",
vec![Value::List(vec![n(x), n(y), n(z)])],
)
}
#[test]
fn p_curve_accepts_an_implicit_indexed_polycurve_reference() {
let mut model = Model::new();
model.insert(EntityId(1), point(0.0, 0.0, 0.0));
model.insert(
EntityId(2),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), entity("IFCPLANE", vec![r(2)]));
model.insert(
EntityId(4),
entity(
"IFCCARTESIANPOINTLIST2D",
vec![Value::List(vec![
Value::List(vec![n(0.0), n(0.0)]),
Value::List(vec![n(1.5), n(2.0)]),
])],
),
);
model.insert(
EntityId(5),
entity(
"IFCINDEXEDPOLYCURVE",
vec![r(4), Value::Null, Value::Bool(false)],
),
);
model.insert(EntityId(6), entity("IFCPCURVE", vec![r(3), r(5)]));
let scale = millimetres();
let mut session = LoweringSession::new(&model, &scale);
let root = lower_curve_node(&mut session, EntityId(6), Transform::identity()).expect("lowers");
let lowered = session.finish(root).expect("finishes");
let GeometryNode::CurveRelation(CurveRelation::ParameterCurve {
basis_surface: _,
reference_curve,
}) = lowered.graph.get(root).expect("root")
else {
panic!("expected parameter curve relation");
};
let Some(GeometryNode::Curve2(Curve2::Polyline(polyline))) =
lowered.graph.get(*reference_curve)
else {
panic!("expected parameter-space polyline");
};
assert_eq!(
polyline.points[1].to_array(),
[1.5, 2.0],
"surface parameters must not use project length units"
);
}
#[test]
fn p_curve_lowers_an_indexed_polycurve_arc_as_a_trimmed_circle() {
let mut model = Model::new();
model.insert(EntityId(1), point(0.0, 0.0, 0.0));
model.insert(
EntityId(2),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), entity("IFCPLANE", vec![r(2)]));
model.insert(
EntityId(4),
entity(
"IFCCARTESIANPOINTLIST2D",
vec![Value::List(vec![
Value::List(vec![n(0.0), n(0.0)]),
Value::List(vec![n(1.0), n(1.0)]),
Value::List(vec![n(2.0), n(0.0)]),
])],
),
);
let index = |name: &str, values: &[i64]| Value::Typed {
type_name: name.into(),
value: Box::new(Value::List(
values.iter().copied().map(Value::Integer).collect(),
)),
};
model.insert(
EntityId(5),
entity(
"IFCINDEXEDPOLYCURVE",
vec![
r(4),
Value::List(vec![index("IFCARCINDEX", &[1, 2, 3])]),
Value::Bool(false),
],
),
);
model.insert(EntityId(6), entity("IFCPCURVE", vec![r(3), r(5)]));
let scale = millimetres();
let mut session = LoweringSession::new(&model, &scale);
let root = lower_curve_node(&mut session, EntityId(6), Transform::identity()).expect("lowers");
let lowered = session.finish(root).expect("finishes");
let GeometryNode::CurveRelation(CurveRelation::ParameterCurve {
reference_curve, ..
}) = lowered.graph.get(root).expect("root")
else {
panic!("expected a parameter curve relation");
};
let Some(GeometryNode::CurveRelation(CurveRelation::Composite { segments })) =
lowered.graph.get(*reference_curve)
else {
panic!("expected a composite reference curve");
};
assert_eq!(segments.len(), 1);
let Some(GeometryNode::CurveRelation(CurveRelation::Trimmed {
basis,
sense_agreement,
..
})) = lowered.graph.get(segments[0].curve)
else {
panic!("expected a trimmed arc");
};
assert!(
!sense_agreement,
"a clockwise three-point arc must not claim the circle's sense"
);
let Some(GeometryNode::Curve2(Curve2::Circle(circle))) = lowered.graph.get(*basis) else {
panic!("expected a parameter-space circle");
};
assert_eq!(circle.frame.origin.to_array(), [1.0, 0.0]);
assert_eq!(circle.radius, 1.0, "parameter-space radius is not a length");
}
#[test]
fn p_curve_circle_keeps_its_radius_in_parameter_space() {
let mut model = Model::new();
model.insert(EntityId(1), point(0.0, 0.0, 0.0));
model.insert(
EntityId(2),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), entity("IFCPLANE", vec![r(2)]));
model.insert(
EntityId(4),
entity("IFCCARTESIANPOINT", vec![Value::List(vec![n(2.0), n(3.0)])]),
);
model.insert(
EntityId(5),
entity("IFCAXIS2PLACEMENT2D", vec![r(4), Value::Null]),
);
model.insert(EntityId(6), entity("IFCCIRCLE", vec![r(5), n(1.5)]));
model.insert(EntityId(7), entity("IFCPCURVE", vec![r(3), r(6)]));
let scale = millimetres();
let mut session = LoweringSession::new(&model, &scale);
let root = lower_curve_node(&mut session, EntityId(7), Transform::identity()).expect("lowers");
let lowered = session.finish(root).expect("finishes");
let GeometryNode::CurveRelation(CurveRelation::ParameterCurve {
basis_surface: _,
reference_curve,
}) = lowered.graph.get(root).expect("root")
else {
panic!("expected parameter curve relation");
};
let Some(GeometryNode::Curve2(Curve2::Circle(circle))) = lowered.graph.get(*reference_curve)
else {
panic!("expected parameter-space circle");
};
assert_eq!(
circle.radius, 1.5,
"surface parameters must not use project length units"
);
assert_eq!(circle.frame.origin.to_array(), [2.0, 3.0]);
assert_eq!(circle.frame.x.to_array(), [1.0, 0.0]);
assert_eq!(
circle.frame.y.to_array(),
[0.0, 1.0],
"local Y is the orthogonal complement of X"
);
}
#[test]
fn p_curve_conic_frame_follows_the_authored_ref_direction() {
let mut model = Model::new();
model.insert(EntityId(1), point(0.0, 0.0, 0.0));
model.insert(
EntityId(2),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), entity("IFCPLANE", vec![r(2)]));
model.insert(
EntityId(4),
entity("IFCCARTESIANPOINT", vec![Value::List(vec![n(0.0), n(0.0)])]),
);
model.insert(
EntityId(5),
entity("IFCDIRECTION", vec![Value::List(vec![n(0.0), n(1.0)])]),
);
model.insert(EntityId(6), entity("IFCAXIS2PLACEMENT2D", vec![r(4), r(5)]));
model.insert(EntityId(7), entity("IFCCIRCLE", vec![r(6), n(2.0)]));
model.insert(EntityId(8), entity("IFCPCURVE", vec![r(3), r(7)]));
let scale = millimetres();
let mut session = LoweringSession::new(&model, &scale);
let root = lower_curve_node(&mut session, EntityId(8), Transform::identity()).expect("lowers");
let lowered = session.finish(root).expect("finishes");
let GeometryNode::CurveRelation(CurveRelation::ParameterCurve {
basis_surface: _,
reference_curve,
}) = lowered.graph.get(root).expect("root")
else {
panic!("expected parameter curve relation");
};
let Some(GeometryNode::Curve2(Curve2::Circle(circle))) = lowered.graph.get(*reference_curve)
else {
panic!("expected parameter-space circle");
};
assert_eq!(circle.frame.x.to_array(), [0.0, 1.0]);
assert_eq!(
circle.frame.y.to_array(),
[-1.0, 0.0],
"Y must be X rotated counter-clockwise, not clockwise"
);
}
#[test]
fn p_curve_ellipse_keeps_both_semi_axes_in_parameter_space() {
let mut model = Model::new();
model.insert(EntityId(1), point(0.0, 0.0, 0.0));
model.insert(
EntityId(2),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), entity("IFCPLANE", vec![r(2)]));
model.insert(
EntityId(4),
entity("IFCCARTESIANPOINT", vec![Value::List(vec![n(0.0), n(0.0)])]),
);
model.insert(
EntityId(5),
entity("IFCAXIS2PLACEMENT2D", vec![r(4), Value::Null]),
);
model.insert(
EntityId(6),
entity("IFCELLIPSE", vec![r(5), n(3.0), n(0.5)]),
);
model.insert(EntityId(7), entity("IFCPCURVE", vec![r(3), r(6)]));
let scale = millimetres();
let mut session = LoweringSession::new(&model, &scale);
let root = lower_curve_node(&mut session, EntityId(7), Transform::identity()).expect("lowers");
let lowered = session.finish(root).expect("finishes");
let GeometryNode::CurveRelation(CurveRelation::ParameterCurve {
basis_surface: _,
reference_curve,
}) = lowered.graph.get(root).expect("root")
else {
panic!("expected parameter curve relation");
};
let Some(GeometryNode::Curve2(Curve2::Ellipse(ellipse))) = lowered.graph.get(*reference_curve)
else {
panic!("expected parameter-space ellipse");
};
assert_eq!(
(ellipse.semi_axis_x, ellipse.semi_axis_y),
(3.0, 0.5),
"surface parameters must not use project length units"
);
}
#[test]
fn p_curve_line_keeps_its_direction_magnitude_unscaled() {
let mut model = Model::new();
model.insert(EntityId(1), point(0.0, 0.0, 0.0));
model.insert(
EntityId(2),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), entity("IFCPLANE", vec![r(2)]));
model.insert(
EntityId(4),
entity("IFCCARTESIANPOINT", vec![Value::List(vec![n(1.0), n(2.0)])]),
);
model.insert(
EntityId(5),
entity("IFCDIRECTION", vec![Value::List(vec![n(1.0), n(0.0)])]),
);
model.insert(EntityId(6), entity("IFCVECTOR", vec![r(5), n(4.0)]));
model.insert(EntityId(7), entity("IFCLINE", vec![r(4), r(6)]));
model.insert(EntityId(8), entity("IFCPCURVE", vec![r(3), r(7)]));
let scale = millimetres();
let mut session = LoweringSession::new(&model, &scale);
let root = lower_curve_node(&mut session, EntityId(8), Transform::identity()).expect("lowers");
let lowered = session.finish(root).expect("finishes");
let GeometryNode::CurveRelation(CurveRelation::ParameterCurve {
basis_surface: _,
reference_curve,
}) = lowered.graph.get(root).expect("root")
else {
panic!("expected parameter curve relation");
};
let Some(GeometryNode::Curve2(Curve2::Line(line))) = lowered.graph.get(*reference_curve) else {
panic!("expected parameter-space line");
};
assert_eq!(line.origin.to_array(), [1.0, 2.0]);
assert_eq!(
line.direction.to_array(),
[4.0, 0.0],
"Dir magnitude sets the parameter scale and must be preserved unscaled"
);
}
#[test]
fn p_curve_conic_refuses_a_three_dimensional_placement() {
let mut model = Model::new();
model.insert(EntityId(1), point(0.0, 0.0, 0.0));
model.insert(
EntityId(2),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), entity("IFCPLANE", vec![r(2)]));
model.insert(EntityId(4), entity("IFCCIRCLE", vec![r(2), n(1.0)]));
model.insert(EntityId(5), entity("IFCPCURVE", vec![r(3), r(4)]));
let scale = millimetres();
let mut session = LoweringSession::new(&model, &scale);
let error = lower_curve_node(&mut session, EntityId(5), Transform::identity())
.expect_err("a 3D placement has no meaning in a parameter domain");
assert!(error.is_unsupported());
assert_eq!(error.entity(), Some(EntityId(2)));
}
#[test]
fn p_curve_bspline_keeps_its_knots_and_control_points_in_parameter_space() {
let mut model = Model::new();
model.insert(EntityId(1), point(0.0, 0.0, 0.0));
model.insert(
EntityId(2),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), entity("IFCPLANE", vec![r(2)]));
model.insert(
EntityId(6),
entity("IFCCARTESIANPOINT", vec![Value::List(vec![n(1.5), n(2.5)])]),
);
model.insert(
EntityId(7),
entity("IFCCARTESIANPOINT", vec![Value::List(vec![n(3.5), n(4.5)])]),
);
let ints = |a: i64, b: i64| Value::List(vec![Value::Integer(a), Value::Integer(b)]);
model.insert(
EntityId(4),
entity(
"IFCBSPLINECURVEWITHKNOTS",
vec![
Value::Integer(1),
Value::List(vec![r(6), r(7)]),
Value::Enum("UNSPECIFIED".into()),
Value::Bool(false),
Value::Bool(false),
ints(2, 2),
Value::List(vec![n(0.0), n(1.0)]),
Value::Enum("UNSPECIFIED".into()),
],
),
);
model.insert(EntityId(5), entity("IFCPCURVE", vec![r(3), r(4)]));
let scale = millimetres();
let mut session = LoweringSession::new(&model, &scale);
let root = lower_curve_node(&mut session, EntityId(5), Transform::identity()).expect("lowers");
let lowered = session.finish(root).expect("finishes");
let GeometryNode::CurveRelation(CurveRelation::ParameterCurve {
basis_surface: _,
reference_curve,
}) = lowered.graph.get(root).expect("root")
else {
panic!("expected parameter curve relation");
};
let Some(GeometryNode::Curve2(Curve2::BSpline(spline))) = lowered.graph.get(*reference_curve)
else {
panic!("expected parameter-space B-spline");
};
assert_eq!(spline.degree, 1);
assert_eq!(
spline.knots,
vec![0.0, 1.0],
"knots are curve parameters, never lengths"
);
assert_eq!(spline.multiplicities, vec![2, 2]);
assert_eq!(
spline.control_points[0].to_array(),
[1.5, 2.5],
"control points are (u, v), not model lengths"
);
assert_eq!(spline.control_points[1].to_array(), [3.5, 4.5]);
}
#[test]
fn p_curve_trimmed_keeps_its_parameters_unscaled() {
let mut model = Model::new();
model.insert(EntityId(1), point(0.0, 0.0, 0.0));
model.insert(
EntityId(2),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), entity("IFCPLANE", vec![r(2)]));
model.insert(
EntityId(4),
entity("IFCCARTESIANPOINT", vec![Value::List(vec![n(0.0), n(0.0)])]),
);
model.insert(
EntityId(5),
entity("IFCAXIS2PLACEMENT2D", vec![r(4), Value::Null]),
);
model.insert(EntityId(6), entity("IFCCIRCLE", vec![r(5), n(2.0)]));
let param = |v: f64| Value::Typed {
type_name: "IFCPARAMETERVALUE".into(),
value: Box::new(Value::Real(v)),
};
model.insert(
EntityId(7),
entity(
"IFCTRIMMEDCURVE",
vec![
r(6),
Value::List(vec![param(0.25)]),
Value::List(vec![param(0.75)]),
Value::Bool(true),
Value::Enum("PARAMETER".into()),
],
),
);
model.insert(EntityId(8), entity("IFCPCURVE", vec![r(3), r(7)]));
let scale = millimetres();
let mut session = LoweringSession::new(&model, &scale);
let root = lower_curve_node(&mut session, EntityId(8), Transform::identity()).expect("lowers");
let lowered = session.finish(root).expect("finishes");
let GeometryNode::CurveRelation(CurveRelation::ParameterCurve {
basis_surface: _,
reference_curve,
}) = lowered.graph.get(root).expect("root")
else {
panic!("expected parameter curve relation");
};
let Some(GeometryNode::CurveRelation(CurveRelation::Trimmed {
start,
end,
sense_agreement,
..
})) = lowered.graph.get(*reference_curve)
else {
panic!("expected a parameter-space trimmed curve");
};
assert_eq!(start, &vec![TrimSelector::Parameter(0.25)]);
assert_eq!(
end,
&vec![TrimSelector::Parameter(0.75)],
"surface parameters must not use project length units"
);
assert!(*sense_agreement);
}
#[test]
fn p_curve_composite_lowers_segments_in_parameter_space() {
let mut model = Model::new();
model.insert(EntityId(1), point(0.0, 0.0, 0.0));
model.insert(
EntityId(2),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), Value::Null, Value::Null]),
);
model.insert(EntityId(3), entity("IFCPLANE", vec![r(2)]));
let pt = |i: u64, x: f64, y: f64| {
(
EntityId(i),
entity("IFCCARTESIANPOINT", vec![Value::List(vec![n(x), n(y)])]),
)
};
for (id, e) in [pt(10, 0.0, 0.0), pt(11, 1.0, 0.0), pt(12, 1.0, 1.0)] {
model.insert(id, e);
}
model.insert(
EntityId(20),
entity("IFCPOLYLINE", vec![Value::List(vec![r(10), r(11)])]),
);
model.insert(
EntityId(21),
entity("IFCPOLYLINE", vec![Value::List(vec![r(11), r(12)])]),
);
let seg = |parent: u64| {
entity(
"IFCCOMPOSITECURVESEGMENT",
vec![
Value::Enum("CONTINUOUS".into()),
Value::Bool(true),
r(parent),
],
)
};
model.insert(EntityId(30), seg(20));
model.insert(EntityId(31), seg(21));
model.insert(
EntityId(40),
entity("IFCCOMPOSITECURVE", vec![Value::List(vec![r(30), r(31)])]),
);
model.insert(EntityId(41), entity("IFCPCURVE", vec![r(3), r(40)]));
let scale = millimetres();
let mut session = LoweringSession::new(&model, &scale);
let root = lower_curve_node(&mut session, EntityId(41), Transform::identity()).expect("lowers");
let lowered = session.finish(root).expect("finishes");
let GeometryNode::CurveRelation(CurveRelation::ParameterCurve {
basis_surface: _,
reference_curve,
}) = lowered.graph.get(root).expect("root")
else {
panic!("expected parameter curve relation");
};
let Some(GeometryNode::CurveRelation(CurveRelation::Composite { segments })) =
lowered.graph.get(*reference_curve)
else {
panic!("expected a parameter-space composite curve");
};
assert_eq!(segments.len(), 2);
let first = lowered.graph.get(segments[0].curve);
let Some(GeometryNode::Curve2(Curve2::Polyline(polyline))) = first else {
panic!("segments must stay in the parameter domain");
};
assert_eq!(
polyline.points[1].to_array(),
[1.0, 0.0],
"surface parameters must not use project length units"
);
assert!(segments[0].same_sense);
}