Skip to main content

symbios_shape/
ops.rs

1use serde::{Deserialize, Serialize};
2
3use crate::expr::Expr;
4use crate::model::Material;
5use crate::scope::Vec3;
6
7/// A reference to a production rule, optionally carrying call arguments.
8///
9/// Every successor position in the grammar — bare rule ops, split slots,
10/// `Comp` / `Offset` / `Roof` / `Attach` cases, occlusion conditionals — is a
11/// `RuleCall`. Arguments are expressions evaluated in the *calling* shape's
12/// context at push time; the callee binds the resulting values to its
13/// declared parameter names (see `Interpreter::add_rule_def`).
14///
15/// ```text
16/// Spire(4)                       // bare call with one argument
17/// Split(Y) { 3: Base | ~1: Tier(depth + 1) }
18/// ```
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct RuleCall {
21    pub name: String,
22    /// Call arguments; empty for plain references. Skipped in serde when
23    /// empty so pre-0.3 serialized ops round-trip unchanged.
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub args: Vec<Expr>,
26}
27
28impl RuleCall {
29    /// Plain, argument-less reference.
30    pub fn new(name: impl Into<String>) -> Self {
31        Self {
32            name: name.into(),
33            args: Vec::new(),
34        }
35    }
36
37    /// Reference with call arguments.
38    pub fn with_args(name: impl Into<String>, args: Vec<Expr>) -> Self {
39        Self {
40            name: name.into(),
41            args,
42        }
43    }
44}
45
46impl From<&str> for RuleCall {
47    fn from(name: &str) -> Self {
48        Self::new(name)
49    }
50}
51
52/// Argument-less calls compare equal to their bare name — keeps assertions
53/// and look-ups terse (`assert_eq!(slot.rule, "Floor")`). A call *with*
54/// arguments never equals a bare name.
55impl PartialEq<&str> for RuleCall {
56    fn eq(&self, other: &&str) -> bool {
57        self.args.is_empty() && self.name == *other
58    }
59}
60
61impl PartialEq<str> for RuleCall {
62    fn eq(&self, other: &str) -> bool {
63        self.args.is_empty() && self.name == other
64    }
65}
66
67impl From<String> for RuleCall {
68    fn from(name: String) -> Self {
69        Self::new(name)
70    }
71}
72
73/// Optional snap-binding attached to a `Split` op.
74///
75/// When set, after the slot sizes are resolved the interior boundaries are
76/// snapped to the nearest registered snap-plane along the split axis carrying
77/// the matching `label`, provided the snap-plane lies within `tolerance`
78/// world-space units of the resolved boundary. Slots on either side of a
79/// snapped boundary stretch / shrink to absorb the offset; total scope
80/// length is preserved.
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct SnapBinding {
83    /// Group label of snap-planes to align to (matches `RegSnap("label")`).
84    pub label: String,
85    /// Maximum world-space distance between a slot boundary and a snap-plane
86    /// for the snap to apply. When `None`, defaults to `5%` of the split-axis
87    /// scope length at interpret time.
88    pub tolerance: Option<f64>,
89}
90
91/// The axis along which a `Split` or `Repeat` operation acts.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93pub enum Axis {
94    X,
95    Y,
96    Z,
97}
98
99/// Sizing mode for a single slot within a `Split` operation.
100///
101/// Mirrors CityEngine CGA syntax:
102/// - `Absolute(e)`: a fixed world-unit size.
103/// - `Relative(e)`: a fraction of the scope's total dimension (prefix `'` in CGA text).
104/// - `Floating(e)`: a weight that shares the remaining space after absolutes are consumed
105///   (prefix `~` in CGA text). Multiple floating slots divide the remainder proportionally.
106///
107/// Sizes are [`Expr`]s evaluated per shape at derivation time; validation
108/// (finite, positive) happens on the evaluated value in the interpreter.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub enum SplitSize {
111    Absolute(Expr),
112    Relative(Expr),
113    Floating(Expr),
114}
115
116impl SplitSize {
117    /// Convenience constructors for literal sizes (tests, programmatic use).
118    pub fn abs(v: f64) -> Self {
119        SplitSize::Absolute(Expr::lit(v))
120    }
121    pub fn rel(v: f64) -> Self {
122        SplitSize::Relative(Expr::lit(v))
123    }
124    pub fn float(v: f64) -> Self {
125        SplitSize::Floating(Expr::lit(v))
126    }
127
128    /// The size expression, whatever the mode.
129    pub fn expr(&self) -> &Expr {
130        match self {
131            SplitSize::Absolute(e) | SplitSize::Relative(e) | SplitSize::Floating(e) => e,
132        }
133    }
134
135    /// Mutable access to the size expression (genetics mutation hook).
136    pub fn expr_mut(&mut self) -> &mut Expr {
137        match self {
138            SplitSize::Absolute(e) | SplitSize::Relative(e) | SplitSize::Floating(e) => e,
139        }
140    }
141}
142
143/// A single slot in a `Split` operation: a size mode paired with a successor rule call.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct SplitSlot {
146    pub size: SplitSize,
147    /// The shape rule invoked on the resulting child scope.
148    pub rule: RuleCall,
149}
150
151/// One entry in a `Split` body: a single slot, or a rhythm group
152/// `{ a | b }*` whose slot pattern repeats to fill the space left by the
153/// entries outside it.
154///
155/// ```text
156/// Split(X) { 1.2: Corner | { 0.5: Pier | ~1: Win }* | 1.2: Corner }
157/// ```
158///
159/// Constraints (enforced at parse / derivation): at most **one** group per
160/// split, no nested groups. Allocation: fixed entries outside the group are
161/// placed first; the group tiles `k` whole copies of its nominal width into
162/// the remainder; leftover space goes to floating slots outside the group,
163/// or — when there are none — the copies stretch uniformly to close the gap
164/// exactly. Sizes inside a copy resolve like a mini-split of the copy width.
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub enum SplitEntry {
167    Slot(SplitSlot),
168    Group(Vec<SplitSlot>),
169}
170
171impl SplitEntry {
172    /// The single slot, when this entry is not a group.
173    pub fn as_slot(&self) -> Option<&SplitSlot> {
174        match self {
175            SplitEntry::Slot(s) => Some(s),
176            SplitEntry::Group(_) => None,
177        }
178    }
179}
180
181impl From<SplitSlot> for SplitEntry {
182    fn from(s: SplitSlot) -> Self {
183        SplitEntry::Slot(s)
184    }
185}
186
187/// Face selectors for the `Comp(Faces)` decomposition.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
189pub enum FaceSelector {
190    Top,
191    Bottom,
192    Front,
193    Back,
194    Left,
195    Right,
196    /// Matches all non-top, non-bottom faces (shorthand for the four sides).
197    Side,
198    /// Matches all faces not otherwise mapped.
199    All,
200}
201
202impl FaceSelector {
203    pub fn parse(s: &str) -> Option<Self> {
204        match s {
205            "top" | "Top" => Some(Self::Top),
206            "bottom" | "Bottom" => Some(Self::Bottom),
207            "front" | "Front" => Some(Self::Front),
208            "back" | "Back" => Some(Self::Back),
209            "left" | "Left" => Some(Self::Left),
210            "right" | "Right" => Some(Self::Right),
211            "side" | "Side" => Some(Self::Side),
212            "all" | "All" | "_" => Some(Self::All),
213            _ => None,
214        }
215    }
216}
217
218/// A single mapping in a `Comp(Faces)` block: a face selector → rule name.
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
220pub struct CompFaceCase {
221    pub selector: FaceSelector,
222    pub rule: RuleCall,
223}
224
225/// Edge-class selectors for the `Comp(Edges)` decomposition.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
227pub enum EdgeSelector {
228    /// Vertical edges: a volume's four corner posts; a face's left/right rim.
229    Vertical,
230    /// All horizontal edges (both rings on a volume; top+bottom on a face).
231    Horizontal,
232    /// The top horizontal ring / edge only.
233    Top,
234    /// The bottom horizontal ring / edge only.
235    Bottom,
236    /// Matches all edges not otherwise mapped.
237    All,
238}
239
240impl EdgeSelector {
241    pub fn parse(s: &str) -> Option<Self> {
242        match s {
243            "vertical" | "Vertical" => Some(Self::Vertical),
244            "horizontal" | "Horizontal" => Some(Self::Horizontal),
245            "top" | "Top" => Some(Self::Top),
246            "bottom" | "Bottom" => Some(Self::Bottom),
247            "all" | "All" | "_" => Some(Self::All),
248            _ => None,
249        }
250    }
251}
252
253/// A single mapping in a `Comp(Edges)` block: an edge selector → rule call.
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255pub struct CompEdgeCase {
256    pub selector: EdgeSelector,
257    pub rule: RuleCall,
258}
259
260/// The decomposition target for a `Comp` operation.
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262pub enum CompTarget {
263    /// Decomposes the volume into its six axis-aligned face scopes.
264    Faces(Vec<CompFaceCase>),
265    /// Decomposes into zero-cross-section edge scopes: 12 for a volume,
266    /// 4 for a face. Local X runs along the edge; give the scope thickness
267    /// with `Size(scope.x, t, t)` and centre it with `Translate`.
268    Edges(Vec<CompEdgeCase>),
269}
270
271/// Face selectors for the `Offset` operation.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
273pub enum OffsetSelector {
274    /// The inset/outset region (the area inside the border).
275    Inside,
276    /// The surrounding border strips.
277    Border,
278    /// Matches any selector not otherwise mapped.
279    All,
280}
281
282impl OffsetSelector {
283    pub fn parse(s: &str) -> Option<Self> {
284        match s {
285            "inside" | "Inside" => Some(Self::Inside),
286            "border" | "Border" => Some(Self::Border),
287            "all" | "All" | "_" => Some(Self::All),
288            _ => None,
289        }
290    }
291}
292
293/// A single mapping in an `Offset` block: a selector → rule name.
294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295pub struct OffsetCase {
296    pub selector: OffsetSelector,
297    pub rule: RuleCall,
298}
299
300/// Roof shape types for the `Roof` operation.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
302pub enum RoofType {
303    // ── Original types ────────────────────────────────────────────────────────
304    /// Four triangular slope panels meeting at a single apex.
305    Pyramid,
306    /// Single slope panel from front eave to back eave.
307    Shed,
308    /// Two slope panels meeting at a horizontal ridge; two triangular gable ends.
309    Gable,
310    /// Four trapezoidal slope panels meeting at a horizontal ridge.
311    Hip,
312
313    // ── New types ─────────────────────────────────────────────────────────────
314    /// Flat horizontal roof — a single horizontal panel covering the scope top.
315    Flat,
316    /// Two rectangular slope panels only (Gable without the triangular end panels).
317    OpenGable,
318    /// Two slope panels + two rectangular (non-tapered) gable-end wall panels.
319    BoxGable,
320    /// Four panels from a rectangular base meeting at a single apex point (no ridge).
321    /// Equivalent to `Pyramid` for square footprints; left/right end panels are triangular.
322    PyramidHip,
323    /// Two inward-tilting slopes forming a central valley (inverted Gable).
324    Butterfly,
325    /// Four panels forming two parallel ridges with a central valley between them (M profile).
326    MShaped,
327    /// Two pitches per slope: steeper lower zone + shallower upper zone (barn roof).
328    /// Requires `secondary_pitch` in `RoofConfig`.
329    Gambrel,
330    /// Gambrel applied to all four sides: 4 steep lower panels + 4 shallow upper panels.
331    /// Requires `secondary_pitch` in `RoofConfig`.
332    Mansard,
333    /// Asymmetric Gable: the ridge is offset toward one end (`ridge_offset` in `RoofConfig`).
334    /// Front slope is steeper; back slope is shallower. Gable ends are asymmetric triangles.
335    Saltbox,
336    /// Gable with clipped hip ends: the upper corners of each gable end are replaced by
337    /// small triangular hip panels. Controlled by `tier_height` in `RoofConfig`.
338    Jerkinhead,
339    /// Hip roof with a small gable rising from the ridge centre.
340    /// Controlled by `tier_height` (fraction of slope from base where the gable starts).
341    DutchGable,
342}
343
344impl RoofType {
345    pub fn parse(s: &str) -> Option<Self> {
346        match s {
347            "pyramid" | "Pyramid" => Some(Self::Pyramid),
348            "shed" | "Shed" => Some(Self::Shed),
349            "gable" | "Gable" => Some(Self::Gable),
350            "hip" | "Hip" => Some(Self::Hip),
351            "flat" | "Flat" => Some(Self::Flat),
352            "openGable" | "OpenGable" => Some(Self::OpenGable),
353            "boxGable" | "BoxGable" => Some(Self::BoxGable),
354            "pyramidHip" | "PyramidHip" => Some(Self::PyramidHip),
355            "butterfly" | "Butterfly" => Some(Self::Butterfly),
356            "mShaped" | "MShaped" => Some(Self::MShaped),
357            "gambrel" | "Gambrel" => Some(Self::Gambrel),
358            "mansard" | "Mansard" => Some(Self::Mansard),
359            "saltbox" | "Saltbox" => Some(Self::Saltbox),
360            "jerkinhead" | "Jerkinhead" => Some(Self::Jerkinhead),
361            "dutchGable" | "DutchGable" => Some(Self::DutchGable),
362            _ => None,
363        }
364    }
365}
366
367/// Face selectors for the `Roof` operation.
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
369pub enum RoofFaceSelector {
370    /// The main sloped panel(s) — front/back in most roof types.
371    Slope,
372    /// The triangular vertical end panels of a Gable or Saltbox roof.
373    GableEnd,
374    /// The steeper, lower zone of a Gambrel or Mansard roof.
375    LowerSlope,
376    /// The shallower, upper zone of a Gambrel or Mansard roof.
377    UpperSlope,
378    /// The small triangular hip panels at the clipped ends of a Jerkinhead roof.
379    HipEnd,
380    /// The inward-facing slopes of a Butterfly or MShaped valley.
381    ValleySlope,
382    /// The outer slopes of an MShaped roof (facing away from the valley).
383    OuterSlope,
384    /// The inner slopes of an MShaped roof (facing toward the valley).
385    InnerSlope,
386    /// The vertical back wall of a `Shed` roof — the raised face under the
387    /// high eave (the glazed "northlight" of a sawtooth factory profile).
388    Back,
389    /// Vertical fascia bands hanging below the eaves.
390    /// Generated when [`RoofConfig::fascia_depth`] is `> 0`. One panel per perimeter
391    /// slope eave; supported for all roof types whose slope panels share a horizontal
392    /// eave at the perimeter (Gable, Hip, Pyramid, Shed, Saltbox, Jerkinhead, DutchGable,
393    /// Gambrel, Mansard, MShaped, BoxGable, OpenGable, PyramidHip). `Flat` and `Butterfly`
394    /// have no perimeter eave at the slope-panel level and produce no fascia panels.
395    Fascia,
396    /// Matches any selector not otherwise mapped.
397    All,
398}
399
400impl RoofFaceSelector {
401    pub fn parse(s: &str) -> Option<Self> {
402        match s {
403            "slope" | "Slope" => Some(Self::Slope),
404            "gable" | "GableEnd" | "gableEnd" => Some(Self::GableEnd),
405            "lowerSlope" | "LowerSlope" => Some(Self::LowerSlope),
406            "upperSlope" | "UpperSlope" => Some(Self::UpperSlope),
407            "hipEnd" | "HipEnd" => Some(Self::HipEnd),
408            "valleySlope" | "ValleySlope" => Some(Self::ValleySlope),
409            "outerSlope" | "OuterSlope" => Some(Self::OuterSlope),
410            "innerSlope" | "InnerSlope" => Some(Self::InnerSlope),
411            "back" | "Back" => Some(Self::Back),
412            "fascia" | "Fascia" => Some(Self::Fascia),
413            "all" | "All" | "_" => Some(Self::All),
414            _ => None,
415        }
416    }
417}
418
419/// A single mapping in a `Roof` block: a face selector → rule name.
420#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
421pub struct RoofCase {
422    pub selector: RoofFaceSelector,
423    pub rule: RuleCall,
424}
425
426/// Rich parametric configuration for the `Roof` operation.
427///
428/// All angular values are in degrees. Lengths are in world units.
429/// Optional fields default as described; see each field doc.
430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
431pub struct RoofConfig {
432    pub roof_type: RoofType,
433    /// Primary pitch angle in degrees from horizontal. Must be in (0°, 90°).
434    pub pitch: f64,
435    /// Secondary pitch angle in degrees. Used by `Gambrel` (upper zone) and `Mansard`.
436    /// If `None` when required, defaults to `pitch / 2`.
437    pub secondary_pitch: Option<f64>,
438    /// Extra overhang beyond the scope footprint on each side. Default `0.0`.
439    pub overhang: f64,
440    /// Ridge offset for `Saltbox`: fraction [0, 1] of the scope depth (Z) where the
441    /// ridge is positioned from the front. Default `0.5` (symmetric / centred ridge).
442    pub ridge_offset: f64,
443    /// Thickness of the roof fascia edge in world units. Default `0.0` (flat panels).
444    pub fascia_depth: f64,
445    /// Normalised tier parameter for `Gambrel`, `Mansard`, `Jerkinhead`, and
446    /// `DutchGable`. For the pitch-break types (`Gambrel`, `Mansard`,
447    /// `DutchGable`) it is the height at which the break occurs: `0.5` means
448    /// the break is at half the eave-to-ridge distance. For `Jerkinhead` it
449    /// is the **fraction of the half-depth that is clipped**: `0.25` clips a
450    /// quarter of each gable end into a hip-let, and larger values clip
451    /// more. `None` uses a type-specific default.
452    pub tier_height: Option<f64>,
453}
454
455impl RoofConfig {
456    /// Creates a minimal config for deterministic types (Pyramid, Shed, Gable, Hip, Flat, …).
457    pub fn new(roof_type: RoofType, pitch: f64) -> Self {
458        Self {
459            roof_type,
460            pitch,
461            secondary_pitch: None,
462            overhang: 0.0,
463            ridge_offset: 0.5,
464            fascia_depth: 0.0,
465            tier_height: None,
466        }
467    }
468
469    /// Returns the secondary pitch, defaulting to `pitch / 2` if unset.
470    pub fn secondary_pitch_or_default(&self) -> f64 {
471        self.secondary_pitch.unwrap_or(self.pitch / 2.0)
472    }
473
474    /// Returns the tier height, defaulting to `default` if unset.
475    pub fn tier_height_or(&self, default: f64) -> f64 {
476        self.tier_height.unwrap_or(default)
477    }
478}
479
480/// Expression-valued roof parameters as they appear in the grammar.
481///
482/// The interpreter evaluates every field against the current shape's context
483/// and produces a resolved [`RoofConfig`] for the geometry builder. `pitch`
484/// and `height` are mutually exclusive ways to set the roof's steepness:
485/// when `height` is `Some`, the pitch is derived from it and the scope's
486/// half-span at derivation time (CGA `byHeight` parity), letting mixed-width
487/// wings share one ridge line.
488#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
489pub struct RoofSpec {
490    pub roof_type: RoofType,
491    /// Primary pitch angle in degrees, exclusive range (0°, 90°). Ignored
492    /// when `height` is set.
493    pub pitch: Expr,
494    /// Absolute roof rise in world units (`height=` named arg). Overrides
495    /// `pitch` when present.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub height: Option<Expr>,
498    /// Secondary pitch in degrees for `Gambrel` / `Mansard`.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub secondary_pitch: Option<Expr>,
501    /// Eave overhang beyond the footprint, world units. Default `0`.
502    pub overhang: Expr,
503    /// Ridge offset fraction for `Saltbox`. Default `0.5`.
504    pub ridge_offset: Expr,
505    /// Fascia band depth below each perimeter eave. Default `0`.
506    pub fascia_depth: Expr,
507    /// Pitch-break height fraction for tiered types.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub tier_height: Option<Expr>,
510    /// Forces the ridge to run along the given scope axis (`ridge=X` /
511    /// `ridge=Z`), overriding the default longest-axis heuristic.
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub ridge_axis: Option<Axis>,
514}
515
516/// Wraps every numeric field of a resolved config back into literal
517/// expressions — the programmatic bridge for builders that think in numbers.
518impl From<RoofConfig> for RoofSpec {
519    fn from(c: RoofConfig) -> Self {
520        Self {
521            roof_type: c.roof_type,
522            pitch: Expr::lit(c.pitch),
523            height: None,
524            secondary_pitch: c.secondary_pitch.map(Expr::lit),
525            overhang: Expr::lit(c.overhang),
526            ridge_offset: Expr::lit(c.ridge_offset),
527            fascia_depth: Expr::lit(c.fascia_depth),
528            tier_height: c.tier_height.map(Expr::lit),
529            ridge_axis: None,
530        }
531    }
532}
533
534impl RoofSpec {
535    /// Literal spec with defaults matching `RoofConfig::new` — the
536    /// programmatic construction path for tests and builders.
537    pub fn new(roof_type: RoofType, pitch: f64) -> Self {
538        Self {
539            roof_type,
540            pitch: Expr::lit(pitch),
541            height: None,
542            secondary_pitch: None,
543            overhang: Expr::lit(0.0),
544            ridge_offset: Expr::lit(0.5),
545            fascia_depth: Expr::lit(0.0),
546            tier_height: None,
547            ridge_axis: None,
548        }
549    }
550}
551
552/// Selector for the `Attach` operation.
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
554pub enum AttachSelector {
555    /// The projected scope that sits on (or comes out of) the surface.
556    Surface,
557    /// Matches any selector not otherwise mapped.
558    All,
559}
560
561impl AttachSelector {
562    pub fn parse(s: &str) -> Option<Self> {
563        match s {
564            "surface" | "Surface" => Some(Self::Surface),
565            "all" | "All" | "_" => Some(Self::All),
566            _ => None,
567        }
568    }
569}
570
571/// A single mapping in an `Attach` block: a selector → rule name.
572#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
573pub struct AttachCase {
574    pub selector: AttachSelector,
575    pub rule: RuleCall,
576}
577
578/// How one variant of a rule is selected during derivation.
579#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
580pub enum VariantSelector {
581    /// Stochastic: relative weight among the rule's weighted variants
582    /// (`70% ops | 30% ops`). Weights need not sum to 1.
583    Weight(f64),
584    /// Guarded: taken when the expression evaluates non-zero, top-down
585    /// (`when(scope.x < 4): ops`). A guarded rule's variants are evaluated
586    /// in order; the first true guard wins.
587    When(Expr),
588    /// Fallback for a guarded rule (`else: ops`); must be last. In weighted
589    /// rules `else:` is parse-time sugar resolved into a `Weight` of the
590    /// remaining probability mass, so it never reaches the interpreter.
591    Else,
592}
593
594/// One alternative in a rule: how it is selected, and what it does.
595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
596pub struct RuleVariant {
597    pub selector: VariantSelector,
598    pub ops: Vec<ShapeOp>,
599}
600
601impl RuleVariant {
602    /// Weighted variant — the pre-0.3 shape.
603    pub fn weighted(weight: f64, ops: Vec<ShapeOp>) -> Self {
604        Self {
605            selector: VariantSelector::Weight(weight),
606            ops,
607        }
608    }
609
610    /// The stochastic weight, when this variant is weighted.
611    pub fn weight(&self) -> Option<f64> {
612        match self.selector {
613            VariantSelector::Weight(w) => Some(w),
614            _ => None,
615        }
616    }
617}
618
619/// Region selectors for the `ShapeL` / `ShapeU` footprint-carving ops.
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
621pub enum CarveSelector {
622    /// The carved letter shape. `ShapeL` delivers it as TWO rectangular
623    /// scopes (front bar + side leg), `ShapeU` as three — OBB purity means a
624    /// letter footprint is a set of boxes, never one polygon.
625    Shape,
626    /// The rectangular remainder cut away from the letter.
627    Remainder,
628    /// Matches any selector not otherwise mapped.
629    All,
630}
631
632impl CarveSelector {
633    pub fn parse(s: &str) -> Option<Self> {
634        match s {
635            "shape" | "Shape" => Some(Self::Shape),
636            "remainder" | "Remainder" | "rest" | "Rest" => Some(Self::Remainder),
637            "all" | "All" | "_" => Some(Self::All),
638            _ => None,
639        }
640    }
641}
642
643/// A single mapping in a `ShapeL` / `ShapeU` block: selector → rule call.
644#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
645pub struct CarveCase {
646    pub selector: CarveSelector,
647    pub rule: RuleCall,
648}
649
650/// One candidate in a `Fit` op: the minimum extent it needs, and the rule
651/// invoked on the whole scope when it is the first that fits.
652#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
653pub struct FitCandidate {
654    pub min_size: Expr,
655    pub rule: RuleCall,
656}
657
658/// The atomic CGA operations that the interpreter executes.
659///
660/// Every operation transforms the current `Scope` into zero or more child scopes,
661/// each tagged with a rule name that will be recursively evaluated.
662#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
663pub enum ShapeOp {
664    /// Lifts a 2-D footprint (XZ plane) into a 3-D volume by setting the Y size.
665    Extrude(Expr),
666
667    /// Pyramidal taper: scales the top face toward the centroid.
668    /// `amount` ∈ `[0, 1]`: 0 = no taper, 1 = full pyramid (top collapses to a point).
669    Taper(Expr),
670
671    /// Applies an additional rotation to the scope (cumulative with existing rotation).
672    /// Components are `(w, x, y, z)` in grammar order; the evaluated quaternion
673    /// is normalized at derivation time.
674    Rotate([Expr; 4]),
675
676    /// Translates the scope origin in local space.
677    Translate([Expr; 3]),
678
679    /// Scales the scope size along each axis (multiplicative).
680    Scale([Expr; 3]),
681
682    /// Divides the scope along `axis` into ordered slots.
683    ///
684    /// When `snap` is `Some`, interior slot boundaries are snapped to the
685    /// nearest registered snap-plane along `axis` (see [`SnapBinding`]).
686    Split {
687        axis: Axis,
688        entries: Vec<SplitEntry>,
689        snap: Option<SnapBinding>,
690    },
691
692    /// Divides the scope along `axis` by *target areas* instead of lengths.
693    ///
694    /// Slot sizes are read as areas: absolute = square units, relative (`'`)
695    /// = fraction of the face area, floating (`~`) = share of the remaining
696    /// area. Lengths are recovered by dividing through the cross-axis extent,
697    /// so this is only meaningful on the horizontal axes of a footprint-like
698    /// scope; `SplitArea(Y)` is rejected.
699    ///
700    /// Syntax: `SplitArea(X) { 30: Lot | ~1: Rest }`
701    SplitArea { axis: Axis, slots: Vec<SplitSlot> },
702
703    /// Size-fallback choice: invokes the first candidate whose minimum
704    /// extent fits the scope along `axis`; candidates are tried in order and
705    /// the scope vanishes when none fits (use a `0:` catch-all to avoid
706    /// that).
707    ///
708    /// Syntax: `Fit(X) { 2.2: DoorBay | 1.2: WinBay | 0: Wall }`
709    Fit {
710        axis: Axis,
711        candidates: Vec<FitCandidate>,
712    },
713
714    /// Tiles the scope along `axis` with child scopes drawn from `tile_sizes`.
715    ///
716    /// `tile_sizes` is a per-slot pattern that is cycled to fill the axis range.
717    /// Tiles are appended greedily (next tile from the cycle is added while it
718    /// still fits inside the remaining range), then **all** placed tiles are
719    /// scaled by the same factor `total / Σ(placed)` so they fill the scope
720    /// exactly with no gap and no overshoot.
721    ///
722    /// A single-element list `[t]` is the legacy uniform `Repeat(axis, t)`.
723    /// A multi-element list `[a, b, c]` produces an `…, a, b, c, a, b, c, …`
724    /// cadence where each tile's relative width is preserved.
725    Repeat {
726        axis: Axis,
727        tile_sizes: Vec<Expr>,
728        rule: RuleCall,
729    },
730
731    /// Decomposes the scope into its geometric components (faces, edges, vertices).
732    Comp(CompTarget),
733
734    /// Terminal: replace the scope with the named mesh asset.
735    /// This is the "terminal symbol" — produces a `Terminal` node in the output model.
736    I(String),
737
738    /// Sets the material on the current work item.
739    /// The material is propagated to the final `Terminal`, allowing downstream
740    /// renderers to apply textures / shaders and physics consumers to derive
741    /// volumetric mass properties without changing the scope.
742    ///
743    /// Syntax:
744    /// - `Mat("Brick")` / `Mat(Brick)` — id-only material; no density.
745    /// - `Mat("Brick", 1800)` — id + density in kg/m³; the interpreter computes
746    ///   [`crate::model::MassProperties`] for terminals stamped with this material.
747    Mat(Material),
748
749    /// Calls a named sub-rule on the current scope unchanged, optionally
750    /// passing call arguments (`Tier(depth + 1)`).
751    /// Used for grammar rule references that don't transform the scope themselves.
752    Rule(RuleCall),
753
754    /// Sets the scope size to absolute world-unit values (CGA `s()` parity).
755    /// Components must be finite and non-negative; `0` flattens the axis
756    /// (face-scope semantics). Essential for sizing the zero-extent scopes
757    /// produced by `Scatter` and `Comp(Edges)`.
758    ///
759    /// Syntax: `Size(2.1, 0.9, 0.12)` — expressions welcome:
760    /// `Size(scope.x, 0.3, 0.3)`.
761    Size([Expr; 3]),
762
763    /// Re-centres the scope inside the axis-aligned bounds it occupied when
764    /// the current rule was entered, along the masked axes. The scope must
765    /// have been shrunk (e.g. by `Size`) for this to move anything.
766    ///
767    /// Syntax: `Center(X)`, `Center(XY)`, `Center(XYZ)` …
768    Center { x: bool, y: bool, z: bool },
769
770    /// Mirrors the *pending face profile* horizontally (Triangle peak,
771    /// Trapezoid offset, Polygon points). Scope geometry is untouched —
772    /// terminals carry rotations, not reflections, so a scope-level mirror
773    /// cannot exist in this engine. Apply after the profile is set.
774    ///
775    /// Syntax: `Mirror(X)` (only X — profiles are 2-D, mirrored across
776    /// their vertical centre line).
777    Mirror,
778
779    /// Carves an L footprint: a front bar of depth `front` (along local Z
780    /// from the scope origin) plus a side leg of width `side` (along local X)
781    /// over the remaining depth. The `Shape` selector receives both boxes;
782    /// `Remainder` receives the cut-away rectangle.
783    ///
784    /// Syntax: `ShapeL(4, 3) { Shape: Wing | Remainder: Court }`
785    ShapeL {
786        front: Expr,
787        side: Expr,
788        cases: Vec<CarveCase>,
789    },
790
791    /// Carves a U footprint: a front bar plus left and right legs; the
792    /// remainder is the inner court between the legs behind the bar.
793    ///
794    /// Syntax: `ShapeU(4, 3, 3) { Shape: Range | Remainder: Court }`
795    ShapeU {
796        front: Expr,
797        left: Expr,
798        right: Expr,
799        cases: Vec<CarveCase>,
800    },
801
802    /// Rotates the scope so that the specified local axis points in the given world direction.
803    ///
804    /// Applies the shortest-arc rotation from the current world direction of `local_axis`
805    /// to `target`. Useful for recovering from accumulated rotations.
806    /// Syntax: `Align(Y, Up)`, `Align(Z, Forward)`, etc.
807    /// Named targets: `Up`=(0,1,0), `Down`=(0,-1,0), `Right`=(1,0,0), `Left`=(-1,0,0),
808    /// `Forward`=(0,0,-1), `Back`=(0,0,1).
809    Align { local_axis: Axis, target: Vec3 },
810
811    /// Creates an inset (`distance < 0`) frame on a 2D face scope.
812    ///
813    /// Produces up to two kinds of child scopes:
814    /// - `Inside`: the inset rectangle.
815    /// - `Border`: four surrounding strips (bottom, top, left, right), each invoking the same rule.
816    ///
817    /// Syntax: `Offset(-0.2) { Inside: Glass | Border: Frame }`
818    Offset {
819        distance: Expr,
820        cases: Vec<OffsetCase>,
821    },
822
823    /// Generates a roof structure above the current scope using rich parametric configuration.
824    ///
825    /// Operates on a volume scope. The `config` contains the roof type, primary pitch angle,
826    /// optional secondary pitch, overhang, ridge offset, fascia depth, and tier height.
827    ///
828    /// Syntax examples:
829    /// - `Roof(Gable, 30) { Slope: Tiles | GableEnd: Bricks }` — basic Gable
830    /// - `Roof(Hip, 30, 0.5) { Slope: Tiles }` — Hip with overhang
831    /// - `Roof(Gambrel, 45, 20) { LowerSlope: Shingles | UpperSlope: Tiles }` — Gambrel
832    /// - `Roof(Saltbox, 45, offset=0.3) { Slope: Tiles | GableEnd: Bricks }` — Saltbox
833    /// - `Roof(DutchGable, 45, tier=0.7) { Slope: Tiles | GableEnd: Bricks }` — Dutch Gable
834    Roof {
835        spec: RoofSpec,
836        cases: Vec<RoofCase>,
837    },
838
839    /// Registers all six face planes of the current scope as snap-planes
840    /// under the given label. Subsequent `Split(snap="label")` ops can align
841    /// their interior boundaries to these planes. Read-only with respect to
842    /// the scope (the scope itself passes through unchanged).
843    ///
844    /// Syntax: `RegSnap("bays")`
845    RegSnap(String),
846
847    /// Conditionally invokes `rule` on the current scope only when no
848    /// already-emitted terminal occludes the scope (true OBB overlap test).
849    /// Useful for placing decorative elements that should only appear where
850    /// no structural element has already been placed.
851    ///
852    /// The grammar author is responsible for ordering — terminals derived
853    /// before this op participate in the test; later terminals do not.
854    ///
855    /// Syntax: `IfClear { Window }` / `IfClear("chimneys") { Window }` —
856    /// the optional label restricts the test to terminals stamped with that
857    /// `Label`.
858    IfClear {
859        rule: RuleCall,
860        #[serde(default, skip_serializing_if = "Option::is_none")]
861        label: Option<String>,
862    },
863
864    /// Inverse of [`ShapeOp::IfClear`]: invokes `rule` only when the current
865    /// scope **is** occluded by an already-emitted terminal.
866    ///
867    /// Syntax: `IfOccluded { Patch }` / `IfOccluded("roof") { Patch }`.
868    IfOccluded {
869        rule: RuleCall,
870        #[serde(default, skip_serializing_if = "Option::is_none")]
871        label: Option<String>,
872    },
873
874    /// Graded occlusion: invokes `rule` only when the scope is FULLY inside
875    /// a single already-emitted terminal (optionally of one label class).
876    ///
877    /// Syntax: `IfInside { Core }` / `IfInside("mass") { Core }`
878    IfInside {
879        rule: RuleCall,
880        #[serde(default, skip_serializing_if = "Option::is_none")]
881        label: Option<String>,
882    },
883
884    /// Graded occlusion: invokes `rule` only when the scope is in surface
885    /// contact with a terminal — overlapping at a hair's growth but not at a
886    /// hair's shrinkage (optionally restricted to one label class).
887    ///
888    /// Syntax: `IfTouches { Trim }` / `IfTouches("walls") { Trim }`
889    IfTouches {
890        rule: RuleCall,
891        #[serde(default, skip_serializing_if = "Option::is_none")]
892        label: Option<String>,
893    },
894
895    /// Coordination key: a weighted choice resolved once per derivation —
896    /// every `Pick` with the same key picks the SAME index, wherever it
897    /// appears in the tree. The poor man's CGA++ event: all floors agree on
898    /// one window variant, front and back facades match.
899    ///
900    /// The choice is a pure function of `(interpreter seed, key)` — no
901    /// derivation-order dependence.
902    ///
903    /// Syntax: `Pick("winStyle") { 60% WinA | 40% WinB }`
904    Pick {
905        key: String,
906        /// `(weight, successor)` pairs; weights need not sum to 1.
907        choices: Vec<(f64, RuleCall)>,
908    },
909
910    /// Stamps an occlusion label on subsequent terminals of this branch
911    /// (propagates like `Mat`). Labelled terminals form a named class the
912    /// occlusion conditionals can filter on.
913    ///
914    /// Syntax: `Label("chimneys")`
915    Label(String),
916
917    /// Scatters `count` zero-size point scopes uniformly over the scope's
918    /// top face (`Top`) or through its volume (`Volume`), invoking `rule` on
919    /// each. Points are drawn from the shape's RNG stream (seed-stable);
920    /// give them extent with `Size(..)`. `count` is capped at 1024.
921    ///
922    /// Syntax: `Scatter(Top, 12) { Bush }`
923    Scatter {
924        volume: bool,
925        count: Expr,
926        rule: RuleCall,
927    },
928
929    /// Stamps an explicit polygonal `FaceProfile` on the next terminal in this rule.
930    ///
931    /// Mirrors how [`ShapeOp::Taper`] sets a profile override: the next `I(...)`
932    /// (or implicit terminal) emits a `Terminal` whose `face_profile` is
933    /// [`crate::model::FaceProfile::Polygon`] with the provided vertex list.
934    /// Vertices are 2-D points in **normalized `[0, 1]²` scope coordinates**
935    /// (X: 0 = left edge → 1 = right edge; Y: 0 = bottom → 1 = top of the
936    /// face); the renderer triangulates and stretches them across the
937    /// scope's extent. They are NOT world units.
938    ///
939    /// Syntax: `Polygon((0,0), (4,0), (4,2), (2,2), (2,4), (0,4))`
940    /// (variadic `(x,y)` list, capped at 256 vertices for parser DoS hardening).
941    Polygon(Vec<glam::DVec2>),
942
943    /// Projects a new horizontal scope out of a sloped face for attaching dormers or details.
944    ///
945    /// `world_axis` defines the "up" direction for the attached scope (usually world Y).
946    /// The resulting scope sits on the face's surface with its Y axis aligned to `world_axis`,
947    /// inheriting the face's width and height but with depth = 0.
948    ///
949    /// Syntax: `Attach(Up) { Surface: DormerMass }`
950    Attach {
951        world_axis: Vec3,
952        cases: Vec<AttachCase>,
953    },
954}