I've been burning evenings on a weird little side project: getting language models to spit out OpenSCAD code for parametric architectural models. Simple brief, right? "Generate a 3m x 4m room with a window centered on the south wall." The first time I tried this, the model returned 80 lines of confident-looking code that, when rendered, produced what I can only describe as abstract art shaped like regret.
If you've ever tried to use LLMs for procedural 3D generation, you've probably hit the same wall. The code parses. It even renders. But the geometry is wrong in subtle, infuriating ways — walls float, windows clip through floors, rotations go the wrong direction. Let me walk through why this happens and what's actually worked for me.
The Symptom: Code That Lies
Here's a real example of the kind of output I kept getting when I asked for "a simple house with a pitched roof":
// Floor
cube([10, 8, 0.2]);
// Walls
translate([0, 0, 0.2])
difference() {
cube([10, 8, 3]);
translate([0.2, 0.2, 0.2])
cube([9.6, 7.6, 2.8]);
}
// Roof
translate([0, 0, 3.2])
rotate([0, 45, 0]) // <-- this is the bug
cube([10, 8, 0.2]);
Looks fine. Renders without errors. But that roof? It's rotated around the wrong axis and offset from origin, so it ends up slicing through the wall like a guillotine. The model knew the words "pitched roof" and "rotate." It did not know what those words mean in 3D space.
Root Cause: Spatial Reasoning Is Not Text Reasoning
After migrating a few prompting strategies and reading through the OpenSCAD documentation for the nth time, I think there are three distinct failure modes happening here:
1. Coordinate frame confusion
OpenSCAD uses a right-handed coordinate system where Z is up. Many tutorials online treat Y as up (Blender, Unity habits leaking in). The training data is a mishmash of conventions, so the model averages them and produces nonsense. Rotations compound the problem because rotate([x, y, z]) applies Euler angles in a specific order that's easy to get backwards.
2. No mental model of CSG operations
Constructive Solid Geometry — union, difference, intersection — requires the model to track what is solid and what is void at every step. Language models don't maintain that state. They pattern-match on "difference means subtract" but lose track of which shape is the minuend after two or three nested operations.
3. Module composition gets recursive fast
Real architectural OpenSCAD code uses modules with parameters. Once you nest two parametric modules and apply transforms at each level, the model loses track of which coordinate space it's in. I've seen output where a window module assumed local coordinates while the wall module passed it world coordinates. Result: window in the parking lot.
The Fix: Constrain the Generation Surface
The single biggest improvement came from not asking the model to write OpenSCAD directly. Instead, I have it produce a structured intermediate representation, then transform that into OpenSCAD with deterministic code. Here's the pattern:
# Step 1: model produces JSON, not OpenSCAD
schema = {
"walls": [
{"start": [0, 0], "end": [10, 0], "height": 3, "thickness": 0.2}
],
"openings": [
{"wall_index": 0, "type": "window",
"position": 5.0, # distance along wall from start
"width": 1.2, "height": 1.0, "sill": 0.9}
]
}
Then a small Python script walks the JSON and emits OpenSCAD. The model doesn't need to reason about coordinate frames anymore — it just needs to think in terms of "this wall runs from here to here." That's a 2D problem, which LLMs handle dramatically better than 3D.
def emit_wall(wall):
dx = wall['end'][0] - wall['start'][0]
dy = wall['end'][1] - wall['start'][1]
length = (dx**2 + dy**2) ** 0.5
# angle in degrees, atan2 handles all quadrants correctly
angle = math.degrees(math.atan2(dy, dx))
return (
f"translate([{wall['start'][0]}, {wall['start'][1]}, 0])\n"
f" rotate([0, 0, {angle}])\n"
f" cube([{length}, {wall['thickness']}, {wall['height']}]);\n"
)
The transform code is boring, deterministic, and testable. The LLM does the creative part (where do the walls go?) and the code does the brittle part (how do I rotate this correctly?).
Validate Before You Render
The second fix: never trust the output. I run every generated model through a validation pass before opening it in the viewer. A few checks that catch most failures:
def validate_model(model):
errors = []
# Walls should form closed loops for rooms
for room in model.get('rooms', []):
if not is_closed_polygon(room['walls']):
errors.append(f"Room {room['id']} has open walls")
# Openings must fit in their parent wall
for opening in model['openings']:
wall = model['walls'][opening['wall_index']]
wall_length = distance(wall['start'], wall['end'])
if opening['position'] + opening['width'] > wall_length:
errors.append(
f"Opening at wall {opening['wall_index']} "
f"extends past wall end"
)
# Sill + height must not exceed wall height
if opening['sill'] + opening['height'] > wall['height']:
errors.append("Opening taller than wall")
return errors
When validation fails, I feed the errors back to the model and ask it to revise. This loop catches maybe 90% of the geometric nonsense before it ever becomes OpenSCAD.
Prevention Tips
A few things I wish I'd known three weekends ago:
- Avoid free-form OpenSCAD generation for anything non-trivial. Use a constrained intermediate format and a deterministic transformer. The model's spatial reasoning collapses past about 20 lines of geometry.
- Specify units explicitly in your prompts. "3 meters" gets interpreted as 3 OpenSCAD units, but the model occasionally throws in millimeters mid-file. Pick one and enforce it in your schema.
-
Use
$fnconsistently. Models love to vary the facet count between objects, leading to weird visual artifacts where one cylinder is smooth and the adjacent one is hexagonal. Set it globally at the top of every generated file. -
Render in headless mode for validation. OpenSCAD has a CLI:
openscad -o output.stl input.scad. If it produces a degenerate mesh (zero volume, non-manifold), that's a strong signal something is wrong even if the code parsed. - Keep examples in your prompt. A few high-quality OpenSCAD snippets in the system prompt do more for output quality than any amount of verbal instruction. The model needs to see the convention you want.
The broader lesson, which applies well beyond OpenSCAD: when a model is bad at something, don't try to make it better at that thing. Reduce the problem until what's left is something the model is already good at. Text-to-JSON it can do. Coordinate-frame algebra it cannot. Let the deterministic code own the parts that need to be exactly right.
Top comments (0)