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}