use symbios_shape::grammar::parse_rule;
use symbios_shape::{Interpreter, Quat, Scope, ShapeError, Vec3};
fn interp_from(lines: &[&str]) -> Interpreter {
let mut interp = Interpreter::new();
for line in lines {
let rule = parse_rule(line).unwrap_or_else(|e| panic!("parse {line:?}: {e}"));
interp.add_grammar_rule(rule).unwrap();
}
interp
}
fn footprint(x: f64, z: f64) -> Scope {
Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(x, 0.0, z))
}
#[test]
fn rand_extrude_is_in_range_and_seed_deterministic() {
let mut interp = interp_from(&[r#"Lot --> Extrude(rand(8, 14)) I("Mass")"#]);
interp.seed = 7;
let a = interp.derive(footprint(10.0, 10.0), "Lot").unwrap();
let b = interp.derive(footprint(10.0, 10.0), "Lot").unwrap();
let h = a.terminals[0].scope.size.y;
assert!((8.0..14.0).contains(&h), "height {h} outside rand range");
assert_eq!(
h, b.terminals[0].scope.size.y,
"same seed must derive the same height"
);
interp.seed = 8;
let c = interp.derive(footprint(10.0, 10.0), "Lot").unwrap();
assert_ne!(
h, c.terminals[0].scope.size.y,
"different seed should draw a different height"
);
}
#[test]
fn scope_dependent_split_sizes() {
let interp = interp_from(&[
r#"Lot --> Extrude(12) Body"#,
r#"Body --> Split(Y) { scope.y / 4: Base | ~1: Rest }"#,
]);
let model = interp.derive(footprint(10.0, 10.0), "Lot").unwrap();
assert_eq!(model.len(), 2);
let base = model
.terminals
.iter()
.find(|t| t.mesh_id == "Base")
.expect("Base terminal");
assert!((base.scope.size.y - 3.0).abs() < 1e-9, "12 / 4 = 3");
}
#[test]
fn host_attributes_parameterize_a_grammar() {
let mut interp = interp_from(&[r#"Lot --> Extrude(Floors * FloorH) I("Mass")"#]);
interp.set_attr("Floors", 5.0);
interp.set_attr("FloorH", 3.2);
let model = interp.derive(footprint(8.0, 8.0), "Lot").unwrap();
assert!((model.terminals[0].scope.size.y - 16.0).abs() < 1e-9);
interp.set_attr("Floors", 2.0);
let model = interp.derive(footprint(8.0, 8.0), "Lot").unwrap();
assert!((model.terminals[0].scope.size.y - 6.4).abs() < 1e-9);
}
#[test]
fn unknown_identifier_surfaces_as_error() {
let interp = interp_from(&[r#"Lot --> Extrude(MissingKnob) I("Mass")"#]);
match interp.derive(footprint(8.0, 8.0), "Lot") {
Err(ShapeError::UnknownIdentifier(name)) => assert_eq!(name, "MissingKnob"),
other => panic!("expected UnknownIdentifier, got {other:?}"),
}
}
#[test]
fn expression_scale_reads_current_scope() {
let interp = interp_from(&[
r#"Lot --> Extrude(4) Scale(0.5, 1, 1) Scale(scope.x / 10, 1, 1) I("Mass")"#,
]);
let model = interp.derive(footprint(10.0, 10.0), "Lot").unwrap();
assert!((model.terminals[0].scope.size.x - 2.5).abs() < 1e-9);
}
#[test]
fn division_by_zero_is_a_derivation_error() {
let interp = interp_from(&[r#"Lot --> Extrude(4 / (scope.y)) I("Mass")"#]);
assert!(matches!(
interp.derive(footprint(8.0, 8.0), "Lot"),
Err(ShapeError::ExprEval(_))
));
}
#[test]
fn rule_call_arguments_bind_to_parameters() {
use symbios_shape::grammar::parse_ops;
let mut interp = Interpreter::new();
interp
.add_rule_def(
"Box",
vec!["h".to_string()],
vec![(1.0, parse_ops(r#"Extrude(h) I("Mass")"#).unwrap())],
)
.unwrap();
interp.add_rule("Lot", parse_ops("Box(scope.x / 2)").unwrap());
let model = interp.derive(footprint(9.0, 5.0), "Lot").unwrap();
assert!((model.terminals[0].scope.size.y - 4.5).abs() < 1e-9);
}
#[test]
fn arity_mismatch_is_a_derivation_error() {
use symbios_shape::grammar::parse_ops;
let mut interp = Interpreter::new();
interp
.add_rule_def(
"Box",
vec!["h".to_string()],
vec![(1.0, parse_ops(r#"Extrude(h) I("Mass")"#).unwrap())],
)
.unwrap();
interp.add_rule("Lot", parse_ops("Box(1, 2)").unwrap());
assert!(matches!(
interp.derive(footprint(9.0, 5.0), "Lot"),
Err(ShapeError::ArityMismatch(_))
));
}
#[test]
fn split_index_vars_differentiate_first_and_last_bays() {
let interp = interp_from(&[
r#"Lot --> Extrude(2) Row"#,
r#"Row --> Repeat(X, 5) { Bay }"#,
r#"Bay --> Scale(1, 1 + split.i, 1) I("Bay")"#,
]);
let model = interp.derive(footprint(10.0, 4.0), "Lot").unwrap();
assert_eq!(model.len(), 2);
let mut heights: Vec<f64> = model.terminals.iter().map(|t| t.scope.size.y).collect();
heights.sort_by(f64::total_cmp);
assert!((heights[0] - 2.0).abs() < 1e-9, "tile 0: 2 * (1+0)");
assert!((heights[1] - 4.0).abs() < 1e-9, "tile 1: 2 * (1+1)");
}
#[test]
fn text_level_parameterized_recursion_with_guards() {
let interp = interp_from(&[
r#"Lot --> Extrude(8) Spire(3)"#,
r#"Spire(n) --> when(n == 0): I("Finial") | else: Split(Y) { '0.6: Body | ~1: Next(n) }"#,
r#"Body --> I("Tier")"#,
r#"Next(n) --> Spire(n - 1)"#,
]);
let model = interp.derive(footprint(6.0, 6.0), "Lot").unwrap();
let tiers = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Tier")
.count();
let finials = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Finial")
.count();
assert_eq!(tiers, 3, "three guarded recursion steps");
assert_eq!(finials, 1, "guard base case emits the finial once");
}
#[test]
fn guards_read_scope_dimensions() {
let lines = [
r#"Lot --> Extrude(3) Face"#,
r#"Face --> when(scope.x < 4): I("Wall") | else: I("Window")"#,
];
let narrow = interp_from(&lines)
.derive(footprint(3.0, 3.0), "Lot")
.unwrap();
assert_eq!(narrow.terminals[0].mesh_id, "Wall");
let wide = interp_from(&lines)
.derive(footprint(9.0, 3.0), "Lot")
.unwrap();
assert_eq!(wide.terminals[0].mesh_id, "Window");
}
#[test]
fn nil_vanishes_in_slots_and_variants() {
let interp = interp_from(&[
r#"Lot --> Extrude(2) Split(X) { ~1: Solid | ~1: NIL | ~1: Solid }"#,
r#"Solid --> I("Wall")"#,
]);
let model = interp.derive(footprint(9.0, 3.0), "Lot").unwrap();
assert_eq!(model.len(), 2, "NIL slot must not emit");
let mut hit = 0;
let mut miss = 0;
for seed in 0..40 {
let mut i2 = interp_from(&[
r#"Lot --> Extrude(2) Maybe"#,
r#"Maybe --> 50% I("Thing") | 50% NIL"#,
]);
i2.seed = seed;
let m = i2.derive(footprint(4.0, 4.0), "Lot").unwrap();
if m.len() == 1 {
hit += 1;
} else {
assert_eq!(m.len(), 0);
miss += 1;
}
}
assert!(hit > 5 && miss > 5, "both branches taken: {hit} vs {miss}");
}
#[test]
fn else_weight_sugar_takes_the_remaining_mass() {
let rule = parse_rule(r#"Pick --> 70% I("A") | else: I("B")"#).unwrap();
let weights: Vec<f64> = rule.variants.iter().map(|v| v.weight().unwrap()).collect();
assert!((weights[0] - 0.7).abs() < 1e-9);
assert!((weights[1] - 0.3).abs() < 1e-9);
assert!(parse_rule(r#"Pick --> 100% I("A") | else: I("B")"#).is_err());
assert!(parse_rule(r#"Pick --> 70% I("A") | when(scope.x > 1): I("B")"#).is_err());
assert!(
parse_rule(
r#"Pick --> when(scope.x > 1): I("A") | else: I("B") | when(scope.x > 2): I("C")"#
)
.is_err()
);
}
#[test]
fn defining_nil_is_rejected() {
let rule = parse_rule(r#"NIL --> I("X")"#).unwrap();
let mut interp = Interpreter::new();
assert!(interp.add_grammar_rule(rule).is_err());
}
#[test]
fn rhythm_split_repeats_pattern_between_bookends() {
let interp = interp_from(&[
r#"Lot --> Extrude(3) Face"#,
r#"Face --> Split(X) { 1.2: Corner | { 0.5: Pier | 1.5: Win }* | 1.2: Corner }"#,
]);
let model = interp.derive(footprint(12.0, 3.0), "Lot").unwrap();
let count = |id: &str| model.terminals.iter().filter(|t| t.mesh_id == id).count();
assert_eq!(count("Corner"), 2);
assert_eq!(count("Pier"), 4, "four whole pattern copies fit");
assert_eq!(count("Win"), 4);
let total: f64 = model.terminals.iter().map(|t| t.scope.size.x).sum();
assert!((total - 12.0).abs() < 1e-6, "no gap, no overshoot: {total}");
}
#[test]
fn rhythm_split_leftover_goes_to_floats() {
let interp = interp_from(&[
r#"Lot --> Extrude(3) Face"#,
r#"Face --> Split(X) { { 3: Bay }* | ~1: End }"#,
]);
let model = interp.derive(footprint(10.0, 3.0), "Lot").unwrap();
let end = model
.terminals
.iter()
.find(|t| t.mesh_id == "End")
.expect("End terminal");
assert!(
(end.scope.size.x - 1.0).abs() < 1e-6,
"float absorbs leftover"
);
let bays = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Bay")
.count();
assert_eq!(bays, 3);
}
#[test]
fn fit_takes_the_first_candidate_that_fits() {
let lines = [
r#"Lot --> Extrude(3) Face"#,
r#"Face --> Fit(X) { 5: Wide | 2: Medium | 0: Narrow }"#,
];
let wide = interp_from(&lines)
.derive(footprint(6.0, 3.0), "Lot")
.unwrap();
assert_eq!(wide.terminals[0].mesh_id, "Wide");
let medium = interp_from(&lines)
.derive(footprint(3.0, 3.0), "Lot")
.unwrap();
assert_eq!(medium.terminals[0].mesh_id, "Medium");
let narrow = interp_from(&lines)
.derive(footprint(1.0, 3.0), "Lot")
.unwrap();
assert_eq!(narrow.terminals[0].mesh_id, "Narrow");
}
#[test]
fn split_area_divides_by_target_areas() {
let interp = interp_from(&[r#"Lot --> SplitArea(X) { 20: Parcel | ~1: Rest }"#]);
let model = interp.derive(footprint(12.0, 5.0), "Lot").unwrap();
let parcel = model
.terminals
.iter()
.find(|t| t.mesh_id == "Parcel")
.expect("Parcel terminal");
assert!(
(parcel.scope.size.x - 4.0).abs() < 1e-9,
"20 m² / 5 m = 4 m"
);
assert!(symbios_shape::grammar::parse_ops("SplitArea(Y) { 20: A | ~1: B }").is_err());
}
#[test]
fn shape_l_carves_two_boxes_and_a_remainder() {
let interp = interp_from(&[
r#"Lot --> ShapeL(4, 3) { Shape: Wing | Remainder: Court }"#,
r#"Wing --> Extrude(6) I("Wing")"#,
r#"Court --> I("Court")"#,
]);
let model = interp.derive(footprint(10.0, 12.0), "Lot").unwrap();
let wings: Vec<_> = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Wing")
.collect();
let courts: Vec<_> = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Court")
.collect();
assert_eq!(wings.len(), 2, "L = front bar + side leg");
assert_eq!(courts.len(), 1);
let mut wing_foot: Vec<(f64, f64)> = wings
.iter()
.map(|t| (t.scope.size.x, t.scope.size.z))
.collect();
wing_foot.sort_by(|a, b| a.0.total_cmp(&b.0));
assert!((wing_foot[0].0 - 3.0).abs() < 1e-9 && (wing_foot[0].1 - 8.0).abs() < 1e-9);
assert!((wing_foot[1].0 - 10.0).abs() < 1e-9 && (wing_foot[1].1 - 4.0).abs() < 1e-9);
assert!(
(courts[0].scope.size.x - 7.0).abs() < 1e-9 && (courts[0].scope.size.z - 8.0).abs() < 1e-9
);
}
#[test]
fn shape_u_carves_three_boxes_around_a_court() {
let interp = interp_from(&[r#"Lot --> ShapeU(3, 2, 2) { Shape: Range | Remainder: Court }"#]);
let model = interp.derive(footprint(10.0, 9.0), "Lot").unwrap();
let ranges = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Range")
.count();
let court = model
.terminals
.iter()
.find(|t| t.mesh_id == "Court")
.expect("court");
assert_eq!(ranges, 3, "U = front bar + two legs");
assert!((court.scope.size.x - 6.0).abs() < 1e-9);
assert!((court.scope.size.z - 6.0).abs() < 1e-9);
}
#[test]
fn size_and_center_place_a_fixed_size_centred_box() {
let interp = interp_from(&[r#"Lot --> Size(2, 1, 2) Center(XZ) Extrude(1) I("Pad")"#]);
let model = interp.derive(footprint(10.0, 6.0), "Lot").unwrap();
let t = &model.terminals[0];
assert!((t.scope.size.x - 2.0).abs() < 1e-9);
assert!((t.scope.position.x - 4.0).abs() < 1e-9, "centred: (10-2)/2");
assert!((t.scope.position.z - 2.0).abs() < 1e-9, "centred: (6-2)/2");
}
#[test]
fn mirror_flips_the_pending_polygon_profile() {
let interp = interp_from(&[
r#"Lot --> Extrude(4) Polygon((0, 0), (1, 0), (1, 0.25)) Mirror(X) I("Ramp")"#,
]);
let model = interp.derive(footprint(8.0, 8.0), "Lot").unwrap();
let symbios_shape::FaceProfile::Polygon(pts) = &model.terminals[0].face_profile else {
panic!("expected polygon profile");
};
assert!((pts[0].x - 1.0).abs() < 1e-9);
assert!((pts[1].x - 0.0).abs() < 1e-9);
assert!((pts[2].x - 0.0).abs() < 1e-9 && (pts[2].y - 0.25).abs() < 1e-9);
}
#[test]
fn comp_edges_yields_corner_posts_and_rings() {
let interp = interp_from(&[
r#"Lot --> Extrude(6) Frame"#,
r#"Frame --> Comp(Edges) { Vertical: Post | Top: Coping }"#,
r#"Post --> Size(scope.x, 0.3, 0.3) I("Post")"#,
r#"Coping --> Size(scope.x, 0.2, 0.2) I("Coping")"#,
]);
let model = interp.derive(footprint(8.0, 5.0), "Lot").unwrap();
let posts: Vec<_> = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Post")
.collect();
let coping = model
.terminals
.iter()
.filter(|t| t.mesh_id == "Coping")
.count();
assert_eq!(posts.len(), 4, "four vertical corner posts");
assert_eq!(coping, 4, "top ring only (Bottom unmapped)");
assert!((posts[0].scope.size.x - 6.0).abs() < 1e-9);
assert!((posts[0].scope.size.y - 0.3).abs() < 1e-9);
}
#[test]
fn ridge_override_turns_the_gable() {
let auto = interp_from(&[
r#"Lot --> Extrude(3) Cap"#,
r#"Cap --> Roof(Gable, 35) { Slope: S | GableEnd: G }"#,
])
.derive(footprint(10.0, 4.0), "Lot")
.unwrap();
let forced = interp_from(&[
r#"Lot --> Extrude(3) Cap"#,
r#"Cap --> Roof(Gable, 35, ridge=Z) { Slope: S | GableEnd: G }"#,
])
.derive(footprint(10.0, 4.0), "Lot")
.unwrap();
let widths = |m: &symbios_shape::ShapeModel| -> Vec<f64> {
let mut v: Vec<f64> = m
.terminals
.iter()
.filter(|t| t.mesh_id == "S")
.map(|t| t.scope.size.x)
.collect();
v.sort_by(f64::total_cmp);
v
};
assert!((widths(&auto)[0] - 10.0).abs() < 1e-9);
assert!((widths(&forced)[0] - 4.0).abs() < 1e-9);
}
#[test]
fn shed_back_wall_supports_northlights() {
let interp = interp_from(&[
r#"Lot --> Extrude(4) Saw"#,
r#"Saw --> Repeat(X, 3) { Tooth }"#,
r#"Tooth --> Roof(Shed, 40) { Slope: Metal | Back: NorthLight }"#,
]);
let model = interp.derive(footprint(9.0, 6.0), "Lot").unwrap();
let lights = model
.terminals
.iter()
.filter(|t| t.mesh_id == "NorthLight")
.count();
assert_eq!(lights, 3, "one glazed back face per sawtooth");
}
#[test]
fn roof_by_height_aligns_mixed_width_wings() {
let interp = interp_from(&[
r#"Lot --> Split(X) { 8: WideWing | 4: NarrowWing }"#,
r#"WideWing --> Extrude(3) Roof(Gable, height=2.5) { Slope: S | GableEnd: G }"#,
r#"NarrowWing --> Extrude(3) Roof(Gable, height=2.5) { Slope: S | GableEnd: G }"#,
]);
let model = interp.derive(footprint(12.0, 6.0), "Lot").unwrap();
let mut ends: Vec<f64> = model
.terminals
.iter()
.filter(|t| t.mesh_id == "G")
.map(|t| t.scope.size.y)
.collect();
ends.sort_by(f64::total_cmp);
assert!(ends.len() >= 2);
assert!(
(ends[0] - 2.5).abs() < 1e-6 && (ends[ends.len() - 1] - 2.5).abs() < 1e-6,
"both wings reach ridge height 2.5: {ends:?}"
);
}
#[test]
fn labelled_occlusion_filters_by_class() {
let interp = interp_from(&[
r#"Lot --> Extrude(4) Split(X) { 4: BlockerZone | 4: FreeZone }"#,
r#"BlockerZone --> Blocker"#,
r#"Blocker --> Label("chimneys") I("Chimney")"#,
r#"FreeZone --> Split(Y) { 2: Filler | ~1: Probe }"#,
r#"Filler --> I("Filler")"#,
r#"Probe --> Size(2, 1, 2) Center(XZ) Probe2"#,
r#"Probe2 --> IfClear("chimneys") { Win }"#,
r#"Win --> I("Win")"#,
]);
let model = interp.derive(footprint(8.0, 4.0), "Lot").unwrap();
assert_eq!(
model
.terminals
.iter()
.filter(|t| t.mesh_id == "Win")
.count(),
1,
"unlabelled terminals must not block a labelled IfClear"
);
}
#[test]
fn if_inside_and_if_touches_grade_the_overlap() {
let interp = interp_from(&[
r#"Lot --> Split(X) { 6: MassZone | 2: SideProbe }"#,
r#"MassZone --> Extrude(6) Mass"#,
r#"Mass --> Label("mass") Split(Y) { ~1: MassBody | 2: InnerProbe }"#,
r#"MassBody --> I("Mass")"#,
r#"InnerProbe --> Size(2, 1, 2) Translate(1, -3, 1) Inner"#,
r#"Inner --> IfInside("mass") { Core }"#,
r#"Core --> I("Core")"#,
r#"SideProbe --> Extrude(6) Side"#,
r#"Side --> SideWait"#,
r#"SideWait --> SideWait2"#,
r#"SideWait2 --> IfTouches("mass") { Skin }"#,
r#"Skin --> I("Skin")"#,
]);
let model = interp.derive(footprint(8.0, 4.0), "Lot").unwrap();
let count = |id: &str| model.terminals.iter().filter(|t| t.mesh_id == id).count();
assert_eq!(
count("Core"),
1,
"probe fully inside the mass fires IfInside"
);
assert_eq!(count("Skin"), 1, "face-contact probe fires IfTouches");
}
#[test]
fn scatter_is_seed_stable_and_sized_by_rule() {
let lines = [
r#"Lot --> Scatter(Top, 8) { Bush }"#,
r#"Bush --> Size(0.4, 0.6, 0.4) I("Bush")"#,
];
let mut i1 = interp_from(&lines);
i1.seed = 5;
let a = i1.derive(footprint(10.0, 10.0), "Lot").unwrap();
let mut i2 = interp_from(&lines);
i2.seed = 5;
let b = i2.derive(footprint(10.0, 10.0), "Lot").unwrap();
assert_eq!(a.len(), 8);
let xs = |m: &symbios_shape::ShapeModel| -> Vec<f64> {
let mut v: Vec<f64> = m.terminals.iter().map(|t| t.scope.position.x).collect();
v.sort_by(f64::total_cmp);
v
};
assert_eq!(xs(&a), xs(&b), "same seed, same scatter");
assert!(
(a.terminals[0].scope.size.y - 0.6).abs() < 1e-9,
"Size gave extent"
);
}
#[test]
fn pick_agrees_across_the_whole_derivation() {
for seed in 0..12 {
let mut interp = interp_from(&[
r#"Lot --> Extrude(3) Row"#,
r#"Row --> Repeat(X, 2) { Bay }"#,
r#"Bay --> Pick("win") { 50% WinA | 50% WinB }"#,
r#"WinA --> I("A")"#,
r#"WinB --> I("B")"#,
]);
interp.seed = seed;
let model = interp.derive(footprint(20.0, 3.0), "Lot").unwrap();
let a = model.terminals.iter().filter(|t| t.mesh_id == "A").count();
let b = model.terminals.iter().filter(|t| t.mesh_id == "B").count();
assert_eq!(a * b, 0, "seed {seed}: mixed picks in one derivation");
assert_eq!(a + b, 10);
}
}
#[test]
fn statements_declare_attrs_consts_and_styles() {
use symbios_shape::grammar::parse_statement;
let mut interp = Interpreter::new();
for line in [
"const FloorH = 3.2",
"attr Floors = 4",
"style Poor { Floors = 2 }",
"style Rich extends Poor { Floors = 6 }",
r#"Lot --> Extrude(Floors * FloorH) I("Mass")"#,
] {
interp
.add_statement(parse_statement(line).unwrap())
.unwrap();
}
let h = |i: &Interpreter| {
i.derive(footprint(8.0, 8.0), "Lot").unwrap().terminals[0]
.scope
.size
.y
};
assert!((h(&interp) - 12.8).abs() < 1e-9, "attr default: 4 floors");
interp.set_style("Poor").unwrap();
assert!((h(&interp) - 6.4).abs() < 1e-9, "style override: 2 floors");
interp.set_style("Rich").unwrap();
assert!((h(&interp) - 19.2).abs() < 1e-9, "extends chain: 6 floors");
interp.set_attr("Floors", 1.0);
assert!((h(&interp) - 3.2).abs() < 1e-9, "host override beats style");
assert!(interp.set_style("NoSuch").is_err());
assert!(parse_statement("attr Bad = scope.x").is_err());
assert!(parse_statement("attr Bad = rand(1, 2)").is_err());
}
#[test]
fn mutated_feature_soup_never_panics() {
use rand::SeedableRng;
use symbios_genetics::Genotype;
use symbios_shape::genetics::ShapeGenotype;
let interp = interp_from(&[
"Lot --> ShapeL(4, 3) { Shape: Wing | Remainder: Court }",
"Wing --> Extrude(rand(6, 9)) Body",
"Court --> Scatter(Top, 5) { Bush }",
"Bush --> Size(0.5, 0.8, 0.5) I(\"Bush\")",
"Body --> Split(Y) { 3: Ground | { 2.8: Floor }* | ~1: Attic }",
"Ground --> Comp(Faces) { Side: Facade | Top: NIL }",
"Floor --> Comp(Faces) { Side: Facade }",
"Facade --> when(scope.x < 4.5): Pier | else: FacadeR",
"FacadeR --> Split(X) { 0.8: Pier | { 0.5: Pier | ~1: Bay }* | 0.8: Pier }",
"Pier --> Extrude(0.3) I(\"Pier\")",
"Bay --> when(scope.x < 0.9): Pier | else: Pick(\"win\") { 60% WinA | 40% WinB }",
"WinA --> Extrude(0.1) I(\"WinA\")",
"WinB --> Extrude(0.1) I(\"WinB\")",
"Attic --> Roof(Gable, height=2, ridge=X) { Slope: Tile | GableEnd: Pier | _: NIL }",
"Tile --> Label(\"roof\") I(\"Tile\")",
]);
let base = ShapeGenotype::from_interpreter(&interp);
let mut rng = rand_pcg::Pcg64::seed_from_u64(99);
let mut ok = 0usize;
let mut soft_fail = 0usize;
for i in 0..200 {
let mut dna = base.clone();
dna.mutate(&mut rng, 0.6);
let mut evolved = dna.to_interpreter();
evolved.seed = i;
match evolved.derive(footprint(12.0, 10.0), "Lot") {
Ok(model) => {
assert!(!model.terminals.is_empty());
ok += 1;
}
Err(_) => soft_fail += 1,
}
}
assert!(
ok > 150,
"only {ok}/200 mutants derived (soft fails: {soft_fail})"
);
}
#[test]
fn terminal_label_serde_round_trips_and_defaults() {
let interp = interp_from(&[r#"Lot --> Extrude(2) Label("cls") I("M")"#]);
let model = interp.derive(footprint(4.0, 4.0), "Lot").unwrap();
let json = serde_json::to_string(&model).unwrap();
let back: symbios_shape::ShapeModel = serde_json::from_str(&json).unwrap();
assert_eq!(back.terminals[0].label.as_deref(), Some("cls"));
let legacy = r#"{"scope":{"position":[0.0,0.0,0.0],"rotation":[0.0,0.0,0.0,1.0],"size":[1.0,1.0,1.0]},"mesh_id":"M","face_profile":"Rectangle","material":null,"mass_properties":null}"#;
let t: symbios_shape::Terminal = serde_json::from_str(legacy).unwrap();
assert_eq!(t.label, None);
}