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.

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 opsExtrude, 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 guardsSpire(n) --> when(n == 0): I("finial") | else: … Spire(n - 1); call arguments work at every successor position
  • Stochastic rules with per-shape seed streams70% 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 carvingShapeL / 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 occlusionLabel("chimneys") classes terminals; IfClear("chimneys"), IfOccluded, IfInside, IfTouches gate rules on precise spatial relations
  • CoordinationPick("key") { 60% A | 40% B } resolves once per derivation: every floor picks the same window variant
  • ScatterScatter(Top, 12) { Bush } seed-stable point scopes over a face or through a volume
  • Grammar statementsattr (host-overridable knobs), const, and style … extends … override sets; hosts call set_attr / set_style
  • Snap-lines & material propagationRegSnap + Split(snap=…); Mat("Brick", 1800) flows to terminals and computes mass properties
  • Genetic evolutionShapeGenotype wraps the rule table for symbios-genetics (literal-leaf mutation, BLX-α crossover)
  • DoS-hardened — bounded queue, depth, terminal, expression, and identifier limits

Installation

[dependencies]
symbios-shape = "0.3"

Quick Start

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:

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_styleattr 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 = 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:

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), WeightedVariantRuleVariant, 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