use bevy::color::Color;
use bevy::prelude::*;
use symbios_shape::{Interpreter, Scope, ShapeError};
use crate::cache::{MeshCacheKey, ProfileKey, ShapeMeshCache};
use crate::label::TerminalLabel;
use crate::mass::TerminalMass;
use crate::mesh::build_profiled_mesh_with;
use crate::query::LastDerivedModel;
use crate::registry::ShapeRegistry;
use crate::snap::{SnapPlane, SnapPlanes};
use crate::transform::scope_to_transform;
#[allow(clippy::too_many_arguments)]
pub trait SpawnShapeExt {
fn spawn_shape(
&mut self,
interpreter: &Interpreter,
root_scope: Scope,
root_rule: &str,
registry: &ShapeRegistry,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
cache: &mut ShapeMeshCache,
) -> Result<Entity, ShapeError>;
}
impl SpawnShapeExt for Commands<'_, '_> {
fn spawn_shape(
&mut self,
interpreter: &Interpreter,
root_scope: Scope,
root_rule: &str,
registry: &ShapeRegistry,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
cache: &mut ShapeMeshCache,
) -> Result<Entity, ShapeError> {
let model = interpreter.derive(root_scope, root_rule)?;
let snap_planes: Vec<SnapPlane> = model.snap_planes.iter().map(SnapPlane::from).collect();
self.insert_resource(SnapPlanes(snap_planes));
let root = self
.spawn((Transform::default(), Visibility::default()))
.id();
for terminal in &model.terminals {
let transform = scope_to_transform(&terminal.scope);
let child = if let Some(scene_handle) = registry.get_mesh(&terminal.mesh_id) {
self.spawn((SceneRoot(scene_handle.clone()), transform))
.id()
} else {
let size = Vec3::new(
terminal.scope.size.x as f32,
terminal.scope.size.y as f32,
terminal.scope.size.z as f32,
);
let stretch_uvs = registry.should_stretch_uvs(
&terminal.mesh_id,
terminal.material.as_ref().map(|m| m.id.as_str()),
);
let round_segments = registry.round_segments_for(
&terminal.mesh_id,
terminal.material.as_ref().map(|m| m.id.as_str()),
);
let mesh_key = MeshCacheKey {
profile: ProfileKey::from_profile(&terminal.face_profile),
size_x_bits: size.x.to_bits(),
size_y_bits: size.y.to_bits(),
size_z_bits: size.z.to_bits(),
stretch_uvs,
round_segments,
};
let mesh_handle = cache.get_or_insert_with(mesh_key, || {
meshes.add(build_profiled_mesh_with(
&terminal.face_profile,
size,
stretch_uvs,
round_segments,
))
});
let material_handle = registry
.resolve_material(terminal.material.as_ref().map(|m| m.id.as_str()))
.unwrap_or_else(|| {
let hue = string_to_hue(&terminal.mesh_id);
materials.add(StandardMaterial {
base_color: Color::hsl(hue, 0.4, 0.6),
..Default::default()
})
});
self.spawn((
Mesh3d(mesh_handle),
MeshMaterial3d(material_handle),
transform,
))
.id()
};
if let Some(mp) = terminal.mass_properties.as_ref() {
self.entity(child).insert(TerminalMass::from(mp));
}
if let Some(label) = terminal.label.as_deref() {
self.entity(child).insert(TerminalLabel::from(label));
}
self.entity(root).add_child(child);
}
self.insert_resource(LastDerivedModel(model));
Ok(root)
}
}
fn string_to_hue(s: &str) -> f32 {
let hash = s.bytes().fold(5381u32, |acc, b| {
acc.wrapping_mul(31).wrapping_add(b as u32)
});
(hash % 360) as f32
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
use symbios_shape::grammar::parse_ops;
use symbios_shape::{Interpreter, Quat as DQuat, Scope, Vec3 as DVec3};
#[test]
fn string_to_hue_is_in_range() {
for id in ["Window", "Door", "Roof", "Ground", "Wall", ""] {
let h = string_to_hue(id);
assert!((0.0..360.0).contains(&h), "hue {h} out of range for '{id}'");
}
}
#[test]
fn string_to_hue_is_stable() {
assert_eq!(string_to_hue("Building"), string_to_hue("Building"));
}
fn make_app() -> App {
let mut app = App::new();
app.add_plugins(bevy::asset::AssetPlugin::default())
.init_resource::<Assets<Mesh>>()
.init_resource::<Assets<StandardMaterial>>()
.init_resource::<Assets<Scene>>()
.init_resource::<ShapeRegistry>()
.init_resource::<ShapeMeshCache>();
app
}
fn spawn_grammar(app: &mut App, lines: &[&str], root: &'static str, footprint: Scope) {
use symbios_shape::grammar::parse_statement;
let mut interp = Interpreter::new();
for line in lines {
interp
.add_statement(parse_statement(line).unwrap())
.unwrap();
}
app.world_mut()
.run_system_once(
move |mut commands: Commands,
registry: Res<ShapeRegistry>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut cache: ResMut<ShapeMeshCache>| {
commands
.spawn_shape(
&interp,
footprint,
root,
®istry,
&mut meshes,
&mut materials,
&mut cache,
)
.unwrap();
},
)
.unwrap();
}
fn unit_footprint() -> Scope {
Scope::new(DVec3::ZERO, DQuat::IDENTITY, DVec3::new(2.0, 0.0, 2.0))
}
#[test]
fn label_component_mirrors_the_grammar_class() {
let mut app = make_app();
spawn_grammar(
&mut app,
&[
r#"Lot --> Split(X) { ~1: Tagged | ~1: Plain }"#,
r#"Tagged --> Extrude(2) Label("roof") I("Tile")"#,
r#"Plain --> Extrude(2) I("Wall")"#,
],
"Lot",
unit_footprint(),
);
let labels: Vec<String> = app
.world_mut()
.query::<&TerminalLabel>()
.iter(app.world())
.map(|l| l.as_str().to_string())
.collect();
assert_eq!(
labels,
vec!["roof".to_string()],
"only the labelled branch should carry a TerminalLabel"
);
}
#[test]
fn round_registration_changes_the_baked_mesh() {
let grammar = [r#"Lot --> Extrude(3) Mat("Marble") I("Column")"#];
let mut boxed_app = make_app();
spawn_grammar(&mut boxed_app, &grammar, "Lot", unit_footprint());
let mut round_app = make_app();
round_app
.world_mut()
.resource_mut::<ShapeRegistry>()
.register_round_mesh("Column");
spawn_grammar(&mut round_app, &grammar, "Lot", unit_footprint());
let vert_count = |app: &mut App| -> usize {
let handles: Vec<Handle<Mesh>> = app
.world_mut()
.query::<&Mesh3d>()
.iter(app.world())
.map(|m| m.0.clone())
.collect();
assert_eq!(handles.len(), 1, "expected exactly one terminal");
let meshes = app.world().resource::<Assets<Mesh>>();
meshes
.get(&handles[0])
.expect("mesh asset")
.attribute(Mesh::ATTRIBUTE_POSITION)
.expect("positions")
.len()
};
assert_eq!(vert_count(&mut boxed_app), 24, "default stays a cuboid");
assert!(
vert_count(&mut round_app) > 24,
"registered round mesh must bake an elliptical prism"
);
}
#[test]
fn mass_component_inserted_when_density_present() {
let mut interp = Interpreter::new();
interp.add_rule(
"Lot",
parse_ops(r#"Extrude(2) Mat("Stone", 1800) I("Wall")"#).unwrap(),
);
let footprint = Scope::new(DVec3::ZERO, DQuat::IDENTITY, DVec3::new(2.0, 0.0, 2.0));
let mut app = make_app();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
registry: Res<ShapeRegistry>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut cache: ResMut<ShapeMeshCache>| {
commands
.spawn_shape(
&interp,
footprint,
"Lot",
®istry,
&mut meshes,
&mut materials,
&mut cache,
)
.unwrap();
},
)
.unwrap();
let count = app
.world_mut()
.query::<&TerminalMass>()
.iter(app.world())
.count();
assert!(
count >= 1,
"expected at least one TerminalMass component, got {count}"
);
let masses: Vec<_> = app
.world_mut()
.query::<&TerminalMass>()
.iter(app.world())
.map(|m| m.mass)
.collect();
assert!(
masses.iter().all(|&m| m > 0.0 && m.is_finite()),
"expected positive finite masses, got {masses:?}"
);
}
#[test]
fn snap_planes_resource_populated_after_spawn() {
let mut interp = Interpreter::new();
interp.add_rule(
"Lot",
parse_ops(r#"Extrude(2) RegSnap("walls") I("Wall")"#).unwrap(),
);
let footprint = Scope::new(DVec3::ZERO, DQuat::IDENTITY, DVec3::new(2.0, 0.0, 2.0));
let mut app = make_app();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
registry: Res<ShapeRegistry>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut cache: ResMut<ShapeMeshCache>| {
commands
.spawn_shape(
&interp,
footprint,
"Lot",
®istry,
&mut meshes,
&mut materials,
&mut cache,
)
.unwrap();
},
)
.unwrap();
let planes = app.world().resource::<SnapPlanes>();
assert_eq!(planes.len(), 6, "RegSnap should record 6 face planes");
assert!(
planes.iter().all(|p| p.label == "walls"),
"all planes should carry the RegSnap label"
);
assert!(
planes.iter().all(|p| p.normal.length() > 0.99),
"normals should be unit length"
);
}
#[test]
fn last_derived_model_populated_and_overlaps_query_works() {
let mut interp = Interpreter::new();
interp.add_rule("Lot", parse_ops(r#"Extrude(2) I("Wall")"#).unwrap());
let footprint = Scope::new(DVec3::ZERO, DQuat::IDENTITY, DVec3::new(2.0, 0.0, 2.0));
let mut app = make_app();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
registry: Res<ShapeRegistry>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut cache: ResMut<ShapeMeshCache>| {
commands
.spawn_shape(
&interp,
footprint,
"Lot",
®istry,
&mut meshes,
&mut materials,
&mut cache,
)
.unwrap();
},
)
.unwrap();
let derived = app.world().resource::<LastDerivedModel>();
assert!(
!derived.model().terminals.is_empty(),
"expected at least one terminal in LastDerivedModel"
);
let near = Scope::new(
DVec3::new(0.5, 0.5, 0.5),
DQuat::IDENTITY,
DVec3::new(0.5, 0.5, 0.5),
);
assert!(derived.overlaps(&near), "near scope should overlap");
let far = Scope::new(
DVec3::new(100.0, 100.0, 100.0),
DQuat::IDENTITY,
DVec3::new(0.5, 0.5, 0.5),
);
assert!(!derived.overlaps(&far), "far scope should not overlap");
}
#[test]
fn snap_planes_resource_empty_when_no_regsnap() {
let mut interp = Interpreter::new();
interp.add_rule("Lot", parse_ops(r#"Extrude(2) I("Wall")"#).unwrap());
let footprint = Scope::new(DVec3::ZERO, DQuat::IDENTITY, DVec3::new(2.0, 0.0, 2.0));
let mut app = make_app();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
registry: Res<ShapeRegistry>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut cache: ResMut<ShapeMeshCache>| {
commands
.spawn_shape(
&interp,
footprint,
"Lot",
®istry,
&mut meshes,
&mut materials,
&mut cache,
)
.unwrap();
},
)
.unwrap();
let planes = app.world().resource::<SnapPlanes>();
assert!(
planes.is_empty(),
"expected no snap planes, got {}",
planes.len()
);
}
#[test]
fn mesh_cache_reuses_handles_across_spawns() {
fn make_interp() -> Interpreter {
let mut interp = Interpreter::new();
interp.add_rule("Lot", parse_ops(r#"Extrude(2) I("Wall")"#).unwrap());
interp
}
let footprint = Scope::new(DVec3::ZERO, DQuat::IDENTITY, DVec3::new(2.0, 0.0, 2.0));
let mut app = make_app();
let interp = make_interp();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
registry: Res<ShapeRegistry>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut cache: ResMut<ShapeMeshCache>| {
commands
.spawn_shape(
&interp,
footprint,
"Lot",
®istry,
&mut meshes,
&mut materials,
&mut cache,
)
.unwrap();
},
)
.unwrap();
let (hits_after_first, misses_after_first, len_after_first) = {
let cache = app.world().resource::<ShapeMeshCache>();
(cache.hits(), cache.misses(), cache.len())
};
assert!(
misses_after_first >= 1,
"first spawn should miss at least once"
);
assert_eq!(hits_after_first, 0, "first spawn should not hit");
let interp = make_interp();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
registry: Res<ShapeRegistry>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut cache: ResMut<ShapeMeshCache>| {
commands
.spawn_shape(
&interp,
footprint,
"Lot",
®istry,
&mut meshes,
&mut materials,
&mut cache,
)
.unwrap();
},
)
.unwrap();
let cache = app.world().resource::<ShapeMeshCache>();
assert_eq!(
cache.misses(),
misses_after_first,
"second spawn must not miss (no new keys)"
);
assert!(
cache.hits() > hits_after_first,
"second spawn must register hits"
);
assert_eq!(
cache.len(),
len_after_first,
"cache size should not grow on identical second spawn"
);
}
#[test]
fn mass_component_absent_when_no_density() {
let mut interp = Interpreter::new();
interp.add_rule("Lot", parse_ops(r#"Extrude(2) I("Wall")"#).unwrap());
let footprint = Scope::new(DVec3::ZERO, DQuat::IDENTITY, DVec3::new(2.0, 0.0, 2.0));
let mut app = make_app();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
registry: Res<ShapeRegistry>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut cache: ResMut<ShapeMeshCache>| {
commands
.spawn_shape(
&interp,
footprint,
"Lot",
®istry,
&mut meshes,
&mut materials,
&mut cache,
)
.unwrap();
},
)
.unwrap();
let count = app
.world_mut()
.query::<&TerminalMass>()
.iter(app.world())
.count();
assert_eq!(count, 0, "expected no TerminalMass components, got {count}");
}
}