Skip to main content

symbios_shape/
model.rs

1use serde::{Deserialize, Serialize};
2
3use crate::scope::Scope;
4
5/// The 2D profile shape of a terminal face, used by renderers to construct geometry.
6///
7/// Replaces the old `taper: f64` field. Each variant describes the cross-sectional
8/// outline of the face panel within the scope's local XY plane:
9/// - X runs from 0 (left edge) to `scope.size.x` (right edge).
10/// - Y runs from 0 (bottom edge) to `scope.size.y` (top edge).
11///
12/// The renderer extrudes this 2D outline along the local Z axis by `scope.size.z`
13/// (which is 0 for flat face panels produced by `Roof` and `Comp(Faces)`).
14///
15/// Coordinate convention for `Trapezoid` and `Triangle`: values are **normalized**
16/// (0.0 = left/bottom, 1.0 = right/top) so they are independent of the actual scope size.
17/// The renderer scales them by `scope.size.x` before vertex generation.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub enum FaceProfile {
20    /// Full rectangular face — the default for walls, floors, and generic volumes.
21    Rectangle,
22
23    /// Legacy tapered prism, equivalent to the old `taper` field.
24    ///
25    /// `t` ∈ `[0, 1]`: 0 = box, 1 = full pyramid. The renderer maps this
26    /// to the existing `build_tapered_cuboid` path for backward compatibility.
27    Taper(f64),
28
29    /// Triangular face: base at Y = 0 (full scope width), apex at Y = scope.size.y.
30    ///
31    /// `peak_offset` ∈ `[0, 1]`: horizontal offset of the apex from the left edge,
32    /// normalized to the scope width. `0.5` = symmetric triangle (standard gable).
33    /// `0.3` = apex shifted left (asymmetric, used by Saltbox gable ends).
34    Triangle { peak_offset: f64 },
35
36    /// Trapezoidal face: rectangular base at Y = 0, narrower top edge at Y = scope.size.y.
37    ///
38    /// Both values are normalized to the scope width (`scope.size.x = 1.0`):
39    /// - `top_width` ∈ `[0, 1]`: width of the top edge as a fraction of the base width.
40    /// - `offset_x` ∈ `[0, 1]`: left indent of the top edge from the scope left.
41    ///
42    /// Invariant: `offset_x + top_width ≤ 1.0`.
43    /// A symmetric trapezoid has `offset_x = (1 - top_width) / 2`.
44    Trapezoid { top_width: f64, offset_x: f64 },
45
46    /// Arbitrary convex or concave polygon, produced by the straight skeleton algorithm
47    /// for complex (L-shaped, T-shaped, etc.) building footprints.
48    ///
49    /// Vertices are in the scope's local XZ (floor) plane, measured in world units
50    /// from the scope origin. The renderer triangulates this polygon and extrudes it
51    /// along the local Y axis by the roof pitch height.
52    Polygon(Vec<glam::DVec2>),
53}
54
55impl FaceProfile {
56    /// Returns `true` if the profile is the default `Rectangle` shape.
57    pub fn is_rectangle(&self) -> bool {
58        matches!(self, Self::Rectangle)
59    }
60
61    /// Returns the legacy taper coefficient if this profile was set by `ShapeOp::Taper`.
62    pub fn taper_coeff(&self) -> Option<f64> {
63        match self {
64            Self::Taper(t) => Some(*t),
65            Self::Rectangle => Some(0.0),
66            _ => None,
67        }
68    }
69}
70
71/// A fully-resolved terminal node in the shape model.
72///
73/// Represents a concrete mesh instance placed at the given `scope`.
74/// The `mesh_id` identifies which asset to spawn (e.g. `"Window"`, `"Door"`, `"Pillar"`).
75/// `face_profile` describes the 2D cross-section shape of this terminal (replaces the old `taper`).
76/// `material`: optional material/texture identifier set by `Mat(...)` operations.
77/// This is the "DOM" that Bevy (or any renderer) reads to spawn entities.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct Terminal {
80    pub scope: Scope,
81    pub mesh_id: String,
82    /// 2D face profile describing the cross-sectional shape of this terminal.
83    /// Replaces the old `taper: f64` field. Use [`FaceProfile::taper_coeff`] to
84    /// obtain a legacy taper coefficient for backward-compatible renderers.
85    pub face_profile: FaceProfile,
86    /// Optional material identifier stamped by a `Mat("...")` operation.
87    pub material: Option<String>,
88}
89
90impl Terminal {
91    pub fn new(scope: Scope, mesh_id: impl Into<String>) -> Self {
92        Self {
93            scope,
94            mesh_id: mesh_id.into(),
95            face_profile: FaceProfile::Rectangle,
96            material: None,
97        }
98    }
99
100    /// Creates a terminal with a legacy taper factor (0 = box, 1 = pyramid).
101    /// Converts the taper into a `FaceProfile` automatically.
102    pub fn new_with_taper(scope: Scope, mesh_id: impl Into<String>, taper: f64) -> Self {
103        let face_profile = taper_to_profile(taper);
104        Self {
105            scope,
106            mesh_id: mesh_id.into(),
107            face_profile,
108            material: None,
109        }
110    }
111
112    pub fn new_full(
113        scope: Scope,
114        mesh_id: impl Into<String>,
115        taper: f64,
116        material: Option<String>,
117    ) -> Self {
118        Self {
119            scope,
120            mesh_id: mesh_id.into(),
121            face_profile: taper_to_profile(taper),
122            material,
123        }
124    }
125
126    /// Creates a terminal with an explicit `FaceProfile`.
127    pub fn new_profiled(
128        scope: Scope,
129        mesh_id: impl Into<String>,
130        face_profile: FaceProfile,
131        material: Option<String>,
132    ) -> Self {
133        Self {
134            scope,
135            mesh_id: mesh_id.into(),
136            face_profile,
137            material,
138        }
139    }
140}
141
142/// Converts a legacy taper coefficient to the closest `FaceProfile`.
143pub fn taper_to_profile(taper: f64) -> FaceProfile {
144    if taper <= 0.0 {
145        FaceProfile::Rectangle
146    } else if (taper - 1.0).abs() < 1e-9 {
147        FaceProfile::Triangle { peak_offset: 0.5 }
148    } else {
149        FaceProfile::Taper(taper.clamp(0.0, 1.0))
150    }
151}
152
153/// The output of a shape grammar derivation.
154///
155/// Contains all terminal nodes produced by the grammar, ready for rendering.
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct ShapeModel {
158    pub terminals: Vec<Terminal>,
159}
160
161impl ShapeModel {
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    pub fn push(&mut self, terminal: Terminal) {
167        self.terminals.push(terminal);
168    }
169
170    pub fn len(&self) -> usize {
171        self.terminals.len()
172    }
173
174    pub fn is_empty(&self) -> bool {
175        self.terminals.is_empty()
176    }
177}