use axiolid_curve::Curve3;
use axiolid_model::{GeometryNode, SolidOperation};
use ifc_model::{Entity, EntityId, Model, Value};
use super::lower_half_space_node;
use crate::lower::session::LoweringSession;
use crate::transform::Transform;
use crate::units::UnitScale;
fn entity(type_name: &str, attributes: Vec<Value>) -> Entity {
Entity::new(type_name, attributes)
}
fn r(id: u64) -> Value {
Value::Ref(EntityId(id))
}
fn n(v: f64) -> Value {
Value::Real(v)
}
fn point2(x: f64, y: f64) -> Entity {
entity("IFCCARTESIANPOINT", vec![Value::List(vec![n(x), n(y)])])
}
fn point3(x: f64, y: f64, z: f64) -> Entity {
entity(
"IFCCARTESIANPOINT",
vec![Value::List(vec![n(x), n(y), n(z)])],
)
}
fn half_space(agreement: bool, z_offset: f64) -> Model {
let mut model = Model::new();
model.insert(
EntityId(1),
entity(
"IFCCARTESIANPOINT",
vec![Value::List(vec![n(0.0), n(0.0), n(z_offset)])],
),
);
model.insert(
EntityId(2),
entity(
"IFCDIRECTION",
vec![Value::List(vec![n(0.0), n(0.0), n(1.0)])],
),
);
model.insert(
EntityId(3),
entity("IFCAXIS2PLACEMENT3D", vec![r(1), r(2), Value::Null]),
);
model.insert(EntityId(4), entity("IFCPLANE", vec![r(3)]));
model.insert(
EntityId(5),
entity("IFCHALFSPACESOLID", vec![r(4), Value::Bool(agreement)]),
);
model
}
fn lower(model: &Model, frame: Transform) -> axiolid_primitive::HalfSpace {
let scale = UnitScale::default();
let mut session = LoweringSession::new(model, &scale);
let node =
lower_half_space_node(&mut session, EntityId(5), frame).expect("the half space must lower");
let lowered = session.finish(node).expect("session finishes");
match lowered.graph.get(lowered.root).expect("root node") {
GeometryNode::HalfSpace(hs) => *hs,
other => panic!("expected a HalfSpace node, got {other:?}"),
}
}
#[test]
fn the_agreement_flag_is_inverted_for_the_kernel() {
let solid = lower(&half_space(true, 0.0), Transform::identity());
assert!(
!solid.agreement,
"IFC .T. (away from normal) must become kernel false (opposite side)"
);
let solid = lower(&half_space(false, 0.0), Transform::identity());
assert!(
solid.agreement,
"IFC .F. must become kernel true (normal side)"
);
}
#[test]
fn the_base_placement_becomes_the_boundary_plane() {
let solid = lower(&half_space(true, 2.5), Transform::identity());
assert_eq!(solid.boundary.origin.to_array(), [0.0, 0.0, 2.5]);
assert_eq!(solid.boundary.normal.to_array(), [0.0, 0.0, 1.0]);
}
#[test]
fn the_frame_moves_the_origin_but_only_rotates_the_normal() {
let frame = Transform::translation([10.0, 4.0, 0.0]);
let solid = lower(&half_space(true, 2.5), frame);
assert_eq!(
solid.boundary.origin.to_array(),
[10.0, 4.0, 2.5],
"the plane origin is translated"
);
assert_eq!(
solid.boundary.normal.to_array(),
[0.0, 0.0, 1.0],
"a pure translation must leave the normal untouched"
);
}
#[test]
fn the_boundary_normal_is_normalized_under_a_scaling_frame() {
let model = half_space(true, 0.0);
let scaled = Transform::identity().scaled(4.0);
let solid = lower(&model, scaled);
let normal = solid.boundary.normal.to_array();
let length = (normal[0].powi(2) + normal[1].powi(2) + normal[2].powi(2)).sqrt();
assert!(
(length - 1.0).abs() < 1e-12,
"normal must be unit length after a 4x frame, got {length}"
);
assert_eq!(
normal,
[0.0, 0.0, 1.0],
"uniform scale must not change the direction"
);
}
#[test]
fn non_uniform_frames_transform_plane_normals_as_covectors() {
let mut model = half_space(true, 0.0);
model.insert(
EntityId(2),
entity(
"IFCDIRECTION",
vec![Value::List(vec![n(1.0), n(1.0), n(0.0)])],
),
);
let frame = Transform {
basis: [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
origin: [0.0; 3],
};
let normal = lower(&model, frame).boundary.normal.to_array();
let root_five = 5.0_f64.sqrt();
assert!((normal[0] - 1.0 / root_five).abs() < 1e-12);
assert!((normal[1] - 2.0 / root_five).abs() < 1e-12);
assert!(normal[2].abs() < 1e-12);
}
#[test]
fn the_plane_origin_is_converted_to_metres() {
let model = half_space(true, 2500.0);
let scale = UnitScale {
length_to_metres: 0.001,
angle_to_radians: 1.0,
};
let mut session = LoweringSession::new(&model, &scale);
let node =
lower_half_space_node(&mut session, EntityId(5), Transform::identity()).expect("lowers");
let lowered = session.finish(node).expect("finishes");
let solid = match lowered.graph.get(lowered.root).expect("root") {
GeometryNode::HalfSpace(hs) => *hs,
other => panic!("expected HalfSpace, got {other:?}"),
};
assert_eq!(
solid.boundary.origin.to_array(),
[0.0, 0.0, 2.5],
"2500 mm must become 2.5 m"
);
}
#[test]
fn a_polygonal_bound_is_never_dropped() {
let mut model = half_space(true, 0.0);
model.insert(EntityId(10), point2(0.0, 0.0));
model.insert(EntityId(11), point2(1.0, 0.0));
model.insert(EntityId(12), point2(1.0, 1.0));
model.insert(EntityId(13), point2(0.0, 1.0));
model.insert(
EntityId(6),
entity(
"IFCPOLYLINE",
vec![Value::List(vec![r(10), r(11), r(12), r(13)])],
),
);
model.insert(EntityId(8), point3(3.0, 4.0, 5.0));
model.insert(
EntityId(7),
entity("IFCAXIS2PLACEMENT3D", vec![r(8), Value::Null, Value::Null]),
);
model.insert(
EntityId(5),
entity(
"IFCPOLYGONALBOUNDEDHALFSPACE",
vec![r(4), Value::Bool(true), r(7), r(6)],
),
);
let scale = UnitScale::default();
let mut session = LoweringSession::new(&model, &scale);
let node =
lower_half_space_node(&mut session, EntityId(5), Transform::identity()).expect("lowers");
let lowered = session.finish(node).expect("finishes");
let GeometryNode::SolidOperation(SolidOperation::BoundedHalfSpace {
half_space,
boundary,
placement,
}) = lowered.graph.get(lowered.root).expect("root")
else {
panic!("expected a BoundedHalfSpace operation");
};
assert_eq!(
placement.translation.to_array(),
[3.0, 4.0, 5.0],
"Position is independent of BaseSurface and must not be dropped"
);
assert!(matches!(
lowered.graph.get(*half_space),
Some(GeometryNode::HalfSpace(_))
));
let Some(GeometryNode::Curve3(Curve3::Polyline(boundary_curve))) = lowered.graph.get(*boundary)
else {
panic!("expected a 2D polyline boundary");
};
assert_eq!(
boundary_curve.points.len(),
4,
"the authored square must keep all four corners"
);
assert_eq!(
boundary_curve.points[2].to_array(),
[1.0, 1.0, 0.0],
"boundary stays in the placement's own XY plane; z is not invented"
);
}
#[test]
fn a_non_planar_base_surface_is_reported_as_unsupported() {
let mut model = half_space(true, 0.0);
model.insert(
EntityId(4),
entity("IFCCYLINDRICALSURFACE", vec![r(3), n(1.0)]),
);
let scale = UnitScale::default();
let mut session = LoweringSession::new(&model, &scale);
let error = lower_half_space_node(&mut session, EntityId(5), Transform::identity())
.expect_err("a cylindrical base surface must not lower");
let text = format!("{error:?}");
assert!(
text.contains("IFCCYLINDRICALSURFACE"),
"the error must name the offending surface, got {text}"
);
}