symbios-shape 0.3.0

A derivation engine for CGA Shape Grammars.
Documentation
# symbios-shape

A pure-Rust derivation engine for **CGA Shape Grammars**, as popularised by
Esri CityEngine. Define procedural building rules as text or Rust code; derive
a flat list of oriented mesh instances ready for any renderer.

```text
attr Floors = 4
Lot    --> Extrude(Floors * 3.2) Split(Y) { 3: Ground | ~1: Body | 2: Roof }
Body   --> Comp(Faces) { Side: Facade }
Facade --> Split(X) { 1: Corner | { 0.6: Pier | ~1: Bay }* | 1: Corner }
Bay    --> when(scope.x < 1.1): Wall | else: Pick("win") { 70% WinA | 30% WinB }
Roof   --> Roof(Gable, height=2.5, ridge=X) { Slope: Tiles | GableEnd: Wall }
```

## Features

- **All core CGA ops** — `Extrude`, `Split`, `Repeat`, `Comp(Faces|Edges)`,
  `Taper`, `Scale`, `Translate`, `Rotate`, `Align`, `Offset` (inset *and*
  outset), `Roof`, `Attach`, `I`, `Mat`, `Polygon`, `Size`, `Center`,
  `Mirror`
- **An expression language in every numeric argument** — arithmetic,
  comparisons, `&&`/`||`/`!`, `rand(min, max)`, `floor`/`ceil`/`rint`/`abs`/
  `sqrt`/`pow`/`clamp`/`min`/`max`, plus the built-in variables `scope.x/y/z`,
  `split.i`, `split.n`, and `depth`
- **Rule parameters and guards** — `Spire(n) --> when(n == 0): I("finial") |
  else: … Spire(n - 1)`; call arguments work at every successor position
- **Stochastic rules with per-shape seed streams** — `70% A | 30% B | else: C`,
  reproducible per seed, queue-order independent, and editing one subtree
  re-rolls *only* that subtree
- **Rhythm, fit, and area splits** — `{ pier | window }*` groups tile between
  fixed bookends; `Fit(X)` takes the first candidate that fits; `SplitArea`
  divides by target areas
- **Footprint carving** — `ShapeL` / `ShapeU` deliver courtyard and wing
  plans as rectangular scopes plus a remainder
- **15 roof types** — Pyramid, Shed, Gable, Hip, Flat, OpenGable, BoxGable,
  PyramidHip, Butterfly, MShaped, Gambrel, Mansard, Saltbox, Jerkinhead,
  DutchGable — with `height=` (shared ridge lines across mixed-width wings),
  `ridge=X|Z` orientation control, fascia bands, and a `Back` northlight
  face on `Shed`
- **Labelled & graded occlusion** — `Label("chimneys")` classes terminals;
  `IfClear("chimneys")`, `IfOccluded`, `IfInside`, `IfTouches` gate rules on
  precise spatial relations
- **Coordination** — `Pick("key") { 60% A | 40% B }` resolves once per
  derivation: every floor picks the same window variant
- **Scatter** — `Scatter(Top, 12) { Bush }` seed-stable point scopes over a
  face or through a volume
- **Grammar statements** — `attr` (host-overridable knobs), `const`, and
  `style … extends …` override sets; hosts call `set_attr` / `set_style`
- **Snap-lines & material propagation** — `RegSnap` + `Split(snap=…)`;
  `Mat("Brick", 1800)` flows to terminals and computes mass properties
- **Genetic evolution** — `ShapeGenotype` wraps the rule table for
  `symbios-genetics` (literal-leaf mutation, BLX-α crossover)
- **DoS-hardened** — bounded queue, depth, terminal, expression, and
  identifier limits

## Installation

```toml
[dependencies]
symbios-shape = "0.3"
```

## Quick Start

```rust
use symbios_shape::{Interpreter, Scope, Vec3, Quat};
use symbios_shape::grammar::parse_statement;

let mut interp = Interpreter::new();
for line in [
    "attr Floors = 3",
    r#"Lot    --> Extrude(Floors * 3.2) Split(Y) { 3: Ground | ~1: Floor | 2: Roof }"#,
    r#"Ground --> I("GroundFloor")"#,
    r#"Floor  --> I("UpperFloor")"#,
    r#"Roof   --> Taper(0.8) I("Roof")"#,
] {
    interp.add_statement(parse_statement(line).unwrap()).unwrap();
}
interp.seed = 42;                       // reproducible derivation
interp.set_attr("Floors", 5.0);         // host override, no grammar edit

// XZ footprint — Y is set by Extrude
let footprint = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 0.0, 10.0));
let model = interp.derive(footprint, "Lot").unwrap();

for t in &model.terminals {
    println!("{} @ {:?}", t.mesh_id, t.scope.position);
}
```

`parse_rule` / `add_grammar_rule` remain for rule-only hosts, and
`add_rule` / `add_weighted_rules` / `add_rule_def` / `add_rule_variants`
cover programmatic construction.

## Expressions

Every numeric argument position accepts an expression, evaluated per shape
at derivation time:

```text
Extrude(rand(8, 14))                       # stochastic dimensions
Split(Y) { scope.y / 4: Base | ~1: Rest }  # scope-adaptive sizes
Scale(scope.x * 0.5, 1, 1)
Bay(split.i)                               # call arguments
```

| Element | Forms |
|---|---|
| Operators | `+ - * / %`, `== != < <= > >=`, `&& \|\| !` (booleans are `0.0` / `1.0`) |
| Functions | `rand()` `rand(max)` `rand(min, max)`, `floor` `ceil` `rint` `abs` `sqrt` `pow` `clamp` `min` `max` |
| Variables | `scope.x` `scope.y` `scope.z` (current extents), `split.i` `split.n` (position in the last Split/Repeat), `depth`, plus any parameter, `attr`, or `const` name |

Failures are loud: division by zero, non-finite results, unknown names, and
inverted ranges abort the derivation with a descriptive `ShapeError`.

`rand(..)` draws from the **shape's own seed stream**, forked from its
parent's along the derivation path. Consequences: the same seed always
derives the same model; queue order is irrelevant; and editing the rules of
one wing re-rolls that wing alone.

## Rules

```text
# Deterministic
Lot --> Extrude(10) Split(Y) { ~1: Floor | 2: Roof }

# Stochastic (weights are percentages; `else:` takes the remainder)
Facade --> 70% BrickWall | else: GlassCurtain

# Guarded (top-down; first true guard wins; `else:` is the fallback)
Bay --> when(scope.x < 1.2): Wall | when(split.i == 0): CornerBay | else: WindowBay

# Parameterized (+ recursion with a guard base case)
Spire(n) --> when(n == 0): I("finial") | else: Extrude(scope.y * 0.7) Next(n)
Next(n)  --> Spire(n - 1)
```

Weighted and guarded variants cannot mix within one rule. A guarded rule
with no matching guard and no `else` derives nothing.

**`NIL`** is a reserved rule name that vanishes its shape — legal in any
successor position: `Split(X) { ~1: Wall | 0.4: NIL | ~1: Wall }`,
`50% Balcony | 50% NIL`.

### Statements

```text
attr Floors = 4                    # host-overridable knob
const FloorH = 3.2                 # fixed named value
style Poor { Floors = 2 }          # named attr override set
style Rich extends Poor { Floors = 6 }
```

Declaration values must be constant expressions. Effective precedence when
an expression reads a name: **rule parameters → host `set_attr` → active
`set_style` → `attr` default → `const`.**

## Grammar Syntax

### Operations

| Op | Syntax | Description |
|---|---|---|
| Extrude | `Extrude(h)` | Set Y size (footprints) or push a face outward |
| Taper | `Taper(t)` | Pyramidal taper, t ∈ [0, 1] |
| Size | `Size(x, y, z)` | Absolute scope size (CGA `s()`); `0` flattens an axis |
| Center | `Center(XZ)` | Recentre masked axes within the rule-entry bounds |
| Scale | `Scale(x, y, z)` | Multiply scope size (all > 0) |
| Translate | `Translate(x, y, z)` | Shift origin in local space |
| Rotate | `Rotate(w, x, y, z)` | Apply quaternion rotation |
| Align | `Align(Y, Up)` | Rotate so local axis points at a world direction |
| Mirror | `Mirror(X)` | Flip the pending face profile across its centre line |
| Split | `Split(Y) { 3: A \| ~1: B \| '0.2: C }` | Divide along an axis (abs / floating `~` / relative `'`) |
| — rhythm | `Split(X) { 1: End \| { 0.6: Pier \| ~1: Win }* \| 1: End }` | The group tiles to fill; floats absorb leftover, else copies stretch |
| SplitArea | `SplitArea(X) { 30: Lot \| ~1: Rest }` | Split by target areas (X/Z only) |
| Fit | `Fit(X) { 2.2: Door \| 1.2: Win \| 0: Wall }` | First candidate whose minimum extent fits |
| Repeat | `Repeat(X, 2.5) { Window }` / `Repeat(X, [2, 1.5]) { Bay }` | Tile along an axis, stretch to fill |
| Comp | `Comp(Faces) { Top: R \| Side: R }` | Face scopes, local Z = outward normal |
| — edges | `Comp(Edges) { Vertical: Post \| Top: Coping }` | Zero-thickness edge scopes, local X along the edge |
| Offset | `Offset(-0.2) { Inside: R \| Border: R }` | Inset (negative) or outset (positive) a face |
| ShapeL | `ShapeL(4, 3) { Shape: Wing \| Remainder: Court }` | Carve an L plan (Shape = two boxes) |
| ShapeU | `ShapeU(4, 3, 3) { Shape: Range \| Remainder: Court }` | Carve a U plan (Shape = three boxes) |
| Roof | `Roof(Gable, 30) { Slope: R \| GableEnd: R }` | Parametric roof (see below) |
| Attach | `Attach(Up) { Surface: R }` | Project a scope onto a sloped face |
| Scatter | `Scatter(Top, 12) { Bush }` | Seed-stable points on a face / in a volume (≤ 1024) |
| I | `I("MeshId")` | Emit terminal |
| Mat | `Mat("Brick")` / `Mat("Brick", 1800)` | Set material (+ density → mass properties) |
| Label | `Label("chimneys")` | Stamp an occlusion class on this branch's terminals |
| IfClear | `IfClear { R }` / `IfClear("class") { R }` | Only if no (matching) terminal overlaps |
| IfOccluded | `IfOccluded { R }` / with label | Only if a (matching) terminal overlaps |
| IfInside | `IfInside("mass") { R }` | Only if fully inside one terminal |
| IfTouches | `IfTouches("walls") { R }` | Only on surface contact (no interpenetration) |
| Pick | `Pick("key") { 60% A \| 40% B }` | Derivation-coherent choice, same key → same pick |
| Polygon | `Polygon((0,0), (1,0), (1,0.4))` | Stamp a polygonal profile — **normalized [0,1]² coords** |
| RegSnap | `RegSnap("bays")` | Register the scope's six face planes as snap-planes |
| Rule ref | `Name` / `Name(arg, …)` | Delegate to another rule |

Comments (`// line`, `/* block */`) are legal anywhere whitespace is. Op
keywords match at word boundaries — rule names like `Inner` or `Sized`
never collide with `I(` or `Size(`.

### Roof

```text
Roof(Gable, 30) { Slope: Tiles | GableEnd: Bricks }
Roof(Hip, 30, 0.5) { Slope: Tiles }                          # 0.5 = overhang
Roof(Gambrel, 45, 20) { LowerSlope: Shingles | UpperSlope: Tiles }
Roof(Gable, height=2.5) { Slope: Tiles | GableEnd: Wall }    # byHeight
Roof(Gable, 35, ridge=Z) { Slope: Tiles | GableEnd: Wall }   # force ridge axis
Roof(Shed, 40) { Slope: Metal | Back: NorthLight }           # sawtooth glazing
Roof(Mansard, 60, secondary=20, tier=0.4) { LowerSlope: P | UpperSlope: T }
Roof(Hip, 30, fascia=0.3) { Slope: Tiles | Fascia: Board }
```

Named args (any order, overriding positionals): `overhang=`, `offset=`
(Saltbox ridge, default 0.5), `tier=`, `fascia=`, `secondary=`, `height=`
(rise in world units; replaces pitch — mixed-width wings sharing one height
meet at the same ridge line; Shed measures over its full depth), `ridge=X|Z`
(honoured by the gable family, Gambrel, Saltbox, Jerkinhead, DutchGable; the
hip family derives its ridge from the footprint). The second positional is
the **secondary pitch** for Gambrel/Mansard and the **overhang** for every
other type.

Face selectors: `Slope`, `GableEnd`, `LowerSlope`, `UpperSlope`, `HipEnd`,
`ValleySlope`, `OuterSlope`, `InnerSlope`, `Fascia`, `Back` (Shed), `All`/`_`.

### Occlusion & snap-lines (BFS-order caveat)

`RegSnap`/`Split(snap=…)` and the `If*` conditionals consult the derivation
state **at the moment they fire**. Structure grammars so the shapes being
tested against derive first — one or two rules of indirection on the testing
branch is the standard idiom.

## Rust API highlights

```rust
let mut interp = Interpreter::new();
interp.add_statement(parse_statement("attr Wear = 0.3")?)?;   // text statements
interp.add_grammar_rule(parse_rule("Lot --> Extrude(9) I(\"Mass\")")?)?;
interp.add_rule_def("Box", vec!["h".into()],                   // programmatic params
    vec![(1.0, parse_ops("Extrude(h) I(\"Mass\")")?)])?;
interp.set_attr("Wear", 0.8);                                  // host override
interp.set_style("Poor")?;                                     // style select
interp.seed = 7;
let model = interp.derive(footprint, "Lot")?;
```

### Output

`derive` returns a `ShapeModel`:

```rust
pub struct ShapeModel {
    pub terminals: Vec<Terminal>,
    pub snap_planes: Vec<SnapPlane>,
}

pub struct Terminal {
    pub scope: Scope,                            // OBB: position, rotation, size
    pub mesh_id: String,
    pub face_profile: FaceProfile,               // Rectangle, Taper, Triangle, Trapezoid, Polygon
    pub material: Option<Material>,
    pub mass_properties: Option<MassProperties>, // when material has density
    pub label: Option<String>,                   // set by Label("…")
}
```

`model.query()` exposes OBB overlap tests (`TerminalQuery`), and
`obb_overlap(&a, &b)` is a free function.

## Safety Limits

| Limit | Default |
|---|---|
| Max derivation depth | 64 |
| Max work queue / terminals | 100 000 |
| Max ops per rule | 1 024 |
| Max split slots / rule args / variants | 256 / 16 / 64 |
| Max flattened split children (rhythm) | 4 096 |
| Max scatter points per op | 1 024 |
| Max expression nodes / depth | 512 / 64 |

Exceeding a limit returns a structured `ShapeError` rather than panicking.

## Genetic Evolution

`genetics::ShapeGenotype` wraps the rule table (parameters included) for
`symbios-genetics` algorithms. Mutation jitters **literal leaves** only —
bare literals keep validity clamps; compound expressions re-validate at
derivation. Guard conditions and `Pick` weights are treated as logic, not
aesthetics, and pass through crossover unblended.

## Migrating from 0.2

Grammar text from 0.2 parses unchanged. API changes: op fields hold
`Expr`/`RuleCall` (literals via `Expr::lit`, names via `"Name".into()`),
`Split` uses `entries`, `Roof` takes a `RoofSpec` (`RoofConfig::from` bridges
numeric configs), `WeightedVariant` → `RuleVariant`, and `Terminal` gained
`label`. Derivations **reshuffle once**: per-shape seed streams change which
variant a given seed picks (structure is unaffected). Non-finite argument
values now report `ShapeError::ExprEval`.

## Architecture

- **OBB scopes** — all geometry is oriented bounding boxes; no mesh booleans
- **BFS queue** — breadth-first expansion; per-shape RNG states fork along
  the ancestry path (SplitMix64), so results are queue-order independent
- **Pure derivation** — the engine produces a `ShapeModel`; rendering is the
  caller's responsibility (see `bevy_symbios_shape`)
- **Serde support** — scopes, ops, models, and sub-types round-trip;
  pre-0.3 `Terminal` payloads deserialize (the `label` field defaults)

## License

MIT