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 /// Occlusion label stamped by `Label("...")` — lets `IfClear`/`IfOccluded`
165 /// and friends test against one named class of terminals only.
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub label: Option<String>,
168}
169
170impl Terminal {
171 pub fn new(scope: Scope, mesh_id: impl Into<String>) -> Self {
172 Self {
173 scope,
174 mesh_id: mesh_id.into(),
175 face_profile: FaceProfile::Rectangle,
176 material: None,
177 mass_properties: None,
178 label: None,
179 }
180 }
181
182 /// Creates a terminal with a legacy taper factor (0 = box, 1 = pyramid).
183 /// Converts the taper into a `FaceProfile` automatically.
184 pub fn new_with_taper(scope: Scope, mesh_id: impl Into<String>, taper: f64) -> Self {
185 let face_profile = taper_to_profile(taper);
186 Self {
187 scope,
188 mesh_id: mesh_id.into(),
189 face_profile,
190 material: None,
191 mass_properties: None,
192 label: None,
193 }
194 }
195
196 /// Creates a terminal with an explicit [`FaceProfile`] and optional material.
197 /// `mass_properties` is automatically computed when the material has a density.
198 pub fn new_profiled(
199 scope: Scope,
200 mesh_id: impl Into<String>,
201 face_profile: FaceProfile,
202 material: Option<Material>,
203 ) -> Self {
204 let mass_properties = material
205 .as_ref()
206 .and_then(|m| m.density)
207 .and_then(|rho| compute_mass_properties(&scope, &face_profile, rho));
208 Self {
209 scope,
210 mesh_id: mesh_id.into(),
211 face_profile,
212 material,
213 mass_properties,
214 label: None,
215 }
216 }
217}
218
219/// Converts a legacy taper coefficient to the closest `FaceProfile`.
220pub fn taper_to_profile(taper: f64) -> FaceProfile {
221 if taper <= 0.0 {
222 FaceProfile::Rectangle
223 } else if (taper - 1.0).abs() < 1e-9 {
224 FaceProfile::Triangle { peak_offset: 0.5 }
225 } else {
226 FaceProfile::Taper(taper.clamp(0.0, 1.0))
227 }
228}
229
230/// The output of a shape grammar derivation.
231///
232/// Contains all terminal nodes produced by the grammar plus any snap planes
233/// recorded along the way (via `RegSnap`).
234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235pub struct ShapeModel {
236 pub terminals: Vec<Terminal>,
237 /// Snap planes accumulated during derivation. See [`SnapPlane`] and the
238 /// `RegSnap` / `Split(snap=...)` grammar ops.
239 pub snap_planes: Vec<SnapPlane>,
240}
241
242impl ShapeModel {
243 pub fn new() -> Self {
244 Self::default()
245 }
246
247 pub fn push(&mut self, terminal: Terminal) {
248 self.terminals.push(terminal);
249 }
250
251 pub fn len(&self) -> usize {
252 self.terminals.len()
253 }
254
255 pub fn is_empty(&self) -> bool {
256 self.terminals.is_empty()
257 }
258}
259
260// ── Mass properties ──────────────────────────────────────────────────────────
261//
262// Closed-form mass / centroid / inertia computations for each [`FaceProfile`].
263// All output quantities are in the world frame (post-transform by `scope`).
264//
265// Profile interpretations (matching the renderer convention):
266// - `Rectangle` — solid box of size (sx, sy, sz).
267// - `Taper(t)` — frustum tapering along local Y; top scaled by (1−t).
268// Mass + centroid only; inertia = `None`.
269// - `Triangle` — vertical XY face panel extruded along Z.
270// - `Trapezoid` — vertical XY face panel extruded along Z.
271// - `Polygon(verts)` — horizontal XZ footprint extruded along Y.
272//
273// Returns `None` when the geometry is degenerate (zero volume / collinear poly /
274// non-finite values) so the caller can leave [`Terminal::mass_properties`]
275// empty rather than producing NaN-laden tensors.
276
277/// Computes [`MassProperties`] for a terminal given its scope, face profile,
278/// and material density. Returns `None` if the geometry is degenerate.
279pub fn compute_mass_properties(
280 scope: &Scope,
281 profile: &FaceProfile,
282 density: f64,
283) -> Option<MassProperties> {
284 if !density.is_finite() || density <= 0.0 {
285 return None;
286 }
287 if !scope.size.is_finite() {
288 return None;
289 }
290 match profile {
291 FaceProfile::Rectangle => box_mass_properties(scope, density),
292 FaceProfile::Taper(t) => taper_mass_properties(scope, *t, density),
293 FaceProfile::Triangle { peak_offset } => {
294 let sx = scope.size.x;
295 let sy = scope.size.y;
296 let verts = vec![
297 glam::DVec2::new(0.0, 0.0),
298 glam::DVec2::new(sx, 0.0),
299 glam::DVec2::new(peak_offset.clamp(0.0, 1.0) * sx, sy),
300 ];
301 prism_mass_properties_xy(&verts, scope, density)
302 }
303 FaceProfile::Trapezoid {
304 top_width,
305 offset_x,
306 } => {
307 let sx = scope.size.x;
308 let sy = scope.size.y;
309 let tw = top_width.clamp(0.0, 1.0);
310 let ox = offset_x.clamp(0.0, 1.0);
311 let verts = vec![
312 glam::DVec2::new(0.0, 0.0),
313 glam::DVec2::new(sx, 0.0),
314 glam::DVec2::new((ox + tw) * sx, sy),
315 glam::DVec2::new(ox * sx, sy),
316 ];
317 prism_mass_properties_xy(&verts, scope, density)
318 }
319 FaceProfile::Polygon(verts) => prism_mass_properties_xz(verts, scope, density),
320 }
321}
322
323/// Solid uniform-density box. Inertia tensor is the standard cuboid formula
324/// transformed into world space via R · I_local · Rᵀ.
325fn box_mass_properties(scope: &Scope, density: f64) -> Option<MassProperties> {
326 let sx = scope.size.x;
327 let sy = scope.size.y;
328 let sz = scope.size.z;
329 if sx <= 0.0 || sy <= 0.0 || sz <= 0.0 {
330 return None;
331 }
332 let volume = sx * sy * sz;
333 if !volume.is_finite() {
334 return None;
335 }
336 let mass = density * volume;
337 if !mass.is_finite() {
338 return None;
339 }
340 let local_centroid = Vec3::new(sx * 0.5, sy * 0.5, sz * 0.5);
341 let centroid = scope.position + scope.rotation * local_centroid;
342 if !centroid.is_finite() {
343 return None;
344 }
345 let i_xx = mass * (sy * sy + sz * sz) / 12.0;
346 let i_yy = mass * (sx * sx + sz * sz) / 12.0;
347 let i_zz = mass * (sx * sx + sy * sy) / 12.0;
348 let i_local = glam::DMat3::from_diagonal(glam::DVec3::new(i_xx, i_yy, i_zz));
349 let inertia = rotate_inertia(i_local, scope.rotation);
350 Some(MassProperties {
351 mass,
352 centroid,
353 inertia: Some(inertia),
354 })
355}
356
357/// Tapered cuboid (frustum) along local Y. Top face scaled by `(1 − t)`.
358/// Mass + centroid only; inertia tensor is left `None`.
359fn taper_mass_properties(scope: &Scope, t: f64, density: f64) -> Option<MassProperties> {
360 let sx = scope.size.x;
361 let sy = scope.size.y;
362 let sz = scope.size.z;
363 if sx <= 0.0 || sy <= 0.0 || sz <= 0.0 {
364 return None;
365 }
366 let r = (1.0 - t.clamp(0.0, 1.0)).max(0.0);
367 // Frustum volume V = sy/3 · (A_bottom + A_top + √(A_bottom·A_top))
368 // = sx·sy·sz/3 · (1 + r² + r)
369 let volume = sx * sy * sz * (1.0 + r * r + r) / 3.0;
370 if !volume.is_finite() || volume <= 0.0 {
371 return None;
372 }
373 let mass = density * volume;
374 if !mass.is_finite() {
375 return None;
376 }
377 // Frustum centroid Y above base = h/4 · (1 + 2r + 3r²) / (1 + r + r²).
378 // For r = 1 (box) → sy/2; for r = 0 (full pyramid) → sy/4.
379 let denom = 1.0 + r + r * r;
380 let cy = if denom > 1e-12 {
381 sy * 0.25 * (1.0 + 2.0 * r + 3.0 * r * r) / denom
382 } else {
383 sy * 0.25
384 };
385 let local_centroid = Vec3::new(sx * 0.5, cy, sz * 0.5);
386 let centroid = scope.position + scope.rotation * local_centroid;
387 if !centroid.is_finite() {
388 return None;
389 }
390 Some(MassProperties {
391 mass,
392 centroid,
393 inertia: None,
394 })
395}
396
397/// Prism whose 2-D cross-section is `verts` in the local XY plane, extruded
398/// along local Z by `scope.size.z`. Used by `Triangle` and `Trapezoid`.
399fn prism_mass_properties_xy(
400 verts: &[glam::DVec2],
401 scope: &Scope,
402 density: f64,
403) -> Option<MassProperties> {
404 let depth = scope.size.z;
405 if depth <= 0.0 {
406 return None;
407 }
408 let m = polygon_moments(verts)?;
409 let volume = m.area * depth;
410 if !volume.is_finite() || volume <= 0.0 {
411 return None;
412 }
413 let mass = density * volume;
414 if !mass.is_finite() {
415 return None;
416 }
417 // Centred polygon second moments: J = ∫(x − Cx)² dA, K = ∫(y − Cy)² dA,
418 // P = ∫(x − Cx)(y − Cy) dA. Per-unit-area normalised: divide by area.
419 let j = (m.ixx_origin - m.area * m.cx * m.cx) / m.area;
420 let k = (m.iyy_origin - m.area * m.cy * m.cy) / m.area;
421 let p = (m.ixy_origin - m.area * m.cx * m.cy) / m.area;
422 let local_centroid = Vec3::new(m.cx, m.cy, depth * 0.5);
423 let centroid = scope.position + scope.rotation * local_centroid;
424 if !centroid.is_finite() {
425 return None;
426 }
427 // Prism inertia about its centroid, axes aligned with scope local frame:
428 // I_xx = m·(d²/12 + ⟨(y−Cy)²⟩) = m·d²/12 + m·k
429 // I_yy = m·d²/12 + m·j
430 // I_zz = m·(j + k)
431 // I_xy = −m·p, I_xz = I_yz = 0 (z is the extrusion axis through centroid)
432 let d2_12 = depth * depth / 12.0;
433 let i_xx = mass * (d2_12 + k);
434 let i_yy = mass * (d2_12 + j);
435 let i_zz = mass * (j + k);
436 let i_xy = -mass * p;
437 let i_local = glam::DMat3::from_cols_array(&[
438 i_xx, i_xy, 0.0, // col 0
439 i_xy, i_yy, 0.0, // col 1
440 0.0, 0.0, i_zz, // col 2
441 ]);
442 let inertia = rotate_inertia(i_local, scope.rotation);
443 Some(MassProperties {
444 mass,
445 centroid,
446 inertia: Some(inertia),
447 })
448}
449
450/// Prism whose 2-D cross-section is `verts` in the local XZ floor plane,
451/// extruded along local Y by `scope.size.y`. Used by `Polygon`.
452fn prism_mass_properties_xz(
453 verts: &[glam::DVec2],
454 scope: &Scope,
455 density: f64,
456) -> Option<MassProperties> {
457 let height = scope.size.y;
458 if height <= 0.0 {
459 return None;
460 }
461 // Polygon vertices interpreted as (X, Z). Re-use the XY moments routine
462 // by reading the second component as "z" and remapping at the end.
463 let m = polygon_moments(verts)?;
464 let volume = m.area * height;
465 if !volume.is_finite() || volume <= 0.0 {
466 return None;
467 }
468 let mass = density * volume;
469 if !mass.is_finite() {
470 return None;
471 }
472 // ⟨(x − Cx)²⟩, ⟨(z − Cz)²⟩, ⟨(x − Cx)(z − Cz)⟩
473 let j = (m.ixx_origin - m.area * m.cx * m.cx) / m.area;
474 let k = (m.iyy_origin - m.area * m.cy * m.cy) / m.area;
475 let p = (m.ixy_origin - m.area * m.cx * m.cy) / m.area;
476 let local_centroid = Vec3::new(m.cx, height * 0.5, m.cy);
477 let centroid = scope.position + scope.rotation * local_centroid;
478 if !centroid.is_finite() {
479 return None;
480 }
481 // Prism along Y through centroid:
482 // I_xx = m·(h²/12 + ⟨(z−Cz)²⟩)
483 // I_yy = m·(⟨(x−Cx)²⟩ + ⟨(z−Cz)²⟩)
484 // I_zz = m·(h²/12 + ⟨(x−Cx)²⟩)
485 // I_xz = −m·p, I_xy = I_yz = 0
486 let h2_12 = height * height / 12.0;
487 let i_xx = mass * (h2_12 + k);
488 let i_yy = mass * (j + k);
489 let i_zz = mass * (h2_12 + j);
490 let i_xz = -mass * p;
491 let i_local = glam::DMat3::from_cols_array(&[
492 i_xx, 0.0, i_xz, // col 0
493 0.0, i_yy, 0.0, // col 1
494 i_xz, 0.0, i_zz, // col 2
495 ]);
496 let inertia = rotate_inertia(i_local, scope.rotation);
497 Some(MassProperties {
498 mass,
499 centroid,
500 inertia: Some(inertia),
501 })
502}
503
504/// 2-D polygon moments via the shoelace formula and its higher-moment variants.
505struct PolygonMoments {
506 area: f64,
507 cx: f64,
508 cy: f64,
509 /// ∫∫ x² dA, about the original axes (not centroidal).
510 ixx_origin: f64,
511 /// ∫∫ y² dA, about the original axes.
512 iyy_origin: f64,
513 /// ∫∫ x·y dA, about the original axes.
514 ixy_origin: f64,
515}
516
517fn polygon_moments(verts: &[glam::DVec2]) -> Option<PolygonMoments> {
518 if verts.len() < 3 {
519 return None;
520 }
521 let n = verts.len();
522 let mut a2 = 0.0_f64; // 2·signed-area
523 let mut cx6 = 0.0_f64;
524 let mut cy6 = 0.0_f64;
525 let mut ixx12 = 0.0_f64;
526 let mut iyy12 = 0.0_f64;
527 let mut ixy24 = 0.0_f64;
528 for i in 0..n {
529 let p = verts[i];
530 let q = verts[(i + 1) % n];
531 let cross = p.x * q.y - q.x * p.y;
532 a2 += cross;
533 // Centroid sums (× 6).
534 cx6 += (p.x + q.x) * cross;
535 cy6 += (p.y + q.y) * cross;
536 // Note: standard polygon-moment formulas yield ∫y² dA and ∫x² dA;
537 // the variable names here track the integrand, not the axis label.
538 ixx12 += (p.y * p.y + p.y * q.y + q.y * q.y) * cross;
539 iyy12 += (p.x * p.x + p.x * q.x + q.x * q.x) * cross;
540 ixy24 += (p.x * q.y + 2.0 * p.x * p.y + 2.0 * q.x * q.y + q.x * p.y) * cross;
541 }
542 let area = a2 * 0.5;
543 if !area.is_finite() || area.abs() < 1e-15 {
544 return None;
545 }
546 let cx = cx6 / (6.0 * area);
547 let cy = cy6 / (6.0 * area);
548 // |area| handles CW vs CCW input — moments must be positive.
549 let abs_area = area.abs();
550 let area_sign = if area >= 0.0 { 1.0 } else { -1.0 };
551 Some(PolygonMoments {
552 area: abs_area,
553 cx,
554 cy,
555 ixx_origin: (ixx12 / 12.0) * area_sign,
556 iyy_origin: (iyy12 / 12.0) * area_sign,
557 ixy_origin: (ixy24 / 24.0) * area_sign,
558 })
559}
560
561/// Transforms a body-frame inertia tensor to world frame: R · I · Rᵀ.
562fn rotate_inertia(i_local: glam::DMat3, rotation: crate::scope::Quat) -> glam::DMat3 {
563 let r = glam::DMat3::from_quat(rotation);
564 r * i_local * r.transpose()
565}