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}
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}