# 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 }
```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
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
| 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(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
| 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