symbios_shape/model.rs
1use serde::{Deserialize, Serialize};
2
3use crate::scope::{Scope, Vec3};
4
5/// Material stamped on a [`Terminal`] by a `Mat("...")` grammar op.
6///
7/// Carries an asset identifier (used by downstream renderers to look up
8/// textures / shaders) and an optional mass density in kg/m³ used to
9/// derive [`MassProperties`] for physics, LOD, and IK pipelines.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct Material {
12 /// Identifier the renderer uses to resolve textures / shaders.
13 pub id: String,
14 /// Mass density in kg/m³. When `Some`, the interpreter computes
15 /// [`MassProperties`] for every terminal stamped with this material.
16 /// When `None`, the terminal's [`Terminal::mass_properties`] stays `None`.
17 pub density: Option<f64>,
18}
19
20impl Material {
21 /// Creates a material with no density (purely a renderer-side identifier).
22 pub fn new(id: impl Into<String>) -> Self {
23 Self {
24 id: id.into(),
25 density: None,
26 }
27 }
28
29 /// Creates a material with a mass density in kg/m³.
30 pub fn with_density(id: impl Into<String>, density: f64) -> Self {
31 Self {
32 id: id.into(),
33 density: Some(density),
34 }
35 }
36}
37
38/// A snap-plane recorded during derivation.
39///
40/// Snap planes are emitted by the `RegSnap("label")` op (which records all six
41/// face planes of the current scope) and consumed by the snap-aware variant
42/// of `Split` (`Split(axis, snap="label") { … }`). They give downstream
43/// elements a way to align to landmarks established earlier in the derivation
44/// — e.g. ground-floor bay edges become snap planes that upper-floor bays
45/// align to.
46///
47/// The plane is defined in world-space by `point` (a point on the plane) and
48/// `normal` (a unit-length plane normal). `label` groups planes for selective
49/// querying.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct SnapPlane {
52 pub point: Vec3,
53 pub normal: Vec3,
54 pub label: String,
55}
56
57/// Volumetric mass properties of a terminal.
58///
59/// Populated by the interpreter when the terminal's [`Material`] carries a
60/// density. Downstream consumers (Bevy, IK solvers, LOD selectors) read these
61/// directly rather than recomputing from `scope` + `face_profile`.
62///
63/// All quantities are in **world-frame** SI units: `mass` in kg, `centroid` in
64/// metres from the world origin, `inertia` is the moment-of-inertia tensor
65/// about the centroid expressed as a 3×3 matrix in kg·m².
66///
67/// `inertia` is `None` for face profiles whose closed-form tensor is not
68/// implemented (currently only [`FaceProfile::Taper`]); `mass` and `centroid`
69/// are always populated whenever a density is available.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct MassProperties {
72 pub mass: f64,
73 pub centroid: Vec3,
74 pub inertia: Option<glam::DMat3>,
75}
76
77/// The 2D profile shape of a terminal face, used by renderers to construct geometry.
78///
79/// Replaces the old `taper: f64` field. Each variant describes the cross-sectional
80/// outline of the face panel within the scope's local XY plane:
81/// - X runs from 0 (left edge) to `scope.size.x` (right edge).
82/// - Y runs from 0 (bottom edge) to `scope.size.y` (top edge).
83///
84/// The renderer extrudes this 2D outline along the local Z axis by `scope.size.z`
85/// (which is 0 for flat face panels produced by `Roof` and `Comp(Faces)`).
86///
87/// Coordinate convention for `Trapezoid` and `Triangle`: values are **normalized**
88/// (0.0 = left/bottom, 1.0 = right/top) so they are independent of the actual scope size.
89/// The renderer scales them by `scope.size.x` before vertex generation.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub enum FaceProfile {
92 /// Full rectangular face — the default for walls, floors, and generic volumes.
93 Rectangle,
94
95 /// Legacy tapered prism, equivalent to the old `taper` field.
96 ///
97 /// `t` ∈ `[0, 1]`: 0 = box, 1 = full pyramid. The renderer maps this
98 /// to the existing `build_tapered_cuboid` path for backward compatibility.
99 Taper(f64),
100
101 /// Triangular face: base at Y = 0 (full scope width), apex at Y = scope.size.y.
102 ///
103 /// `peak_offset` ∈ `[0, 1]`: horizontal offset of the apex from the left edge,
104 /// normalized to the scope width. `0.5` = symmetric triangle (standard gable).
105 /// `0.3` = apex shifted left (asymmetric, used by Saltbox gable ends).
106 Triangle { peak_offset: f64 },
107
108 /// Trapezoidal face: rectangular base at Y = 0, narrower top edge at Y = scope.size.y.
109 ///
110 /// Both values are normalized to the scope width (`scope.size.x = 1.0`):
111 /// - `top_width` ∈ `[0, 1]`: width of the top edge as a fraction of the base width.
112 /// - `offset_x` ∈ `[0, 1]`: left indent of the top edge from the scope left.
113 ///
114 /// Invariant: `offset_x + top_width ≤ 1.0`.
115 /// A symmetric trapezoid has `offset_x = (1 - top_width) / 2`.
116 Trapezoid { top_width: f64, offset_x: f64 },
117
118 /// Arbitrary convex or concave polygon, produced by the straight skeleton algorithm
119 /// for complex (L-shaped, T-shaped, etc.) building footprints.
120 ///
121 /// Vertices are in the scope's local XZ (floor) plane, measured in world units
122 /// from the scope origin. The renderer triangulates this polygon and extrudes it
123 /// along the local Y axis by the roof pitch height.
124 Polygon(Vec<glam::DVec2>),
125}
126
127impl FaceProfile {
128 /// Returns `true` if the profile is the default `Rectangle` shape.
129 pub fn is_rectangle(&self) -> bool {
130 matches!(self, Self::Rectangle)
131 }
132
133 /// Returns the legacy taper coefficient if this profile was set by `ShapeOp::Taper`.
134 pub fn taper_coeff(&self) -> Option<f64> {
135 match self {
136 Self::Taper(t) => Some(*t),
137 Self::Rectangle => Some(0.0),
138 _ => None,
139 }
140 }
141}
142
143/// A fully-resolved terminal node in the shape model.
144///
145/// Represents a concrete mesh instance placed at the given `scope`.
146/// The `mesh_id` identifies which asset to spawn (e.g. `"Window"`, `"Door"`, `"Pillar"`).
147/// `face_profile` describes the 2D cross-section shape of this terminal.
148/// `material`: optional [`Material`] (id + density) set by `Mat(...)` operations.
149/// `mass_properties`: derived volumetric properties when the material carries a density.
150/// This is the "DOM" that Bevy (or any renderer) reads to spawn entities.
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct Terminal {
153 pub scope: Scope,
154 pub mesh_id: String,
155 /// 2D face profile describing the cross-sectional shape of this terminal.
156 /// Replaces the old `taper: f64` field. Use [`FaceProfile::taper_coeff`] to
157 /// obtain a legacy taper coefficient for backward-compatible renderers.
158 pub face_profile: FaceProfile,
159 /// Optional material stamped by a `Mat("...", density?)` operation.
160 pub material: Option<Material>,
161 /// Volumetric mass properties (mass / centroid / inertia tensor), populated
162 /// when `material` carries a `density`. Always `None` otherwise.
163 pub mass_properties: Option<MassProperties>,
164}
165
166impl Terminal {
167 pub fn new(scope: Scope, mesh_id: impl Into<String>) -> Self {
168 Self {
169 scope,
170 mesh_id: mesh_id.into(),
171 face_profile: FaceProfile::Rectangle,
172 material: None,
173 mass_properties: None,
174 }
175 }
176
177 /// Creates a terminal with a legacy taper factor (0 = box, 1 = pyramid).
178 /// Converts the taper into a `FaceProfile` automatically.
179 pub fn new_with_taper(scope: Scope, mesh_id: impl Into<String>, taper: f64) -> Self {
180 let face_profile = taper_to_profile(taper);
181 Self {
182 scope,
183 mesh_id: mesh_id.into(),
184 face_profile,
185 material: None,
186 mass_properties: None,
187 }
188 }
189
190 /// Creates a terminal with an explicit [`FaceProfile`] and optional material.
191 /// `mass_properties` is automatically computed when the material has a density.
192 pub fn new_profiled(
193 scope: Scope,
194 mesh_id: impl Into<String>,
195 face_profile: FaceProfile,
196 material: Option<Material>,
197 ) -> Self {
198 let mass_properties = material
199 .as_ref()
200 .and_then(|m| m.density)
201 .and_then(|rho| compute_mass_properties(&scope, &face_profile, rho));
202 Self {
203 scope,
204 mesh_id: mesh_id.into(),
205 face_profile,
206 material,
207 mass_properties,
208 }
209 }
210}
211
212/// Converts a legacy taper coefficient to the closest `FaceProfile`.
213pub fn taper_to_profile(taper: f64) -> FaceProfile {
214 if taper <= 0.0 {
215 FaceProfile::Rectangle
216 } else if (taper - 1.0).abs() < 1e-9 {
217 FaceProfile::Triangle { peak_offset: 0.5 }
218 } else {
219 FaceProfile::Taper(taper.clamp(0.0, 1.0))
220 }
221}
222
223/// The output of a shape grammar derivation.
224///
225/// Contains all terminal nodes produced by the grammar plus any snap planes
226/// recorded along the way (via `RegSnap`).
227#[derive(Debug, Clone, Default, Serialize, Deserialize)]
228pub struct ShapeModel {
229 pub terminals: Vec<Terminal>,
230 /// Snap planes accumulated during derivation. See [`SnapPlane`] and the
231 /// `RegSnap` / `Split(snap=...)` grammar ops.
232 pub snap_planes: Vec<SnapPlane>,
233}
234
235impl ShapeModel {
236 pub fn new() -> Self {
237 Self::default()
238 }
239
240 pub fn push(&mut self, terminal: Terminal) {
241 self.terminals.push(terminal);
242 }
243
244 pub fn len(&self) -> usize {
245 self.terminals.len()
246 }
247
248 pub fn is_empty(&self) -> bool {
249 self.terminals.is_empty()
250 }
251}
252
253// ── Mass properties ──────────────────────────────────────────────────────────
254//
255// Closed-form mass / centroid / inertia computations for each [`FaceProfile`].
256// All output quantities are in the world frame (post-transform by `scope`).
257//
258// Profile interpretations (matching the renderer convention):
259// - `Rectangle` — solid box of size (sx, sy, sz).
260// - `Taper(t)` — frustum tapering along local Y; top scaled by (1−t).
261// Mass + centroid only; inertia = `None`.
262// - `Triangle` — vertical XY face panel extruded along Z.
263// - `Trapezoid` — vertical XY face panel extruded along Z.
264// - `Polygon(verts)` — horizontal XZ footprint extruded along Y.
265//
266// Returns `None` when the geometry is degenerate (zero volume / collinear poly /
267// non-finite values) so the caller can leave [`Terminal::mass_properties`]
268// empty rather than producing NaN-laden tensors.
269
270/// Computes [`MassProperties`] for a terminal given its scope, face profile,
271/// and material density. Returns `None` if the geometry is degenerate.
272pub fn compute_mass_properties(
273 scope: &Scope,
274 profile: &FaceProfile,
275 density: f64,
276) -> Option<MassProperties> {
277 if !density.is_finite() || density <= 0.0 {
278 return None;
279 }
280 if !scope.size.is_finite() {
281 return None;
282 }
283 match profile {
284 FaceProfile::Rectangle => box_mass_properties(scope, density),
285 FaceProfile::Taper(t) => taper_mass_properties(scope, *t, density),
286 FaceProfile::Triangle { peak_offset } => {
287 let sx = scope.size.x;
288 let sy = scope.size.y;
289 let verts = vec![
290 glam::DVec2::new(0.0, 0.0),
291 glam::DVec2::new(sx, 0.0),
292 glam::DVec2::new(peak_offset.clamp(0.0, 1.0) * sx, sy),
293 ];
294 prism_mass_properties_xy(&verts, scope, density)
295 }
296 FaceProfile::Trapezoid {
297 top_width,
298 offset_x,
299 } => {
300 let sx = scope.size.x;
301 let sy = scope.size.y;
302 let tw = top_width.clamp(0.0, 1.0);
303 let ox = offset_x.clamp(0.0, 1.0);
304 let verts = vec![
305 glam::DVec2::new(0.0, 0.0),
306 glam::DVec2::new(sx, 0.0),
307 glam::DVec2::new((ox + tw) * sx, sy),
308 glam::DVec2::new(ox * sx, sy),
309 ];
310 prism_mass_properties_xy(&verts, scope, density)
311 }
312 FaceProfile::Polygon(verts) => prism_mass_properties_xz(verts, scope, density),
313 }
314}
315
316/// Solid uniform-density box. Inertia tensor is the standard cuboid formula
317/// transformed into world space via R · I_local · Rᵀ.
318fn box_mass_properties(scope: &Scope, density: f64) -> Option<MassProperties> {
319 let sx = scope.size.x;
320 let sy = scope.size.y;
321 let sz = scope.size.z;
322 if sx <= 0.0 || sy <= 0.0 || sz <= 0.0 {
323 return None;
324 }
325 let volume = sx * sy * sz;
326 if !volume.is_finite() {
327 return None;
328 }
329 let mass = density * volume;
330 if !mass.is_finite() {
331 return None;
332 }
333 let local_centroid = Vec3::new(sx * 0.5, sy * 0.5, sz * 0.5);
334 let centroid = scope.position + scope.rotation * local_centroid;
335 if !centroid.is_finite() {
336 return None;
337 }
338 let i_xx = mass * (sy * sy + sz * sz) / 12.0;
339 let i_yy = mass * (sx * sx + sz * sz) / 12.0;
340 let i_zz = mass * (sx * sx + sy * sy) / 12.0;
341 let i_local = glam::DMat3::from_diagonal(glam::DVec3::new(i_xx, i_yy, i_zz));
342 let inertia = rotate_inertia(i_local, scope.rotation);
343 Some(MassProperties {
344 mass,
345 centroid,
346 inertia: Some(inertia),
347 })
348}
349
350/// Tapered cuboid (frustum) along local Y. Top face scaled by `(1 − t)`.
351/// Mass + centroid only; inertia tensor is left `None`.
352fn taper_mass_properties(scope: &Scope, t: f64, density: f64) -> Option<MassProperties> {
353 let sx = scope.size.x;
354 let sy = scope.size.y;
355 let sz = scope.size.z;
356 if sx <= 0.0 || sy <= 0.0 || sz <= 0.0 {
357 return None;
358 }
359 let r = (1.0 - t.clamp(0.0, 1.0)).max(0.0);
360 // Frustum volume V = sy/3 · (A_bottom + A_top + √(A_bottom·A_top))
361 // = sx·sy·sz/3 · (1 + r² + r)
362 let volume = sx * sy * sz * (1.0 + r * r + r) / 3.0;
363 if !volume.is_finite() || volume <= 0.0 {
364 return None;
365 }
366 let mass = density * volume;
367 if !mass.is_finite() {
368 return None;
369 }
370 // Frustum centroid Y above base = h/4 · (1 + 2r + 3r²) / (1 + r + r²).
371 // For r = 1 (box) → sy/2; for r = 0 (full pyramid) → sy/4.
372 let denom = 1.0 + r + r * r;
373 let cy = if denom > 1e-12 {
374 sy * 0.25 * (1.0 + 2.0 * r + 3.0 * r * r) / denom
375 } else {
376 sy * 0.25
377 };
378 let local_centroid = Vec3::new(sx * 0.5, cy, sz * 0.5);
379 let centroid = scope.position + scope.rotation * local_centroid;
380 if !centroid.is_finite() {
381 return None;
382 }
383 Some(MassProperties {
384 mass,
385 centroid,
386 inertia: None,
387 })
388}
389
390/// Prism whose 2-D cross-section is `verts` in the local XY plane, extruded
391/// along local Z by `scope.size.z`. Used by `Triangle` and `Trapezoid`.
392fn prism_mass_properties_xy(
393 verts: &[glam::DVec2],
394 scope: &Scope,
395 density: f64,
396) -> Option<MassProperties> {
397 let depth = scope.size.z;
398 if depth <= 0.0 {
399 return None;
400 }
401 let m = polygon_moments(verts)?;
402 let volume = m.area * depth;
403 if !volume.is_finite() || volume <= 0.0 {
404 return None;
405 }
406 let mass = density * volume;
407 if !mass.is_finite() {
408 return None;
409 }
410 // Centred polygon second moments: J = ∫(x − Cx)² dA, K = ∫(y − Cy)² dA,
411 // P = ∫(x − Cx)(y − Cy) dA. Per-unit-area normalised: divide by area.
412 let j = (m.ixx_origin - m.area * m.cx * m.cx) / m.area;
413 let k = (m.iyy_origin - m.area * m.cy * m.cy) / m.area;
414 let p = (m.ixy_origin - m.area * m.cx * m.cy) / m.area;
415 let local_centroid = Vec3::new(m.cx, m.cy, depth * 0.5);
416 let centroid = scope.position + scope.rotation * local_centroid;
417 if !centroid.is_finite() {
418 return None;
419 }
420 // Prism inertia about its centroid, axes aligned with scope local frame:
421 // I_xx = m·(d²/12 + ⟨(y−Cy)²⟩) = m·d²/12 + m·k
422 // I_yy = m·d²/12 + m·j
423 // I_zz = m·(j + k)
424 // I_xy = −m·p, I_xz = I_yz = 0 (z is the extrusion axis through centroid)
425 let d2_12 = depth * depth / 12.0;
426 let i_xx = mass * (d2_12 + k);
427 let i_yy = mass * (d2_12 + j);
428 let i_zz = mass * (j + k);
429 let i_xy = -mass * p;
430 let i_local = glam::DMat3::from_cols_array(&[
431 i_xx, i_xy, 0.0, // col 0
432 i_xy, i_yy, 0.0, // col 1
433 0.0, 0.0, i_zz, // col 2
434 ]);
435 let inertia = rotate_inertia(i_local, scope.rotation);
436 Some(MassProperties {
437 mass,
438 centroid,
439 inertia: Some(inertia),
440 })
441}
442
443/// Prism whose 2-D cross-section is `verts` in the local XZ floor plane,
444/// extruded along local Y by `scope.size.y`. Used by `Polygon`.
445fn prism_mass_properties_xz(
446 verts: &[glam::DVec2],
447 scope: &Scope,
448 density: f64,
449) -> Option<MassProperties> {
450 let height = scope.size.y;
451 if height <= 0.0 {
452 return None;
453 }
454 // Polygon vertices interpreted as (X, Z). Re-use the XY moments routine
455 // by reading the second component as "z" and remapping at the end.
456 let m = polygon_moments(verts)?;
457 let volume = m.area * height;
458 if !volume.is_finite() || volume <= 0.0 {
459 return None;
460 }
461 let mass = density * volume;
462 if !mass.is_finite() {
463 return None;
464 }
465 // ⟨(x − Cx)²⟩, ⟨(z − Cz)²⟩, ⟨(x − Cx)(z − Cz)⟩
466 let j = (m.ixx_origin - m.area * m.cx * m.cx) / m.area;
467 let k = (m.iyy_origin - m.area * m.cy * m.cy) / m.area;
468 let p = (m.ixy_origin - m.area * m.cx * m.cy) / m.area;
469 let local_centroid = Vec3::new(m.cx, height * 0.5, m.cy);
470 let centroid = scope.position + scope.rotation * local_centroid;
471 if !centroid.is_finite() {
472 return None;
473 }
474 // Prism along Y through centroid:
475 // I_xx = m·(h²/12 + ⟨(z−Cz)²⟩)
476 // I_yy = m·(⟨(x−Cx)²⟩ + ⟨(z−Cz)²⟩)
477 // I_zz = m·(h²/12 + ⟨(x−Cx)²⟩)
478 // I_xz = −m·p, I_xy = I_yz = 0
479 let h2_12 = height * height / 12.0;
480 let i_xx = mass * (h2_12 + k);
481 let i_yy = mass * (j + k);
482 let i_zz = mass * (h2_12 + j);
483 let i_xz = -mass * p;
484 let i_local = glam::DMat3::from_cols_array(&[
485 i_xx, 0.0, i_xz, // col 0
486 0.0, i_yy, 0.0, // col 1
487 i_xz, 0.0, i_zz, // col 2
488 ]);
489 let inertia = rotate_inertia(i_local, scope.rotation);
490 Some(MassProperties {
491 mass,
492 centroid,
493 inertia: Some(inertia),
494 })
495}
496
497/// 2-D polygon moments via the shoelace formula and its higher-moment variants.
498struct PolygonMoments {
499 area: f64,
500 cx: f64,
501 cy: f64,
502 /// ∫∫ x² dA, about the original axes (not centroidal).
503 ixx_origin: f64,
504 /// ∫∫ y² dA, about the original axes.
505 iyy_origin: f64,
506 /// ∫∫ x·y dA, about the original axes.
507 ixy_origin: f64,
508}
509
510fn polygon_moments(verts: &[glam::DVec2]) -> Option<PolygonMoments> {
511 if verts.len() < 3 {
512 return None;
513 }
514 let n = verts.len();
515 let mut a2 = 0.0_f64; // 2·signed-area
516 let mut cx6 = 0.0_f64;
517 let mut cy6 = 0.0_f64;
518 let mut ixx12 = 0.0_f64;
519 let mut iyy12 = 0.0_f64;
520 let mut ixy24 = 0.0_f64;
521 for i in 0..n {
522 let p = verts[i];
523 let q = verts[(i + 1) % n];
524 let cross = p.x * q.y - q.x * p.y;
525 a2 += cross;
526 // Centroid sums (× 6).
527 cx6 += (p.x + q.x) * cross;
528 cy6 += (p.y + q.y) * cross;
529 // Note: standard polygon-moment formulas yield ∫y² dA and ∫x² dA;
530 // the variable names here track the integrand, not the axis label.
531 ixx12 += (p.y * p.y + p.y * q.y + q.y * q.y) * cross;
532 iyy12 += (p.x * p.x + p.x * q.x + q.x * q.x) * cross;
533 ixy24 += (p.x * q.y + 2.0 * p.x * p.y + 2.0 * q.x * q.y + q.x * p.y) * cross;
534 }
535 let area = a2 * 0.5;
536 if !area.is_finite() || area.abs() < 1e-15 {
537 return None;
538 }
539 let cx = cx6 / (6.0 * area);
540 let cy = cy6 / (6.0 * area);
541 // |area| handles CW vs CCW input — moments must be positive.
542 let abs_area = area.abs();
543 let area_sign = if area >= 0.0 { 1.0 } else { -1.0 };
544 Some(PolygonMoments {
545 area: abs_area,
546 cx,
547 cy,
548 ixx_origin: (ixx12 / 12.0) * area_sign,
549 iyy_origin: (iyy12 / 12.0) * area_sign,
550 ixy_origin: (ixy24 / 24.0) * area_sign,
551 })
552}
553
554/// Transforms a body-frame inertia tensor to world frame: R · I · Rᵀ.
555fn rotate_inertia(i_local: glam::DMat3, rotation: crate::scope::Quat) -> glam::DMat3 {
556 let r = glam::DMat3::from_quat(rotation);
557 r * i_local * r.transpose()
558}