use symbios_shape::grammar::parse_ops;
use symbios_shape::ops::{Axis, ShapeOp, SnapBinding, SplitSize, SplitSlot};
use symbios_shape::{Interpreter, Quat, Scope, Vec3};
fn unit_scope() -> Scope {
Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 10.0, 10.0))
}
#[test]
fn reg_snap_registers_six_face_planes() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::RegSnap("bays".to_string()),
ShapeOp::I("Mass".to_string()),
],
);
let model = interp.derive(unit_scope(), "R").unwrap();
let bays: Vec<_> = model
.snap_planes
.iter()
.filter(|p| p.label == "bays")
.collect();
assert_eq!(bays.len(), 6, "should register all 6 face planes");
}
#[test]
fn reg_snap_world_planes_match_scope_faces() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::RegSnap("bays".to_string()),
ShapeOp::I("Mass".to_string()),
],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 4.0, 6.0));
let model = interp.derive(scope, "R").unwrap();
let plus_x = model
.snap_planes
.iter()
.find(|p| p.normal.x > 0.99)
.expect("+X plane");
assert!((plus_x.point.x - 10.0).abs() < 1e-9);
let minus_z = model
.snap_planes
.iter()
.find(|p| p.normal.z < -0.99)
.expect("-Z plane");
assert!((minus_z.point.z).abs() < 1e-9);
}
#[test]
fn reg_snap_via_grammar() {
let ops = parse_ops("RegSnap(\"bays\") I(\"Mass\")").unwrap();
let mut interp = Interpreter::new();
interp.add_rule("R", ops);
let model = interp.derive(unit_scope(), "R").unwrap();
assert!(model.snap_planes.iter().any(|p| p.label == "bays"));
}
#[test]
fn snap_split_aligns_to_registered_plane() {
let mut interp = Interpreter::new();
interp.add_rule(
"Lot",
vec![ShapeOp::Split {
axis: Axis::Y,
entries: vec![
SplitSlot {
size: SplitSize::abs(5.0),
rule: "Ground".into(),
}
.into(),
SplitSlot {
size: SplitSize::abs(5.0),
rule: "Upper".into(),
}
.into(),
],
snap: None,
}],
);
interp.add_rule(
"Ground",
vec![ShapeOp::Split {
axis: Axis::X,
entries: vec![
SplitSlot {
size: SplitSize::abs(5.0),
rule: "GroundLeft".into(),
}
.into(),
SplitSlot {
size: SplitSize::abs(5.0),
rule: "GroundRight".into(),
}
.into(),
],
snap: None,
}],
);
interp.add_rule(
"GroundLeft",
vec![
ShapeOp::RegSnap("bays".to_string()),
ShapeOp::I("LeftFloor".to_string()),
],
);
interp.add_rule("GroundRight", vec![ShapeOp::I("RightFloor".to_string())]);
interp.add_rule("Upper", vec![ShapeOp::Rule("UpperImpl".into())]);
interp.add_rule(
"UpperImpl",
vec![ShapeOp::Split {
axis: Axis::X,
entries: vec![
SplitSlot {
size: SplitSize::abs(4.5),
rule: "UpperBay".into(),
}
.into(),
SplitSlot {
size: SplitSize::abs(5.5),
rule: "UpperBay".into(),
}
.into(),
],
snap: Some(SnapBinding {
label: "bays".to_string(),
tolerance: Some(1.0),
}),
}],
);
interp.add_rule("UpperBay", vec![ShapeOp::I("Bay".to_string())]);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 10.0, 10.0));
let model = interp.derive(scope, "Lot").unwrap();
let upper_bays: Vec<_> = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Bay")
.collect();
assert_eq!(upper_bays.len(), 2);
let widths: Vec<f64> = upper_bays.iter().map(|t| t.scope.size.x).collect();
assert!(
(widths[0] - 5.0).abs() < 1e-6,
"expected snapped width 5.0, got {}",
widths[0]
);
assert!(
(widths[1] - 5.0).abs() < 1e-6,
"expected snapped width 5.0, got {}",
widths[1]
);
}
#[test]
fn snap_split_does_nothing_when_outside_tolerance() {
let mut interp = Interpreter::new();
interp.add_rule(
"Lot",
vec![ShapeOp::Split {
axis: Axis::Y,
entries: vec![
SplitSlot {
size: SplitSize::abs(5.0),
rule: "Ground".into(),
}
.into(),
SplitSlot {
size: SplitSize::abs(5.0),
rule: "Upper".into(),
}
.into(),
],
snap: None,
}],
);
interp.add_rule(
"Ground",
vec![ShapeOp::Split {
axis: Axis::X,
entries: vec![
SplitSlot {
size: SplitSize::abs(5.0),
rule: "GL".into(),
}
.into(),
SplitSlot {
size: SplitSize::abs(5.0),
rule: "GR".into(),
}
.into(),
],
snap: None,
}],
);
interp.add_rule(
"GL",
vec![
ShapeOp::RegSnap("bays".to_string()),
ShapeOp::I("LeftFloor".to_string()),
],
);
interp.add_rule("GR", vec![ShapeOp::I("RightFloor".to_string())]);
interp.add_rule("Upper", vec![ShapeOp::Rule("UpperImpl".into())]);
interp.add_rule(
"UpperImpl",
vec![ShapeOp::Split {
axis: Axis::X,
entries: vec![
SplitSlot {
size: SplitSize::abs(4.5),
rule: "UB".into(),
}
.into(),
SplitSlot {
size: SplitSize::abs(5.5),
rule: "UB".into(),
}
.into(),
],
snap: Some(SnapBinding {
label: "bays".to_string(),
tolerance: Some(0.1),
}),
}],
);
interp.add_rule("UB", vec![ShapeOp::I("Bay".to_string())]);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 10.0, 10.0));
let model = interp.derive(scope, "Lot").unwrap();
let bays: Vec<_> = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Bay")
.collect();
assert_eq!(bays.len(), 2);
let widths: Vec<f64> = bays.iter().map(|t| t.scope.size.x).collect();
assert!(
(widths[0] - 4.5).abs() < 1e-6,
"expected 4.5 (no snap), got {}",
widths[0]
);
assert!(
(widths[1] - 5.5).abs() < 1e-6,
"expected 5.5 (no snap), got {}",
widths[1]
);
}
#[test]
fn snap_split_default_tolerance_is_5pct_of_axis() {
let mut interp = Interpreter::new();
interp.add_rule(
"Lot",
vec![ShapeOp::Split {
axis: Axis::Y,
entries: vec![
SplitSlot {
size: SplitSize::abs(5.0),
rule: "Ground".into(),
}
.into(),
SplitSlot {
size: SplitSize::abs(5.0),
rule: "Upper".into(),
}
.into(),
],
snap: None,
}],
);
interp.add_rule(
"Ground",
vec![ShapeOp::Split {
axis: Axis::X,
entries: vec![
SplitSlot {
size: SplitSize::abs(5.0),
rule: "GL".into(),
}
.into(),
SplitSlot {
size: SplitSize::abs(5.0),
rule: "GR".into(),
}
.into(),
],
snap: None,
}],
);
interp.add_rule(
"GL",
vec![
ShapeOp::RegSnap("bays".to_string()),
ShapeOp::I("LeftFloor".to_string()),
],
);
interp.add_rule("GR", vec![ShapeOp::I("RightFloor".to_string())]);
interp.add_rule("Upper", vec![ShapeOp::Rule("UpperImpl".into())]);
interp.add_rule(
"UpperImpl",
vec![ShapeOp::Split {
axis: Axis::X,
entries: vec![
SplitSlot {
size: SplitSize::abs(4.6),
rule: "UB".into(),
}
.into(),
SplitSlot {
size: SplitSize::abs(5.4),
rule: "UB".into(),
}
.into(),
],
snap: Some(SnapBinding {
label: "bays".to_string(),
tolerance: None, }),
}],
);
interp.add_rule("UB", vec![ShapeOp::I("Bay".to_string())]);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 10.0, 10.0));
let model = interp.derive(scope, "Lot").unwrap();
let bays: Vec<_> = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Bay")
.collect();
let widths: Vec<f64> = bays.iter().map(|t| t.scope.size.x).collect();
assert!(
(widths[0] - 5.0).abs() < 1e-6,
"expected snap to 5.0, got {}",
widths[0]
);
}
#[test]
fn snap_split_grammar_syntax() {
let ops = parse_ops("Split(X, snap=\"bays\", tol=1.0) { 4.5: A | 5.5: B }").unwrap();
let ShapeOp::Split {
axis,
entries,
snap,
} = &ops[0]
else {
panic!("expected Split");
};
assert_eq!(*axis, Axis::X);
assert_eq!(entries.len(), 2);
let snap = snap.as_ref().expect("snap binding");
assert_eq!(snap.label, "bays");
assert_eq!(snap.tolerance, Some(1.0));
}
#[test]
fn snap_split_grammar_syntax_default_tol() {
let ops = parse_ops("Split(X, snap=\"bays\") { 4.5: A | 5.5: B }").unwrap();
let ShapeOp::Split { snap, .. } = &ops[0] else {
panic!("expected Split");
};
let snap = snap.as_ref().expect("snap binding");
assert_eq!(snap.label, "bays");
assert_eq!(snap.tolerance, None);
}
#[test]
fn snap_split_with_no_matching_label_leaves_boundaries_intact() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::RegSnap("ignored".to_string()),
ShapeOp::Split {
axis: Axis::X,
entries: vec![
SplitSlot {
size: SplitSize::abs(4.5),
rule: "A".into(),
}
.into(),
SplitSlot {
size: SplitSize::abs(5.5),
rule: "B".into(),
}
.into(),
],
snap: Some(SnapBinding {
label: "bays".to_string(),
tolerance: Some(1.0),
}),
},
],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 10.0, 10.0));
let model = interp.derive(scope, "R").unwrap();
assert!((model.terminals[0].scope.size.x - 4.5).abs() < 1e-6);
assert!((model.terminals[1].scope.size.x - 5.5).abs() < 1e-6);
}
#[test]
fn snap_planes_cleared_between_derive_calls() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::RegSnap("once".to_string()),
ShapeOp::I("Mass".to_string()),
],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(1.0, 1.0, 1.0));
let m1 = interp.derive(scope, "R").unwrap();
let m2 = interp.derive(scope, "R").unwrap();
assert_eq!(m1.snap_planes.len(), 6);
assert_eq!(m2.snap_planes.len(), 6);
}