Skip to content

DonBeleren/edifice

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Edifice

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.

What it is

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, and split(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. box is the standard cube. gable is a pitched roof with its ridge along Z (5 faces). hip is 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 at assets/scripts/). The old welcome dialog is gone; the app auto-loads skyline.txt on 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 glBufferSubData calls. Both drive a determinate QProgressBar. 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.

What it isn't

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 is if / else on 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 .vcxproj invokes win_flex and win_bison on every build to regenerate the parser.

DSL quick reference

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.

Shape-grammar features

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

Lower-level primitives

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.

Building::* primitive calls

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

Roof primitives: face 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 when length > width, triangular otherwise), hipLeft, hipRight (left and right faces, the converse). When length == width the ridge collapses to a point and you get a square pyramid.

Worked example (excerpt from default.txt)

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.

DSL gotchas

  • A null blockName in setColor(group, null, face, r, g, b) is a wildcard. It applies the color to every face named face in the group. (null is 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 getFaceIndex returning -1).
  • Roof faces are not splittable. surfaceSplit (and the shape-grammar Face(...) built on top of it) refuses to split any face on a gable or hip block, 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), the idx placeholder is decorative. The index actually binds to the rule's first declared parameter, so rule Room(r) puts the index in r, 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, …) becomes setColor(..., "frnt", …) rather than a parse error. The error surfaces later when Building::* can't find a face by that name.

Textures

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 ignore boxMaterial entirely. 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 against QOpenGLTexture so binding goes through Qt's context-bound function dispatch.

Generate or regenerate the PNGs with:

pip install Pillow
py tools/gen_textures.py

Catalog (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.

Bundled scripts

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.

Building

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 .vcxproj runs win_flex and win_bison on every build to regenerate lexer.cpp and parser.cpp from lexer.l and parser.y. Both binaries are vendored at tools/winflexbison/ and the project references them by relative path, so no separate install is needed.

Steps

  1. Open base_qt/base_qt.sln in Visual Studio.
  2. Make sure the Qt VS Tools extension points at your Qt install.
  3. Build the base_qt project (Debug or Release, x64 or Win32).
  4. 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 with editor_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.cpp

That 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).

Controls

  • 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.

Project layout

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

Acknowledgments

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.

License

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.

About

Procedural 3D buildings from a custom DSL: Flex/Bison-parsed shape-grammar rules drive split, group, color, and texture ops in a Qt + OpenGL viewer with a live in-app editor.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors