symbios_shape/ops.rs
1use serde::{Deserialize, Serialize};
2
3use crate::model::Material;
4use crate::scope::{Quat, Vec3};
5
6/// Optional snap-binding attached to a `Split` op.
7///
8/// When set, after the slot sizes are resolved the interior boundaries are
9/// snapped to the nearest registered snap-plane along the split axis carrying
10/// the matching `label`, provided the snap-plane lies within `tolerance`
11/// world-space units of the resolved boundary. Slots on either side of a
12/// snapped boundary stretch / shrink to absorb the offset; total scope
13/// length is preserved.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct SnapBinding {
16 /// Group label of snap-planes to align to (matches `RegSnap("label")`).
17 pub label: String,
18 /// Maximum world-space distance between a slot boundary and a snap-plane
19 /// for the snap to apply. When `None`, defaults to `5%` of the split-axis
20 /// scope length at interpret time.
21 pub tolerance: Option<f64>,
22}
23
24/// The axis along which a `Split` or `Repeat` operation acts.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum Axis {
27 X,
28 Y,
29 Z,
30}
31
32/// Sizing mode for a single slot within a `Split` operation.
33///
34/// Mirrors CityEngine CGA syntax:
35/// - `Absolute(n)`: a fixed world-unit size.
36/// - `Relative(t)`: a fraction `t` of the scope's total dimension (prefix `'` in CGA text).
37/// - `Floating(n)`: a weight that shares the remaining space after absolutes are consumed
38/// (prefix `~` in CGA text). Multiple floating slots divide the remainder proportionally.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub enum SplitSize {
41 Absolute(f64),
42 Relative(f64),
43 Floating(f64),
44}
45
46impl SplitSize {
47 pub fn is_valid(&self) -> bool {
48 match self {
49 SplitSize::Absolute(v) | SplitSize::Relative(v) | SplitSize::Floating(v) => {
50 v.is_finite() && *v > 0.0
51 }
52 }
53 }
54}
55
56/// A single slot in a `Split` operation: a size mode paired with a successor rule name.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct SplitSlot {
59 pub size: SplitSize,
60 /// The name of the shape rule to invoke on the resulting child scope.
61 pub rule: String,
62}
63
64/// Face selectors for the `Comp(Faces)` decomposition.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub enum FaceSelector {
67 Top,
68 Bottom,
69 Front,
70 Back,
71 Left,
72 Right,
73 /// Matches all non-top, non-bottom faces (shorthand for the four sides).
74 Side,
75 /// Matches all faces not otherwise mapped.
76 All,
77}
78
79impl FaceSelector {
80 pub fn parse(s: &str) -> Option<Self> {
81 match s {
82 "top" | "Top" => Some(Self::Top),
83 "bottom" | "Bottom" => Some(Self::Bottom),
84 "front" | "Front" => Some(Self::Front),
85 "back" | "Back" => Some(Self::Back),
86 "left" | "Left" => Some(Self::Left),
87 "right" | "Right" => Some(Self::Right),
88 "side" | "Side" => Some(Self::Side),
89 "all" | "All" | "_" => Some(Self::All),
90 _ => None,
91 }
92 }
93}
94
95/// A single mapping in a `Comp(Faces)` block: a face selector → rule name.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct CompFaceCase {
98 pub selector: FaceSelector,
99 pub rule: String,
100}
101
102/// The decomposition target for a `Comp` operation.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub enum CompTarget {
105 /// Decomposes the volume into its six axis-aligned face scopes.
106 Faces(Vec<CompFaceCase>),
107}
108
109/// Face selectors for the `Offset` operation.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
111pub enum OffsetSelector {
112 /// The inset/outset region (the area inside the border).
113 Inside,
114 /// The surrounding border strips.
115 Border,
116 /// Matches any selector not otherwise mapped.
117 All,
118}
119
120impl OffsetSelector {
121 pub fn parse(s: &str) -> Option<Self> {
122 match s {
123 "inside" | "Inside" => Some(Self::Inside),
124 "border" | "Border" => Some(Self::Border),
125 "all" | "All" | "_" => Some(Self::All),
126 _ => None,
127 }
128 }
129}
130
131/// A single mapping in an `Offset` block: a selector → rule name.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct OffsetCase {
134 pub selector: OffsetSelector,
135 pub rule: String,
136}
137
138/// Roof shape types for the `Roof` operation.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140pub enum RoofType {
141 // ── Original types ────────────────────────────────────────────────────────
142 /// Four triangular slope panels meeting at a single apex.
143 Pyramid,
144 /// Single slope panel from front eave to back eave.
145 Shed,
146 /// Two slope panels meeting at a horizontal ridge; two triangular gable ends.
147 Gable,
148 /// Four trapezoidal slope panels meeting at a horizontal ridge.
149 Hip,
150
151 // ── New types ─────────────────────────────────────────────────────────────
152 /// Flat horizontal roof — a single horizontal panel covering the scope top.
153 Flat,
154 /// Two rectangular slope panels only (Gable without the triangular end panels).
155 OpenGable,
156 /// Two slope panels + two rectangular (non-tapered) gable-end wall panels.
157 BoxGable,
158 /// Four panels from a rectangular base meeting at a single apex point (no ridge).
159 /// Equivalent to `Pyramid` for square footprints; left/right end panels are triangular.
160 PyramidHip,
161 /// Two inward-tilting slopes forming a central valley (inverted Gable).
162 Butterfly,
163 /// Four panels forming two parallel ridges with a central valley between them (M profile).
164 MShaped,
165 /// Two pitches per slope: steeper lower zone + shallower upper zone (barn roof).
166 /// Requires `secondary_pitch` in `RoofConfig`.
167 Gambrel,
168 /// Gambrel applied to all four sides: 4 steep lower panels + 4 shallow upper panels.
169 /// Requires `secondary_pitch` in `RoofConfig`.
170 Mansard,
171 /// Asymmetric Gable: the ridge is offset toward one end (`ridge_offset` in `RoofConfig`).
172 /// Front slope is steeper; back slope is shallower. Gable ends are asymmetric triangles.
173 Saltbox,
174 /// Gable with clipped hip ends: the upper corners of each gable end are replaced by
175 /// small triangular hip panels. Controlled by `tier_height` in `RoofConfig`.
176 Jerkinhead,
177 /// Hip roof with a small gable rising from the ridge centre.
178 /// Controlled by `tier_height` (fraction of slope from base where the gable starts).
179 DutchGable,
180}
181
182impl RoofType {
183 pub fn parse(s: &str) -> Option<Self> {
184 match s {
185 "pyramid" | "Pyramid" => Some(Self::Pyramid),
186 "shed" | "Shed" => Some(Self::Shed),
187 "gable" | "Gable" => Some(Self::Gable),
188 "hip" | "Hip" => Some(Self::Hip),
189 "flat" | "Flat" => Some(Self::Flat),
190 "openGable" | "OpenGable" => Some(Self::OpenGable),
191 "boxGable" | "BoxGable" => Some(Self::BoxGable),
192 "pyramidHip" | "PyramidHip" => Some(Self::PyramidHip),
193 "butterfly" | "Butterfly" => Some(Self::Butterfly),
194 "mShaped" | "MShaped" => Some(Self::MShaped),
195 "gambrel" | "Gambrel" => Some(Self::Gambrel),
196 "mansard" | "Mansard" => Some(Self::Mansard),
197 "saltbox" | "Saltbox" => Some(Self::Saltbox),
198 "jerkinhead" | "Jerkinhead" => Some(Self::Jerkinhead),
199 "dutchGable" | "DutchGable" => Some(Self::DutchGable),
200 _ => None,
201 }
202 }
203}
204
205/// Face selectors for the `Roof` operation.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
207pub enum RoofFaceSelector {
208 /// The main sloped panel(s) — front/back in most roof types.
209 Slope,
210 /// The triangular vertical end panels of a Gable or Saltbox roof.
211 GableEnd,
212 /// The steeper, lower zone of a Gambrel or Mansard roof.
213 LowerSlope,
214 /// The shallower, upper zone of a Gambrel or Mansard roof.
215 UpperSlope,
216 /// The small triangular hip panels at the clipped ends of a Jerkinhead roof.
217 HipEnd,
218 /// The inward-facing slopes of a Butterfly or MShaped valley.
219 ValleySlope,
220 /// The outer slopes of an MShaped roof (facing away from the valley).
221 OuterSlope,
222 /// The inner slopes of an MShaped roof (facing toward the valley).
223 InnerSlope,
224 /// Vertical fascia bands hanging below the eaves.
225 /// Generated when [`RoofConfig::fascia_depth`] is `> 0`. One panel per perimeter
226 /// slope eave; supported for all roof types whose slope panels share a horizontal
227 /// eave at the perimeter (Gable, Hip, Pyramid, Shed, Saltbox, Jerkinhead, DutchGable,
228 /// Gambrel, Mansard, MShaped, BoxGable, OpenGable, PyramidHip). `Flat` and `Butterfly`
229 /// have no perimeter eave at the slope-panel level and produce no fascia panels.
230 Fascia,
231 /// Matches any selector not otherwise mapped.
232 All,
233}
234
235impl RoofFaceSelector {
236 pub fn parse(s: &str) -> Option<Self> {
237 match s {
238 "slope" | "Slope" => Some(Self::Slope),
239 "gable" | "GableEnd" | "gableEnd" => Some(Self::GableEnd),
240 "lowerSlope" | "LowerSlope" => Some(Self::LowerSlope),
241 "upperSlope" | "UpperSlope" => Some(Self::UpperSlope),
242 "hipEnd" | "HipEnd" => Some(Self::HipEnd),
243 "valleySlope" | "ValleySlope" => Some(Self::ValleySlope),
244 "outerSlope" | "OuterSlope" => Some(Self::OuterSlope),
245 "innerSlope" | "InnerSlope" => Some(Self::InnerSlope),
246 "fascia" | "Fascia" => Some(Self::Fascia),
247 "all" | "All" | "_" => Some(Self::All),
248 _ => None,
249 }
250 }
251}
252
253/// A single mapping in a `Roof` block: a face selector → rule name.
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255pub struct RoofCase {
256 pub selector: RoofFaceSelector,
257 pub rule: String,
258}
259
260/// Rich parametric configuration for the `Roof` operation.
261///
262/// All angular values are in degrees. Lengths are in world units.
263/// Optional fields default as described; see each field doc.
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct RoofConfig {
266 pub roof_type: RoofType,
267 /// Primary pitch angle in degrees from horizontal. Must be in (0°, 90°).
268 pub pitch: f64,
269 /// Secondary pitch angle in degrees. Used by `Gambrel` (upper zone) and `Mansard`.
270 /// If `None` when required, defaults to `pitch / 2`.
271 pub secondary_pitch: Option<f64>,
272 /// Extra overhang beyond the scope footprint on each side. Default `0.0`.
273 pub overhang: f64,
274 /// Ridge offset for `Saltbox`: fraction [0, 1] of the scope depth (Z) where the
275 /// ridge is positioned from the front. Default `0.5` (symmetric / centred ridge).
276 pub ridge_offset: f64,
277 /// Thickness of the roof fascia edge in world units. Default `0.0` (flat panels).
278 pub fascia_depth: f64,
279 /// Normalised height at which the pitch break occurs for `Gambrel`, `Mansard`,
280 /// `Jerkinhead`, and `DutchGable`. `0.5` means the break is at half the eave-to-ridge
281 /// distance. `None` uses a type-specific default.
282 pub tier_height: Option<f64>,
283}
284
285impl RoofConfig {
286 /// Creates a minimal config for deterministic types (Pyramid, Shed, Gable, Hip, Flat, …).
287 pub fn new(roof_type: RoofType, pitch: f64) -> Self {
288 Self {
289 roof_type,
290 pitch,
291 secondary_pitch: None,
292 overhang: 0.0,
293 ridge_offset: 0.5,
294 fascia_depth: 0.0,
295 tier_height: None,
296 }
297 }
298
299 /// Returns the secondary pitch, defaulting to `pitch / 2` if unset.
300 pub fn secondary_pitch_or_default(&self) -> f64 {
301 self.secondary_pitch.unwrap_or(self.pitch / 2.0)
302 }
303
304 /// Returns the tier height, defaulting to `default` if unset.
305 pub fn tier_height_or(&self, default: f64) -> f64 {
306 self.tier_height.unwrap_or(default)
307 }
308}
309
310/// Selector for the `Attach` operation.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
312pub enum AttachSelector {
313 /// The projected scope that sits on (or comes out of) the surface.
314 Surface,
315 /// Matches any selector not otherwise mapped.
316 All,
317}
318
319impl AttachSelector {
320 pub fn parse(s: &str) -> Option<Self> {
321 match s {
322 "surface" | "Surface" => Some(Self::Surface),
323 "all" | "All" | "_" => Some(Self::All),
324 _ => None,
325 }
326 }
327}
328
329/// A single mapping in an `Attach` block: a selector → rule name.
330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
331pub struct AttachCase {
332 pub selector: AttachSelector,
333 pub rule: String,
334}
335
336/// The atomic CGA operations that the interpreter executes.
337///
338/// Every operation transforms the current `Scope` into zero or more child scopes,
339/// each tagged with a rule name that will be recursively evaluated.
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341pub enum ShapeOp {
342 /// Lifts a 2-D footprint (XZ plane) into a 3-D volume by setting the Y size.
343 Extrude(f64),
344
345 /// Pyramidal taper: scales the top face toward the centroid.
346 /// `amount` ∈ `[0, 1]`: 0 = no taper, 1 = full pyramid (top collapses to a point).
347 Taper(f64),
348
349 /// Applies an additional rotation to the scope (cumulative with existing rotation).
350 Rotate(Quat),
351
352 /// Translates the scope origin in local space.
353 Translate(Vec3),
354
355 /// Scales the scope size along each axis (multiplicative).
356 Scale(Vec3),
357
358 /// Divides the scope along `axis` into ordered slots.
359 ///
360 /// When `snap` is `Some`, interior slot boundaries are snapped to the
361 /// nearest registered snap-plane along `axis` (see [`SnapBinding`]).
362 Split {
363 axis: Axis,
364 slots: Vec<SplitSlot>,
365 snap: Option<SnapBinding>,
366 },
367
368 /// Tiles the scope along `axis` with child scopes drawn from `tile_sizes`.
369 ///
370 /// `tile_sizes` is a per-slot pattern that is cycled to fill the axis range.
371 /// Tiles are appended greedily (next tile from the cycle is added while it
372 /// still fits inside the remaining range), then **all** placed tiles are
373 /// scaled by the same factor `total / Σ(placed)` so they fill the scope
374 /// exactly with no gap and no overshoot.
375 ///
376 /// A single-element list `[t]` is the legacy uniform `Repeat(axis, t)`.
377 /// A multi-element list `[a, b, c]` produces an `…, a, b, c, a, b, c, …`
378 /// cadence where each tile's relative width is preserved.
379 Repeat {
380 axis: Axis,
381 tile_sizes: Vec<f64>,
382 rule: String,
383 },
384
385 /// Decomposes the scope into its geometric components (faces, edges, vertices).
386 Comp(CompTarget),
387
388 /// Terminal: replace the scope with the named mesh asset.
389 /// This is the "terminal symbol" — produces a `Terminal` node in the output model.
390 I(String),
391
392 /// Sets the material on the current work item.
393 /// The material is propagated to the final `Terminal`, allowing downstream
394 /// renderers to apply textures / shaders and physics consumers to derive
395 /// volumetric mass properties without changing the scope.
396 ///
397 /// Syntax:
398 /// - `Mat("Brick")` / `Mat(Brick)` — id-only material; no density.
399 /// - `Mat("Brick", 1800)` — id + density in kg/m³; the interpreter computes
400 /// [`crate::model::MassProperties`] for terminals stamped with this material.
401 Mat(Material),
402
403 /// Calls a named sub-rule on the current scope unchanged.
404 /// Used for grammar rule references that don't transform the scope themselves.
405 Rule(String),
406
407 /// Rotates the scope so that the specified local axis points in the given world direction.
408 ///
409 /// Applies the shortest-arc rotation from the current world direction of `local_axis`
410 /// to `target`. Useful for recovering from accumulated rotations.
411 /// Syntax: `Align(Y, Up)`, `Align(Z, Forward)`, etc.
412 /// Named targets: `Up`=(0,1,0), `Down`=(0,-1,0), `Right`=(1,0,0), `Left`=(-1,0,0),
413 /// `Forward`=(0,0,-1), `Back`=(0,0,1).
414 Align { local_axis: Axis, target: Vec3 },
415
416 /// Creates an inset (`distance < 0`) frame on a 2D face scope.
417 ///
418 /// Produces up to two kinds of child scopes:
419 /// - `Inside`: the inset rectangle.
420 /// - `Border`: four surrounding strips (bottom, top, left, right), each invoking the same rule.
421 ///
422 /// Syntax: `Offset(-0.2) { Inside: Glass | Border: Frame }`
423 Offset {
424 distance: f64,
425 cases: Vec<OffsetCase>,
426 },
427
428 /// Generates a roof structure above the current scope using rich parametric configuration.
429 ///
430 /// Operates on a volume scope. The `config` contains the roof type, primary pitch angle,
431 /// optional secondary pitch, overhang, ridge offset, fascia depth, and tier height.
432 ///
433 /// Syntax examples:
434 /// - `Roof(Gable, 30) { Slope: Tiles | GableEnd: Bricks }` — basic Gable
435 /// - `Roof(Hip, 30, 0.5) { Slope: Tiles }` — Hip with overhang
436 /// - `Roof(Gambrel, 45, 20) { LowerSlope: Shingles | UpperSlope: Tiles }` — Gambrel
437 /// - `Roof(Saltbox, 45, offset=0.3) { Slope: Tiles | GableEnd: Bricks }` — Saltbox
438 /// - `Roof(DutchGable, 45, tier=0.7) { Slope: Tiles | GableEnd: Bricks }` — Dutch Gable
439 Roof {
440 config: RoofConfig,
441 cases: Vec<RoofCase>,
442 },
443
444 /// Registers all six face planes of the current scope as snap-planes
445 /// under the given label. Subsequent `Split(snap="label")` ops can align
446 /// their interior boundaries to these planes. Read-only with respect to
447 /// the scope (the scope itself passes through unchanged).
448 ///
449 /// Syntax: `RegSnap("bays")`
450 RegSnap(String),
451
452 /// Conditionally invokes `rule` on the current scope only when no
453 /// already-emitted terminal occludes the scope (true OBB overlap test).
454 /// Useful for placing decorative elements that should only appear where
455 /// no structural element has already been placed.
456 ///
457 /// The grammar author is responsible for ordering — terminals derived
458 /// before this op participate in the test; later terminals do not.
459 ///
460 /// Syntax: `IfClear { Window }`
461 IfClear { rule: String },
462
463 /// Inverse of [`ShapeOp::IfClear`]: invokes `rule` only when the current
464 /// scope **is** occluded by an already-emitted terminal.
465 ///
466 /// Syntax: `IfOccluded { Patch }`
467 IfOccluded { rule: String },
468
469 /// Stamps an explicit polygonal `FaceProfile` on the next terminal in this rule.
470 ///
471 /// Mirrors how [`ShapeOp::Taper`] sets a profile override: the next `I(...)`
472 /// (or implicit terminal) emits a `Terminal` whose `face_profile` is
473 /// [`crate::model::FaceProfile::Polygon`] with the provided vertex list.
474 /// Vertices are 2-D points in the scope's local floor plane (XZ), measured
475 /// in world units from the scope origin; the renderer triangulates and
476 /// extrudes along the local Y axis.
477 ///
478 /// Syntax: `Polygon((0,0), (4,0), (4,2), (2,2), (2,4), (0,4))`
479 /// (variadic `(x,y)` list, capped at 256 vertices for parser DoS hardening).
480 Polygon(Vec<glam::DVec2>),
481
482 /// Projects a new horizontal scope out of a sloped face for attaching dormers or details.
483 ///
484 /// `world_axis` defines the "up" direction for the attached scope (usually world Y).
485 /// The resulting scope sits on the face's surface with its Y axis aligned to `world_axis`,
486 /// inheriting the face's width and height but with depth = 0.
487 ///
488 /// Syntax: `Attach(Up) { Surface: DormerMass }`
489 Attach {
490 world_axis: Vec3,
491 cases: Vec<AttachCase>,
492 },
493}