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.
Lot --> Extrude(15) Split(Y) { 3: Ground | ~1: Floor | 2: Roof }
Floor --> Repeat(X, 2.5) { Window }
Roof --> Taper(0.75) I("RoofMesh")
- All core CGA ops —
Extrude,Split,Repeat,Comp(Faces),Taper,Scale,Translate,Rotate,Align,Offset,Roof,Attach,I,Mat - 15 roof types — Pyramid, Shed, Gable, Hip, Flat, OpenGable, BoxGable, PyramidHip, Butterfly, MShaped, Gambrel, Mansard, Saltbox, Jerkinhead, DutchGable
- Three split modes — absolute (
3:), relative ('0.3:), floating (~1:) - Stochastic rules —
70% BrickFacade | 30% GlassFacadewith reproducible seeded RNG - Implicit terminals — unknown rule names resolve to
I(rule_name)without an explicitI(...)op - Material propagation —
Mat("Brick")stamps a material that flows through all branching ops to the finalTerminal - Rich face profiles —
FaceProfiledescribes each terminal's cross-section (Rectangle, Taper, Triangle, Trapezoid, Polygon) for accurate mesh generation - Genetic evolution —
ShapeGenotypewraps the rule table for use withsymbios-geneticsalgorithms (mutation, BLX-α crossover) - Lightweight — depends only on
glam(math),nom(parsing),rand(stochastic rules), and optionallysymbios-genetics; no engine or runtime required - DoS-hardened — bounded queue, depth, terminal, and identifier limits
[dependencies]
symbios-shape = "0.1"use symbios_shape::{Interpreter, Scope, Vec3, Quat};
use symbios_shape::grammar::parse_ops;
let mut interp = Interpreter::new();
interp.add_rule("Lot", parse_ops("Extrude(12) Split(Y) { 3: Ground | ~1: Floor | 2: Roof }").unwrap());
interp.add_rule("Ground", parse_ops(r#"I("GroundFloor")"#).unwrap());
interp.add_rule("Floor", parse_ops(r#"I("UpperFloor")"#).unwrap());
interp.add_rule("Roof", parse_ops(r#"Taper(0.8) I("Roof")"#).unwrap());
// 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();
println!("{} terminals", model.len()); // 3
for t in &model.terminals {
println!(" {} @ {:?}", t.mesh_id, t.scope.position);
}| Op | Syntax | Description |
|---|---|---|
| Extrude | Extrude(h) |
Set Y size to h (h > 0) |
| Taper | Taper(t) |
Pyramidal taper, t ∈ [0, 1] |
| 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 world direction |
| Split | Split(Y) { 3: A | ~1: B | '0.2: C } |
Divide along axis |
| Repeat | Repeat(X, 2.5) { Window } |
Tile along axis, stretch to fill |
| Comp | Comp(Faces) { Top: R | Side: R | Bottom: R } |
Decompose into face scopes |
| Offset | Offset(-0.2) { Inside: R | Border: R } |
Inset a 2D face scope |
| Roof | Roof(Gable, 30) { Slope: R | GableEnd: R } |
Generate roof geometry |
| Attach | Attach(Up) { Surface: R } |
Project a scope onto a sloped face |
| I | I("MeshId") or I(MeshId) |
Emit terminal |
| Mat | Mat("Brick") or Mat(Brick) |
Set material (propagates to terminal) |
| Rule ref | RuleName |
Delegate to another rule |
| Prefix | Mode | Meaning |
|---|---|---|
| (none) | Absolute | Fixed world-unit size |
' |
Relative | Fraction of total scope dimension |
~ |
Floating | Proportional share of remaining space |
Floating slots divide the space left after all absolute and relative slots are
placed. Example: Split(Y) { 3: Base | ~1: Mid | ~2: Top } on a 9m scope →
Base=3m, Mid=2m, Top=4m.
# Deterministic
Lot --> Extrude(10) Split(Y) { ~1: Floor | 2: Roof }
# Stochastic (weights need not sum to 100)
Facade --> 70% BrickWall | 30% GlassCurtain
Top, Bottom, Front, Back, Left, Right, Side (all four sides),
All or _ (catch-all). Each face scope is oriented so local Z points
along the outward normal.
Rotates the scope so that a local axis points in a world direction (shortest-arc
rotation). Named targets: Up, Down, Right, Left, Forward, Back.
Align(Y, Up) # local Y → world up
Align(Z, Forward) # local Z → world forward (0,0,-1)
Insets a 2D face scope, producing an Inside rectangle and four Border
strips. The distance must be negative (inset). Rejects if the inset exceeds
half the scope dimension.
Offset(-0.2) { Inside: Glass | Border: Frame }
Selectors: Inside, Border, All.
Generates roof geometry above a volume scope. Supports 15 roof types with configurable pitch, overhang, secondary pitch, ridge offset, fascia depth, and tier height.
Roof(Gable, 30) { Slope: Tiles | GableEnd: Bricks }
Roof(Hip, 30, 0.5) { Slope: Tiles }
Roof(Gambrel, 45, 20) { LowerSlope: Shingles | UpperSlope: Tiles }
Roof(Saltbox, 45, offset=0.3) { Slope: Tiles | GableEnd: Bricks }
Roof(DutchGable, 45, tier=0.7) { Slope: Tiles | GableEnd: Bricks }
Roof types: Pyramid, Shed, Gable, Hip, Flat, OpenGable, BoxGable,
PyramidHip, Butterfly, MShaped, Gambrel, Mansard, Saltbox,
Jerkinhead, DutchGable.
Face selectors: Slope, GableEnd, LowerSlope, UpperSlope, HipEnd,
ValleySlope, OuterSlope, InnerSlope, All.
Projects a new horizontal scope onto a sloped face for attaching dormers or surface details. The resulting scope sits on the face with its Y axis aligned to the specified world direction.
Attach(Up) { Surface: DormerMass }
Selectors: Surface, All.
interp.add_rule("Floor", parse_ops("Repeat(X, 2.5) { Window }").unwrap());interp.add_weighted_rules("Facade", vec![
(70.0, parse_ops("BrickWall").unwrap()),
(30.0, parse_ops("GlassWall").unwrap()),
]).unwrap();
interp.seed = 42; // reproducible derivationderive returns a ShapeModel containing a Vec<Terminal>:
pub struct Terminal {
pub scope: Scope, // position, rotation, size (OBB)
pub mesh_id: String, // asset to spawn
pub face_profile: FaceProfile, // cross-section shape (Rectangle, Taper, Triangle, …)
pub material: Option<String>,
}FaceProfile describes each terminal's 2D cross-section for mesh generation:
| Variant | Description |
|---|---|
Rectangle |
Full rectangular face (default) |
Taper(t) |
Legacy tapered prism (0 = box, 1 = pyramid) |
Triangle { peak_offset } |
Triangular face (0.5 = symmetric gable) |
Trapezoid { top_width, offset_x } |
Trapezoidal face (hip roof slopes) |
Polygon(Vec<DVec2>) |
Arbitrary polygon (straight skeleton output) |
The engine enforces hard caps to prevent runaway grammars:
| Limit | Default |
|---|---|
| Max derivation depth | 64 |
| Max work queue size | 100 000 |
| Max terminals | 100 000 |
| Max ops per rule | 1 024 |
| Max split slots | 256 |
| Max comp cases | 32 |
| Max rule variants (stochastic) | 64 |
| Max identifier length | 64 chars |
Exceeding a limit returns Err(ShapeError::CapacityOverflow) or
Err(ShapeError::DepthLimitExceeded) rather than panicking.
The genetics module provides ShapeGenotype, a wrapper around the rule table
that implements symbios_genetics::Genotype. Plug it into any
symbios-genetics algorithm (SimpleGA, NSGA-II, MAP-Elites) to evolve
procedural building grammars.
use symbios_shape::genetics::ShapeGenotype;
use symbios_genetics::Genotype;
use rand::SeedableRng;
use rand_pcg::Pcg64;
let dna = ShapeGenotype::from_interpreter(&interp);
let mut rng = Pcg64::seed_from_u64(42);
let mut child = dna.clone();
child.mutate(&mut rng, 0.3); // Gaussian jitter on parametric floats
let offspring = dna.crossover(&child, &mut rng); // BLX-α blending- OBB scopes — all geometry is Oriented Bounding Boxes; no mesh boolean ops
- BFS queue — breadth-first expansion; depth is tracked per work item
- Pure derivation — the engine produces a
ShapeModel(a flat list of terminals); rendering is the caller's responsibility - Serde support —
Scope,ShapeOp,ShapeModel, and all sub-types implementSerialize/Deserialize
MIT