Skip to main content

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}