A small shape-grammar engine for procedurally generating 3D buildings, with a Qt + OpenGL 4.1 viewer to display them. You write rules saying how a face breaks down (floors, then rooms, then window strips), and the engine derives the geometry from that. The auto-loaded skyline.txt builds two rows of five mixed-style city towers along a 30-unit street; default.txt is the original showpiece, a multi-tier setback tower at roughly 70k surface operations.
This is the final version of the project. The earlier 2D proof-of-concept is at DonBeleren/split-grammar-2d.
skyline.txt: two rows of five city towers along a street with sidewalks, lamp posts, and lane markings.
cityCenter.txt: tiered central skyscraper with cantilevered balconies, ringed by four corner towers on a raised plaza.
searsTower_textured.txt: 9-block bundled-tube geometry rendered through the texture pipeline rather than the windowed-strip grammar.
The pipeline runs from text source to rendered geometry:
- A custom DSL with a Flex lexer and a Bison parser. Source compiles to an AST (
base_qt/src/Ast.h), and a tree-walking interpreter (base_qt/src/Interpreter.{cpp,h}) executes it. - A shape-grammar engine on top of that.
rule Name { … }definitions, an implicit "current shape" stack, andsplit(axis, pcts) { ChildRule* }that chains surface splits and dispatches the named rule for each piece. Rules can take one user parameter (the iteration index) and can invoke other rules with the parent shape inherited. - Split-grammar primitives: axis-aligned face subdivision with explicit per-cut percentages, per-face color and texture, and group-based concatenation of multiple blocks into one renderable model.
- Three primitive types.
boxis the standard cube.gableis a pitched roof with its ridge along Z (5 faces).hipis a 4-sided pyramid roof along the longer footprint axis (5 faces). Roof faces are colorable and texturable but not splittable; their triangular ends would degenerate. - A 3D viewer using Qt 5 and OpenGL 4.1 core profile, 4x MSAA, with an orbit camera, auto-orbit, and frame-locked WASD pan.
- An in-app live editor. The CodeEditor dock re-parses and re-renders as you type. Auto-render is debounced at 900 ms, with an immediate render on cursor-line-change or Enter, plus a 2.5 s dwell timer that surfaces a non-modal red banner if the syntax stays broken. AST diff plus checkpointed snapshots make incremental edits cheap.
- Three viewport overlay buttons at the top-left of the GL widget: Editor (toggle the editor dock), Blank Editor (open the editor on
editor_starter.txt's floor), and Import (file dialog rooted atassets/scripts/). The old welcome dialog is gone; the app auto-loadsskyline.txton launch. - OBJ export. The editor toolbar has an "Export as OBJ" button that writes the current geometry as a Wavefront OBJ (positions, a per-vertex color extension, triangulated faces). The file dialog defaults to your Downloads folder.
- A loading panel with a real progress bar. The parse-and-interpret phase counts Building::* operations against a static AST estimate, and the GPU upload phase reports bytes uploaded as it streams chunked
glBufferSubDatacalls. Both drive a determinateQProgressBar. A linger timer holds the completed state for about 600 ms before hiding. The panel only fires for full file loads; live edits stay silent.
Edifice is a working split-grammar implementation in the Wonka 2003 / CGA-shape tradition, but a small one. Compared with a production tool like Esri CityEngine or Houdini's procedural modelers, it's missing:
- Relative-weight splits. There's no
{ ~1 : Wall | ~3 : Window | ~1 : Wall }syntax; you supply explicit per-cut percentages of the parent dimension. - Scope transformations. No
rotate,scale,translate, and no nested coordinate frames. Splits are axis-aligned in world space. - Component splits. You can't write one rule that decomposes a solid into its faces and applies a per-face rule. Each face is listed at the call site (
Face(b, front, x); Face(b, back, x); …). - Stochastic rules and weighted alternatives. No
Floor → 30% Plain | 70% Windowed. Branching isif/elseon the rule's index parameter. - Recursive termination. Rules can recurse, but there's no scope-shrinking guarantee or built-in termination check.
- Symbol or labeled-shape matching. Dispatch is by literal rule name, not by structural pattern matching against the workspace.
- Cross-platform builds. Visual Studio with Qt 5 on Windows only. The
.vcxprojinvokeswin_flexandwin_bisonon every build to regenerate the parser.
The scripting language is illustrated by the bundled scripts in base_qt/assets/scripts/. Bare identifiers that aren't bound to a variable are treated as their own literal name, so front, box, Y, top work as enum-like tags without quoting.
These are what you reach for when writing a new building.
| Construct | Form |
|---|---|
| Rule definition | rule Name { body } or rule Name(param) { body } |
| Multi-split + dispatch | split(axis, pcts) { ChildRule* } or { ChildRule*(idx) } (passes the piece index to the child) |
| Set the current group | useGroup(name) (call once before any rule invocation) |
| Apply current shape's color | color([r, g, b]) |
| Apply current shape's texture | texture(name) |
| Read current room-split axis | faceAxis() (returns "x" or "z" depending on which face the parent was) |
| Top-level rule call | RuleName(block, face, axis) (params, block, face, axis) |
| Inline rule call | RuleName() from inside another rule body. Inherits the parent shape |
These run underneath the rule layer. The grammar features lower to these. You can mix them; plain for loops calling multiSplit directly are valid.
| Construct | Form |
|---|---|
| Variable declaration | let x = expr (alias: var) |
| Assignment | x = expr (auto-declares if new) |
| Array literal | [a, b, c] |
| Array index | arr[i] |
| Range (half-open) | lo..hi. Yields [lo, lo+1, …, hi-1] |
| For loop | for x in 0..N { … } or for x in arr { … } |
| If / else | if cond { … } else if cond2 { … } else { … } |
| Block | { stmt stmt … } (whitespace-separated, no semicolons) |
| User function | fn name(p1, p2) { body } (top-level only, no closures, no return values) |
| Method call | receiver.callee(args) (the interpreter prepends receiver as arg 0) |
| Tuple assignment | (a, b) = surfaceSplit(group, block, face, axis, pct) |
| Comment | // to end of line or /* … */ |
Operators: + - * / %, < > <= >= == !=, && || !, unary -. Division always yields a Double.
Statement-level (drive Building::*):
| Call | Form |
|---|---|
| Create block | name = obj(box, x, y, z, w, h, d) (also obj(gable, …) and obj(hip, …)) |
| Make group | makeGroup(name) |
| Insert into group | group.insert(block) |
| Surface split | (child1, child2) = surfaceSplit(group, block, face, axis, pct) |
| Set color | setColor(group, block, face, r, g, b) |
| Set texture | setTexture(group, block, face, texture). Applies a named texture to one face |
| Set name | setName(newName, block, face) |
Expression-level:
| Call | Form |
|---|---|
format |
format("%s%sf%02d", block, key, n). printf-style; supports %s %d %i %f %g %e plus widths and %02d-style flags |
len |
len(arr) or len(str) |
multiSplit |
multiSplit(group, block, face, axis, pcts[]). Chains N surfaceSplits and returns an array of N+1 piece names |
Standard cubes have faces named bottom / front / back / left / right / top. The pitched-roof primitives use different names. The axes correspond to the constructor's length / width / height arguments, which map to X / Z / Y respectively.
obj(gable, x, y, z, length, width, height):bottom,slopeLeft,slopeRight(the two pitched quad sides facing ±X),gableFront,gableBack(the triangular ends at ±Z).obj(hip, x, y, z, length, width, height):bottom,eaveFront,eaveBack(front and back faces, trapezoidal whenlength > width, triangular otherwise),hipLeft,hipRight(left and right faces, the converse). Whenlength == widththe ridge collapses to a point and you get a square pyramid.
let FLOOR_PCTS = [0.05, 0.052, …, 0.5] // 19 percentages → 20 floors
let WIN_PCTS = [0.1, 0.12, 0.22, 0.26, 0.4, 0.5]
let DARK = [0.15, 0.15, 0.15]
let WIN = [0.68, 0.85, 0.92]
makeGroup(complex)
useGroup(complex)
plaza = obj(box, 0, 0, 0, 140, 140, 1)
core = obj(box, 46, 12, 46, 48, 48, 30)
complex.insert(plaza)
complex.insert(core)
rule Face { split(Y, FLOOR_PCTS) { Floor* } }
rule Floor { split(faceAxis(), ROOM_PCTS) { Room*(idx) } }
rule Room(r) { if r % 2 == 0 { color(DARK) } else { WindowedRoom() } }
rule WindowedRoom { split(Y, WIN_PCTS) { Strip*(idx) } }
rule Strip(k) { if k % 2 == 0 { color(DARK) } else { color(WIN) } }
Face(core, front, x)
Face(core, left, z)
Top-level rule calls take (block, face, axis) as their last three args. axis is the room-split axis appropriate for the face's orientation. Once you're inside a rule, the shape is implicit, so Floor, Room, WindowedRoom, and Strip all read it from the stack.
- A
nullblockName insetColor(group, null, face, r, g, b)is a wildcard. It applies the color to every face namedfacein the group. (nullis just an unbound bare identifier, which evaluates to the string"null".) - Faces are consumed by splits. Once a face has been split into pieces, the original name is gone. Any later reference to it is an error and triggers an access violation downstream (see
getFaceIndexreturning -1). - Roof faces are not splittable.
surfaceSplit(and the shape-grammarFace(...)built on top of it) refuses to split any face on agableorhipblock, since those faces are triangular or trapezoidal and the splitter can't decompose them. Color and texture work fine. - Rule child param naming. In
Room*(idx), theidxplaceholder is decorative. The index actually binds to the rule's first declared parameter, sorule Room(r)puts the index inr, regardless of what you write inside the parens at the call site. - Auto-stringification of bare identifiers is convenient but silent. A typo like
setColor(tower, block, frnt, …)becomessetColor(..., "frnt", …)rather than a parse error. The error surfaces later whenBuilding::*can't find a face by that name.
Edifice ships a small catalog of procedurally-generated 256x256 textures in base_qt/assets/textures/. They're loaded into a single GL_TEXTURE_2D_ARRAY at GL init, per-face boxMaterial indices select the layer, and the fragment shader samples them via planar projection from world position so faces tile automatically. Faces with boxMaterial < 0 fall through to the per-vertex color (fragColor, set by setColor and the grammar's color() rule).
History: earlier on the developer's environment (NVIDIA RTX 4090, driver 595.97, Qt 5, OpenGL 3.3 Core) any
texture()/textureLod()/texelFetch()call returned NaN despite verifiably correct binding state and texture data, and the shader had to ignoreboxMaterialentirely. Two changes fixed sampling: the GL context was bumped to 4.1 (a different driver code path, and the highest version macOS supports if a Mac port is ever needed), and the texture-load path was rebuilt againstQOpenGLTextureso binding goes through Qt's context-bound function dispatch.
Generate or regenerate the PNGs with:
pip install Pillow
py tools/gen_textures.pyCatalog (use the stem as the name argument to setTexture / texture):
| Walls | Windows |
|---|---|
wall_brick_red, wall_brick_brown |
window_grid_2x2, window_grid_3x3, window_grid_4x2 |
wall_concrete, wall_stucco, wall_wood |
window_strip (curtain-wall band) |
wall_metal, wall_stone, wall_grass |
window_arch (classical) |
wall_panel_white, wall_panel_gray, wall_panel_dark, wall_panel_blue, wall_panel_green |
window_circle (porthole) |
Usage:
setTexture(home, lobby, front, wall_brick_red) // imperative
rule WallStrip { texture(wall_brick_red) } // shape-grammar
Working examples that mix colors and textures live at base_qt/assets/scripts/texturedHouse.txt and default.txt.
All under base_qt/assets/scripts/:
| File | What it shows |
|---|---|
skyline.txt |
Two rows of five city towers along a street with sidewalks, lamp posts, and lane markings. Heights run 60..100 and no two towers share a style. The auto-loaded scene at app start. |
cityCenter.txt |
Tiered central skyscraper (brick podium, glass mid, metal spire) on a raised stone plaza, with four cantilevered balconies on the mid-tower and four corner towers framing it. |
default.txt |
Multi-tier 78-unit-tall complex. Plaza, brick lobby with brown-brick wings, four corner pillars and a glass core, two stepped setbacks, metal cap. The original showpiece. |
complexTower.txt |
A copy of default.txt. Useful as an editing baseline so you can tweak a copy without touching the original. |
searsTower_no_texture.txt |
9-block bundled-tube tower (a..i grid) with grammar-driven window strips on every side face. |
searsTower_textured.txt |
Same 9-block geometry as searsTower_no_texture.txt, but rendered through the texture pipeline (window-grid textures on side faces, dark panels on top and bottom) instead of grammar splits. |
simpleHouse.txt |
Small home with a gable roof on the main house, hip roof on the garage, hip roof on the porch, and a brick chimney. Flat colors only. |
texturedHouse.txt |
Two-story house, side wing, porch, detached garage. Uses the texture catalog plus grass on the lawn. |
editor_starter.txt |
Just a 1-block ground plane. Loaded by the Blank Editor overlay button as a fresh canvas. |
Facade.txt.bak.* |
Two .bak snapshots of an earlier hand-rolled 154-line / 36-combo facade script. Kept for reference, not auto-loaded. |
Requirements
- Visual Studio 2017 or newer (the project uses
vc141/ MSBuild) - Qt 5 with the Visual Studio add-in (
QGLWidget,QMainWindow) - An OpenGL 4.1-capable GPU
- Windows only. The
.vcxprojrunswin_flexandwin_bisonon every build to regeneratelexer.cppandparser.cppfromlexer.landparser.y. Both binaries are vendored attools/winflexbison/and the project references them by relative path, so no separate install is needed.
Steps
- Open
base_qt/base_qt.slnin Visual Studio. - Make sure the Qt VS Tools extension points at your Qt install.
- Build the
base_qtproject (Debug or Release, x64 or Win32). - Run. The app maximizes and auto-loads
skyline.txt. Use the three top-left overlay buttons to switch source: Editor toggles the editor dock on the current source, Blank Editor opens the editor witheditor_starter.txt, Import lets you pick another script.
To verify the grammar parser regenerated cleanly with no new ambiguities:
cd base_qt\parser
..\..\tools\winflexbison\win_bison.exe -v parser.y -o parser.cppThat emits parser.output next to the regenerated parser. 1 shift/reduce conflict is expected and benign (the longstanding IDENT / IDENT LPAREN ambiguity, resolved by Bison's default shift, which is what we want).
- Left mouse drag: orbit camera (interactive).
- Right mouse hold: auto-orbit. Camera spins around the focus point at ~37°/s, hands-free; release to stop.
- Mouse wheel: zoom in or out. Scales with distance, so the feel stays consistent at any zoom.
- W / A / S / D: pan the camera target on the screen-aligned plane. 60 Hz frame-locked motion (smooth from the moment you press, no OS auto-repeat delay). Diagonal directions combine.
- Esc: quit.
- Editor / Blank Editor / Import overlay buttons (top-left of viewport): switch source.
- Inside the editor: type freely. Parsing runs silently in the background. Move the cursor to a different line to commit the current line. Sit on a broken line for 2.5 s and a non-modal red banner explains the syntax error.
Edifice/
├── README.md
├── LICENSE
├── .gitignore
├── assets/
│ ├── captures/ # raw screen-capture gifs (gitignored, local-only)
│ └── gifs/ # README gifs (optimized output of tools/optimize_gifs.py)
├── tools/
│ ├── gen_facade.py # emits a flat (loop-free) script form
│ ├── gen_textures.py # generates the wall_*.png / window_*.png catalog
│ ├── optimize_gifs.py # crops + ffmpeg-encodes raw screen-capture gifs
│ └── winflexbison/ # vendored win_flex.exe + win_bison.exe
└── base_qt/ # Visual Studio project root
├── base_qt.sln, .vcxproj # only project files at root
├── src/ # Edifice's own C++
│ ├── main.cpp, MainWindow.{cpp,h,ui,qrc}
│ ├── GLWidget3D.{cpp,h}, Camera.{cpp,h}
│ ├── CodeEditor.{cpp,h} # Qt subclass with line numbers + syntax-error banner
│ ├── Building.{cpp,hpp}, BuildingBlock.{cpp,h}
│ ├── Ast.h # AST node hierarchy for the DSL
│ ├── Interpreter.{cpp,h} # Tree-walker: AST -> Building::* calls,
│ │ # plus rule registry, shape stack, snapshots
│ ├── ParseError.h # Captured parse-failure surface for the editor banner
│ ├── TextureMapping.{cpp,h}
│ └── util.{cpp,hpp}, Utility.h
├── parser/ # Flex/Bison sources + generated artifacts
│ ├── lexer.l / lexer.cpp
│ └── parser.y / parser.cpp (parser.h is emitted to base_qt/ root)
├── third_party/ # Vendored libraries
│ ├── glm/
│ ├── stb_image.{cpp,h}, stb_image_write.{cpp,h}
│ └── gl_core_3_3.{c,h}
└── assets/
├── shaders/ # *.glsl (vertex + fragment)
├── textures/ # wall_*.png + window_*.png procedural catalog
└── scripts/ # skyline.txt (auto-loaded) + the rest of the bundled scripts
Edifice grew out of an earlier 3D building project that I worked on with Zachariah Menzie. About 14% of the code here — mostly the block-geometry core in Building.{cpp,hpp} and BuildingBlock.{cpp,h} — is Zachariah's original authorship from that work, and everything Edifice does still rests on top of it. The DSL, the interpreter, the shape-grammar layer, the live editor, the texture pipeline, the orbit camera, and the procedural texture and script tooling were all written for this rewrite.
Thank you, Zach. You were a great partner on the original, and Edifice doesn't exist without the work we did together.
Edifice itself is released under the PolyForm Noncommercial License 1.0.0. Personal, hobby, educational, research, and charitable use is free; commercial use requires a separate license. Commercial licensing inquiries: doneliezerbaize@gmail.com.
The vendored tools/winflexbison/ binaries carry their own upstream licenses. win_flex.exe is BSD (from westes/flex) and win_bison.exe is GPLv3+ (from GNU Bison). The license summary is preserved at tools/winflexbison/UPSTREAM-README.md. These tools are invoked at build time only and are not linked into the final executable.