Natural language → OpenSCAD parametric CAD, via a fully-local agentic loop.
Describe a part in plain English; a local LLM writes parametric OpenSCAD; OpenSCAD renders and exports it; and compile, STL-export, and local vision-model gates feed any failure back to the model for repair — until the part is correct.
No cloud, no API keys, no telemetry. Codegen runs on a local Ollama model; rendering uses the OpenSCAD binary as an external tool.
"a hexagonal nut, 16mm across flats, 6mm thick, 8mm center hole"
→ LLM writes parametric OpenSCAD (Ollama, e.g. qwen2.5-coder:32b)
→ OpenSCAD renders iso + top views, exports STL
→ [compile gate] [STL-export gate] [vision-model gate]
→ a failed gate feeds specific feedback back → the model rewrites
→ ✓ correct part
On the hex nut above, the model's first attempt was the wrong shape. The vision
gate — judging a top-down orthographic view (examples/hexnut_closedloop_top.png),
because a foreshortened isometric makes small vision models misread a hexagon as
"rectangular" — rejected it with "not hexagonal; irregular polygonal shape". That
critique fed back, and attempt 2 was approved. No human in the loop.
The vision gate judges an orthographic contact sheet — one labelled image, like an engineering drawing — so features on different faces are all legible at once:
All generated locally from a one-line description by qwen2.5-coder:32b:
| Hex nut | Washer | Standoff | Cube + bore | Pulley | L-bracket |
|---|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
The L-bracket is a multi-feature part — two perpendicular legs and a bolt hole through the flat face of each. Getting it right needed the gate work below.
git clone https://github.com/dbhavery/textcad
cd textcad
pip install -e .
# prerequisites
# 1. Ollama running locally with a codegen model: ollama pull qwen2.5-coder:32b
# 2. (optional, for --inspect) a vision model: ollama pull qwen2.5vl:7b
# 3. an OpenSCAD binary on PATH, or set $TEXTCAD_OPENSCAD,
# or drop a portable build under tools/openscad-*/# compile-only loop (fast; accepts the first part that compiles and exports)
textcad "a flat washer, 24mm outer, 10mm hole, 2.5mm thick" --model qwen2.5-coder:32b
# full closed loop with the local vision inspector
textcad "a hexagonal nut, 16mm across flats, 6mm thick, 8mm center hole" \
--model qwen2.5-coder:32b --inspect qwen2.5vl:7b --iters 4
# compare codegen models on a fixed part set
python scripts/bench.py qwen2.5-coder:32bOutputs: out/<name>.scad, out/<name>.png (iso), out/<name>_top.png
(top-down, for the inspector), out/<name>.stl.
textcad is local by default (--backend ollama). For hard multi-feature parts
you can opt into a frontier model via its subscription CLI — no API key:
textcad "an L-bracket, 2 perpendicular legs, a 5mm hole in each leg" --backend claude
textcad "..." --backend codex # OpenAI Codex CLIThe local 32B coder needs heavy prompting for multi-feature geometry and still slips (~2/3 correct); a frontier backend places it correctly zero-shot, one attempt. Backends run from a neutral temp dir so the agent doesn't inherit your project context. Default stays Ollama — nothing leaves your machine unless you ask for it.
Each iteration runs the gates in order; a failure feeds targeted text back to the next generation:
- Compile — OpenSCAD must render a preview; the compiler
stderrfeeds back. - STL export — must produce a non-empty STL. Catches mixed 2D/3D and non-manifold geometry that previews fine but won't export.
- Visual inspector (optional,
--inspect) — a local VLM judges the part against the request and rejects valid-but-wrong shapes (a round disc when a hexagon was asked for) that the first two gates can't see. It inspects an orthographic contact sheet — top, front and side views composited into one labelled image (like an engineering drawing). This matters: a small VLM misreads a foreshortened isometric, and gets confused by several separate images, but reads one labelled multi-panel sheet reliably — enough to catch a missing perpendicular leg or a wrong cross-section. (Contact sheets need Pillow:pip install textcad[inspect].)
from textcad import run, make_vlm_inspector
result = run(
"a hexagonal nut, 16mm across flats, 6mm thick, 8mm center hole",
model="qwen2.5-coder:32b",
inspector=make_vlm_inspector("qwen2.5vl:7b"), # omit for compile-only
iters=4,
)
print(result["stl"], result["attempts"])Every layer takes an injectable backend (llm=, inspector=), so the loop is
unit-tested with no Ollama and no OpenSCAD — see tests/.
textcad/
llm.py minimal local-Ollama client (text + vision, multi-image)
codegen.py system prompt + OpenSCAD code generation
render.py OpenSCAD invocation: iso/ortho views + STL + contact sheet
inspector.py local vision-model judge (orthographic contact sheet)
loop.py the agentic loop + the three gates
cli.py `textcad` command
scripts/bench.py fixed-part-set model comparison
tests/ mock-backed gate-logic + codegen + inspector tests
- The closed loop is verified end-to-end and fully local — single-feature parts (hex nut, washer, standoff, cube+bore) and now a multi-feature L-bracket (two perpendicular legs, a bolt hole through each leg's flat face).
- Multi-feature generation needed prompt engineering + a helper, not a bigger
model. A smaller
qwen3:14bmakes wedges and skipsdifference();qwen2.5-coder:32bdoes the geometry but originally botched multi-body parts by assembling withcenter=true+rotate(disconnected "plus" shapes) and ran bolt holes along a plate's length. Fixes, in order of impact:- corner-based placement rule + a worked L-bracket example → reliable gross shape (a real L, not a plus);
- a
plan-in-commentsstep so the model reasons about coordinates first; - an injected
thru_hole(pos, axis, d)helper prepended to every file — the model calls it and only chooses the axis (the plate's thin dimension) and a centred position, so it can't get the error-pronerotate/length/centermechanics wrong. This took the both-holes-correct rate from ~1/3 to ~2/3. At ~2/3 correct per attempt, the agentic loop reliably converges on a correct part.
- Inspection is solved for multi-feature parts via the orthographic contact sheet: where a 7B VLM failed on both a single isometric and a raw 3-image call, it correctly approves a real L-bracket and rejects a flat L-plate from one labelled contact sheet.
- A larger VLM (
qwen2.5vl:32b) is unsupported on the current Ollama (0.30.10 is the latest; it fails to load that model's vision encoder) — but the contact-sheet trick made the small 7B sufficient, so it isn't required.
Still-open edge: the coder's last weak spot is which axis the upright-plate hole takes (~1 in 3 swaps it); the helper fixes the mechanics, not that semantic choice. Next steps: more part archetypes in the prompt (or a template library), and a parameter-slider UI over the named OpenSCAD vars.
MIT — see LICENSE. textcad is a clean-room project: it reproduces a general pattern (LLM writes CAD code → render → inspect → iterate) but contains no third-party source. The OpenSCAD binary is invoked as an external tool, so textcad is an independent work and inherits no GPL obligations.







