use symbios_shape::expr::Expr;
use symbios_shape::grammar::parse_ops;
use symbios_shape::model::FaceProfile;
use symbios_shape::ops::{
Axis, CompFaceCase, CompTarget, FaceSelector, OffsetCase, OffsetSelector, RoofCase, RoofConfig,
RoofFaceSelector, RoofType, ShapeOp, SplitSize, SplitSlot,
};
use symbios_shape::{Interpreter, Quat, Scope, ShapeError, Vec3};
fn slot(size: SplitSize, rule: &str) -> SplitSlot {
SplitSlot {
size,
rule: rule.into(),
}
}
fn unit_scope() -> Scope {
Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 10.0, 10.0))
}
#[test]
fn single_terminal_direct() {
let mut interp = Interpreter::new();
interp.add_rule("Root", vec![ShapeOp::I("Cube".to_string())]);
let model = interp.derive(unit_scope(), "Root").unwrap();
assert_eq!(model.len(), 1);
assert_eq!(model.terminals[0].mesh_id, "Cube");
}
#[test]
fn unknown_rule_becomes_implicit_terminal() {
let interp = Interpreter::new();
let model = interp.derive(unit_scope(), "Foo").unwrap();
assert_eq!(model.len(), 1);
assert_eq!(model.terminals[0].mesh_id, "Foo");
}
#[test]
fn empty_ops_produces_no_terminals() {
let mut interp = Interpreter::new();
interp.add_rule("Nil", vec![]);
let model = interp.derive(unit_scope(), "Nil").unwrap();
assert!(model.is_empty());
}
#[test]
fn extrude_sets_y_size() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::Extrude(Expr::lit(7.5)),
ShapeOp::I("Box".to_string()),
],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 0.0, 10.0));
let model = interp.derive(scope, "R").unwrap();
assert!((model.terminals[0].scope.size.y - 7.5).abs() < 1e-9);
}
#[test]
fn extrude_on_face_scope_sets_z_size() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::Extrude(Expr::lit(0.2)),
ShapeOp::I("Wall".to_string()),
],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 0.0));
let model = interp.derive(scope, "R").unwrap();
assert!(
(model.terminals[0].scope.size.z - 0.2).abs() < 1e-9,
"expected size.z = 0.2, got {}",
model.terminals[0].scope.size.z
);
assert!(
(model.terminals[0].scope.size.y - 3.0).abs() < 1e-9,
"face height must be preserved, got {}",
model.terminals[0].scope.size.y
);
}
#[test]
fn taper_propagates_to_terminal() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::Taper(Expr::lit(0.6)),
ShapeOp::I("Cone".to_string()),
],
);
let model = interp.derive(unit_scope(), "R").unwrap();
assert!(
matches!(model.terminals[0].face_profile, FaceProfile::Taper(t) if (t - 0.6).abs() < 1e-9)
);
}
#[test]
fn no_taper_defaults_to_zero() {
let mut interp = Interpreter::new();
interp.add_rule("R", vec![ShapeOp::I("Box".to_string())]);
let model = interp.derive(unit_scope(), "R").unwrap();
assert_eq!(model.terminals[0].face_profile, FaceProfile::Rectangle);
}
#[test]
fn scale_multiplies_size() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::Scale([Expr::lit(0.5), Expr::lit(2.0), Expr::lit(1.0)]),
ShapeOp::I("S".to_string()),
],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 2.0));
let model = interp.derive(scope, "R").unwrap();
let s = model.terminals[0].scope.size;
assert!((s.x - 2.0).abs() < 1e-9);
assert!((s.y - 6.0).abs() < 1e-9);
assert!((s.z - 2.0).abs() < 1e-9);
}
#[test]
fn translate_moves_position() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::Translate([Expr::lit(1.0), Expr::lit(2.0), Expr::lit(3.0)]),
ShapeOp::I("T".to_string()),
],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::ONE);
let model = interp.derive(scope, "R").unwrap();
let p = model.terminals[0].scope.position;
assert!((p.x - 1.0).abs() < 1e-9);
assert!((p.y - 2.0).abs() < 1e-9);
assert!((p.z - 3.0).abs() < 1e-9);
}
#[test]
fn split_y_positions_are_sequential() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Split {
axis: Axis::Y,
entries: vec![
slot(SplitSize::abs(3.0), "A").into(),
slot(SplitSize::abs(4.0), "B").into(),
slot(SplitSize::abs(3.0), "C").into(),
],
snap: None,
}],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 10.0, 10.0));
let model = interp.derive(scope, "R").unwrap();
assert_eq!(model.len(), 3);
assert!((model.terminals[0].scope.position.y - 0.0).abs() < 1e-9);
assert!((model.terminals[1].scope.position.y - 3.0).abs() < 1e-9);
assert!((model.terminals[2].scope.position.y - 7.0).abs() < 1e-9);
assert!((model.terminals[0].scope.size.y - 3.0).abs() < 1e-9);
assert!((model.terminals[1].scope.size.y - 4.0).abs() < 1e-9);
assert!((model.terminals[2].scope.size.y - 3.0).abs() < 1e-9);
}
#[test]
fn split_floating_fills_remainder() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Split {
axis: Axis::Y,
entries: vec![
slot(SplitSize::abs(2.0), "Base").into(),
slot(SplitSize::float(3.0), "Mid").into(),
slot(SplitSize::float(1.0), "Top").into(),
],
snap: None,
}],
);
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.y - 2.0).abs() < 1e-9);
assert!((model.terminals[1].scope.size.y - 6.0).abs() < 1e-9);
assert!((model.terminals[2].scope.size.y - 2.0).abs() < 1e-9);
}
#[test]
fn split_x_widths_correct() {
let mut interp = Interpreter::new();
interp.add_rule(
"Facade",
vec![ShapeOp::Split {
axis: Axis::X,
entries: vec![
slot(SplitSize::float(1.0), "L").into(),
slot(SplitSize::float(1.0), "R").into(),
],
snap: None,
}],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(6.0, 4.0, 4.0));
let model = interp.derive(scope, "Facade").unwrap();
assert!((model.terminals[0].scope.size.x - 3.0).abs() < 1e-9);
assert!((model.terminals[1].scope.size.x - 3.0).abs() < 1e-9);
assert!((model.terminals[1].scope.position.x - 3.0).abs() < 1e-9);
}
#[test]
fn repeat_tile_count_and_sizes() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Repeat {
axis: Axis::X,
tile_sizes: vec![Expr::lit(2.0)],
rule: "W".into(),
}],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(7.0, 3.0, 1.0));
let model = interp.derive(scope, "R").unwrap();
assert_eq!(model.len(), 3);
let actual = 7.0_f64 / 3.0;
assert!((model.terminals[0].scope.size.x - actual).abs() < 1e-9);
assert!((model.terminals[2].scope.position.x - 2.0 * actual).abs() < 1e-9);
}
#[test]
fn repeat_zero_tiles_when_scope_too_small() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Repeat {
axis: Axis::X,
tile_sizes: vec![Expr::lit(5.0)],
rule: "W".into(),
}],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 1.0));
let model = interp.derive(scope, "R").unwrap();
assert!(model.is_empty());
}
#[test]
fn repeat_pattern_list_cycles_and_preserves_ratios() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Repeat {
axis: Axis::X,
tile_sizes: vec![Expr::lit(2.0), Expr::lit(1.5), Expr::lit(3.0)],
rule: "Bay".into(),
}],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(13.0, 1.0, 1.0));
let model = interp.derive(scope, "R").unwrap();
assert_eq!(model.len(), 6);
let s: Vec<f64> = model.terminals.iter().map(|t| t.scope.size.x).collect();
assert!((s[0] - 2.0).abs() < 1e-9);
assert!((s[1] - 1.5).abs() < 1e-9);
assert!((s[2] - 3.0).abs() < 1e-9);
assert!((s[3] - 2.0).abs() < 1e-9);
assert!((s[4] - 1.5).abs() < 1e-9);
assert!((s[5] - 3.0).abs() < 1e-9);
let positions: Vec<f64> = model.terminals.iter().map(|t| t.scope.position.x).collect();
assert!((positions[1] - 2.0).abs() < 1e-9);
assert!((positions[2] - 3.5).abs() < 1e-9);
assert!((positions[5] - 10.0).abs() < 1e-9);
}
#[test]
fn repeat_pattern_list_stretches_proportionally() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Repeat {
axis: Axis::X,
tile_sizes: vec![Expr::lit(2.0), Expr::lit(1.5), Expr::lit(3.0)],
rule: "Bay".into(),
}],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(14.0, 1.0, 1.0));
let model = interp.derive(scope, "R").unwrap();
assert_eq!(model.len(), 6);
let scale = 14.0_f64 / 13.0;
let s: Vec<f64> = model.terminals.iter().map(|t| t.scope.size.x).collect();
assert!((s[0] - 2.0 * scale).abs() < 1e-9);
assert!((s[1] - 1.5 * scale).abs() < 1e-9);
assert!((s[2] - 3.0 * scale).abs() < 1e-9);
let total: f64 = s.iter().sum();
assert!((total - 14.0).abs() < 1e-9);
}
#[test]
fn repeat_pattern_list_partial_cycle_at_end() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Repeat {
axis: Axis::X,
tile_sizes: vec![Expr::lit(2.0), Expr::lit(1.5), Expr::lit(3.0)],
rule: "Bay".into(),
}],
);
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(9.0, 1.0, 1.0));
let model = interp.derive(scope, "R").unwrap();
assert_eq!(model.len(), 4);
let scale = 9.0_f64 / 8.5;
let s: Vec<f64> = model.terminals.iter().map(|t| t.scope.size.x).collect();
assert!((s[0] - 2.0 * scale).abs() < 1e-9);
assert!((s[3] - 2.0 * scale).abs() < 1e-9);
let total: f64 = s.iter().sum();
assert!((total - 9.0).abs() < 1e-9);
}
#[test]
fn comp_faces_emits_six_terminals() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Comp(CompTarget::Faces(vec![CompFaceCase {
selector: FaceSelector::All,
rule: "Face".into(),
}]))],
);
let model = interp.derive(unit_scope(), "R").unwrap();
assert_eq!(model.len(), 6);
}
#[test]
fn comp_faces_top_rule_selected() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Comp(CompTarget::Faces(vec![
CompFaceCase {
selector: FaceSelector::Top,
rule: "Roof".into(),
},
CompFaceCase {
selector: FaceSelector::All,
rule: "Wall".into(),
},
]))],
);
let model = interp.derive(unit_scope(), "R").unwrap();
let roofs: Vec<_> = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Roof")
.collect();
let walls: Vec<_> = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Wall")
.collect();
assert_eq!(roofs.len(), 1);
assert_eq!(walls.len(), 5);
}
#[test]
fn comp_faces_side_matches_four_walls() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Comp(CompTarget::Faces(vec![
CompFaceCase {
selector: FaceSelector::Top,
rule: "Top".into(),
},
CompFaceCase {
selector: FaceSelector::Bottom,
rule: "Bot".into(),
},
CompFaceCase {
selector: FaceSelector::Side,
rule: "Side".into(),
},
]))],
);
let model = interp.derive(unit_scope(), "R").unwrap();
let sides: Vec<_> = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Side")
.collect();
assert_eq!(sides.len(), 4);
}
#[test]
fn comp_faces_unmapped_face_skipped() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Comp(CompTarget::Faces(vec![CompFaceCase {
selector: FaceSelector::Top,
rule: "Roof".into(),
}]))],
);
let model = interp.derive(unit_scope(), "R").unwrap();
assert_eq!(model.len(), 1);
}
#[test]
fn rule_delegates_to_subrule() {
let mut interp = Interpreter::new();
interp.add_rule("A", vec![ShapeOp::Rule("B".into())]);
interp.add_rule("B", vec![ShapeOp::I("Leaf".to_string())]);
let model = interp.derive(unit_scope(), "A").unwrap();
assert_eq!(model.len(), 1);
assert_eq!(model.terminals[0].mesh_id, "Leaf");
}
#[test]
fn depth_limit_enforced() {
let mut interp = Interpreter::new();
interp.add_rule("A", vec![ShapeOp::Rule("A".into())]);
interp.max_depth = 10;
assert!(matches!(
interp.derive(unit_scope(), "A"),
Err(ShapeError::DepthLimitExceeded(_))
));
}
#[test]
fn terminal_limit_enforced() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Repeat {
axis: Axis::X,
tile_sizes: vec![Expr::lit(1.0)],
rule: "T".into(),
}],
);
interp.max_terminals = 5;
let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(100.0, 1.0, 1.0));
assert!(matches!(
interp.derive(scope, "R"),
Err(ShapeError::CapacityOverflow)
));
}
#[test]
fn invalid_scope_rejected() {
let interp = Interpreter::new();
let bad = Scope::new(Vec3::new(f64::NAN, 0.0, 0.0), Quat::IDENTITY, Vec3::ONE);
assert!(matches!(
interp.derive(bad, "R"),
Err(ShapeError::InvalidNumericValue)
));
}
#[test]
fn parse_and_derive_building() {
let mut interp = Interpreter::new();
interp.add_rule(
"Lot",
parse_ops("Extrude(12) Split(Y) { 3: Ground | ~1: Upper | 2: Roof }").unwrap(),
);
interp.add_rule("Ground", parse_ops(r#"I("GroundFloor")"#).unwrap());
interp.add_rule("Upper", parse_ops(r#"I("Floor")"#).unwrap());
interp.add_rule("Roof", parse_ops(r#"Taper(0.8) I("Roof")"#).unwrap());
let footprint = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 0.0, 10.0));
let model = interp.derive(footprint, "Lot").unwrap();
assert_eq!(model.len(), 3);
assert_eq!(model.terminals[0].mesh_id, "GroundFloor");
assert!((model.terminals[0].scope.size.y - 3.0).abs() < 1e-9);
assert_eq!(model.terminals[2].mesh_id, "Roof");
assert!(
matches!(model.terminals[2].face_profile, FaceProfile::Taper(t) if (t - 0.8).abs() < 1e-9)
);
}
#[test]
fn translate_overflow_to_infinity_rejected() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::Translate([Expr::lit(f64::MAX), Expr::lit(0.0), Expr::lit(0.0)]),
ShapeOp::Translate([Expr::lit(f64::MAX), Expr::lit(0.0), Expr::lit(0.0)]),
ShapeOp::I("Leaf".to_string()),
],
);
assert!(matches!(
interp.derive(unit_scope(), "R"),
Err(ShapeError::InvalidNumericValue)
));
}
#[test]
fn align_large_target_length_sq_overflow_rejected() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![
ShapeOp::Align {
local_axis: Axis::Y,
target: Vec3::new(1e200, 1e200, 1e200),
},
ShapeOp::I("Leaf".to_string()),
],
);
assert!(matches!(
interp.derive(unit_scope(), "R"),
Err(ShapeError::InvalidAlignTarget)
));
}
#[test]
fn roof_trig_overflow_large_scope_rejected() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Roof {
spec: RoofConfig::new(RoofType::Gable, 89.0).into(),
cases: vec![RoofCase {
selector: RoofFaceSelector::Slope,
rule: "Tiles".into(),
}],
}],
);
let big_scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 0.0, 1e307));
assert!(matches!(
interp.derive(big_scope, "R"),
Err(ShapeError::InvalidNumericValue)
));
}
#[test]
fn roof_large_overhang_overflow_rejected() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Roof {
spec: {
let mut c = RoofConfig::new(RoofType::Pyramid, 45.0);
c.overhang = f64::MAX * 0.9;
symbios_shape::ops::RoofSpec::from(c)
},
cases: vec![RoofCase {
selector: RoofFaceSelector::Slope,
rule: "Tiles".into(),
}],
}],
);
assert!(matches!(
interp.derive(unit_scope(), "R"),
Err(ShapeError::InvalidNumericValue)
));
}
#[test]
fn repeat_non_finite_total_rejected() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Repeat {
axis: Axis::X,
tile_sizes: vec![Expr::lit(1.0)],
rule: "Leaf".into(),
}],
);
let bad_scope = Scope::new(
Vec3::ZERO,
Quat::IDENTITY,
Vec3::new(f64::INFINITY, 1.0, 1.0),
);
assert!(interp.derive(bad_scope, "R").is_err());
}
#[test]
fn offset_nan_scope_size_rejected() {
let mut interp = Interpreter::new();
interp.add_rule(
"R",
vec![ShapeOp::Offset {
distance: Expr::lit(-0.2),
cases: vec![OffsetCase {
selector: OffsetSelector::Inside,
rule: "Leaf".into(),
}],
}],
);
let nan_scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(f64::NAN, 1.0, 1.0));
assert!(interp.derive(nan_scope, "R").is_err());
}