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.
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 variablesscope.x/y/z,split.i,split.n, anddepth - 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;SplitAreadivides by target areas - Footprint carving —
ShapeL/ShapeUdeliver 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|Zorientation control, fascia bands, and aBacknorthlight face onShed - Labelled & graded occlusion —
Label("chimneys")classes terminals;IfClear("chimneys"),IfOccluded,IfInside,IfTouchesgate 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, andstyle … extends …override sets; hosts callset_attr/set_style - Snap-lines & material propagation —
RegSnap+Split(snap=…);Mat("Brick", 1800)flows to terminals and computes mass properties - Genetic evolution —
ShapeGenotypewraps the rule table forsymbios-genetics(literal-leaf mutation, BLX-α crossover) - DoS-hardened — bounded queue, depth, terminal, expression, and identifier limits
Quick Start
use ;
use parse_statement;
let mut interp = new;
for line in
interp.seed = 42; // reproducible derivation
interp.set_attr; // host override, no grammar edit
// XZ footprint — Y is set by Extrude
let footprint = new;
let model = interp.derive.unwrap;
for t in &model.terminals
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:
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
# 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
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
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
let mut interp = new;
interp.add_statement?; // text statements
interp.add_grammar_rule?;
interp.add_rule_def?;
interp.set_attr; // host override
interp.set_style?; // style select
interp.seed = 7;
let model = interp.derive?;
Output
derive returns a ShapeModel:
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.
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 (seebevy_symbios_shape) - Serde support — scopes, ops, models, and sub-types round-trip;
pre-0.3
Terminalpayloads deserialize (thelabelfield defaults)
License
MIT