This is the original design doc for Maquette, written before any code. Maquette shipped a v0 and then became Touch, an interactive editor. The text below is that original design with the language tidied up. The architecture it describes is now Touch's headless core, so where it says "the system does X", read it as the design intent at the time.
Maquette was a command-line CAD generator. You described a part in natural language and got back a parametric model you could hand-edit in your CAD tool of choice. It was free and open by default, with Siemens NX supported as an optional output target for people who already had a seat.
The point it set out to prove was that an AI model can turn a sentence into correct, editable parametric geometry, not just a mesh. The v0 proved that. What it could not do was handle position and ambiguity from text alone, which is why the project became Touch.
Goals
- Prompt to editable part. Natural language in, parametric solid out, manual refinement in a real CAD GUI.
- Two backends, one core. The default output was build123d (free, headless, scriptable). The optional output was Siemens NX Open Python journals, which give a full feature tree in the Part Navigator.
- Repo stays free and decoupled. Nothing in the repo imported or depended on NX. The NX adapter only emitted code against the public NX Open API.
- Bounded automation. A worker and evaluator loop with a strict structured intermediate (Intent) to keep the model on track.
High-level flow
Repo layout
maquette/
├── README.md
├── ARCHITECTURE.md # this file
├── pyproject.toml # build123d, pyvista, anthropic, pydantic, typer
├── .env.example # ANTHROPIC_API_KEY
├── src/maquette/
│ ├── intent.py # pydantic schema: the structured design spec
│ ├── agent/
│ │ ├── planner.py # prompt to Intent
│ │ ├── worker.py # Intent to build123d code
│ │ ├── executor.py # runs code, exports STEP + PNG renders
│ │ ├── evaluator.py # vision model: renders vs prompt
│ │ └── loop.py # orchestrator
│ ├── adapters/
│ │ ├── build123d_target.py # default, runs in-repo
│ │ └── nx_open_target.py # emits NX Open Python; imports nothing from NXOpen
│ ├── render/ # headless PyVista
│ └── cli.py # `maquette design "..."`
├── prompts/ # system prompts + few-shot examples (versioned)
├── examples/ # known-good sessions, regression cases
├── output/ # generated artifacts (gitignored)
└── tests/
Core concept: Intent as the pivot
The model did not emit CAD code directly. It first emitted a typed Intent
describing the part, and adapters translated that Intent into backend code.
This gave:
- Validation before execution. Pydantic caught a lot of errors before any geometry was built.
- Multiple backends from one generation. The same Intent compiled to build123d and to NX Open.
- A regression corpus that survived library churn.
- A natural unit of caching. The same Intent gives deterministic output.
Sketch:
# src/maquette/intent.py
from pydantic import BaseModel, Field
from typing import Literal
class Parameter(BaseModel):
name: str
value: float
unit: Literal["mm", "cm", "m", "in"]
class PrimaryFeature(BaseModel):
kind: Literal["box", "cylinder", "sphere", "extrude", "revolve", "loft"]
params: dict[str, float]
class Modifier(BaseModel):
kind: Literal["hole", "fillet", "chamfer", "shell", "pattern"]
params: dict[str, float | str]
target: str | None = None # feature id this applies to
class Intent(BaseModel):
name: str
description: str
parameters: list[Parameter] = Field(default_factory=list)
features: list[PrimaryFeature]
modifiers: list[Modifier] = Field(default_factory=list)
extras: str | None = None # escape hatch: raw build123d for things the schema can't expressThe Intent was kept deliberately small. Anything it could not express went into
extras as raw code. The rule was to resist modelling all of CAD up front.
Generation loop
The roles in the loop:
- Planner. Natural language prompt to
Intent, in strict JSON mode against the schema. - Worker.
Intentto build123d Python, templated with the schema as guardrails. - Executor. Ran the Python in a subprocess, exported STEP, and rendered three orthographic PNGs with PyVista.
- Evaluator. A vision model that took the original prompt, the current Intent, and the three renders, and returned pass or fail plus a structured critique (which dimension was wrong, what was missing).
- Refiner. Fed the critique back into the worker for another pass.
- Loop until the evaluator passed, the iteration limit was hit, or the budget ran out.
- Emit. The final
output/<name>.stepplusoutput/<name>_nx.py, the NX journal.
NX handoff
The NX adapter was a pure code generator. It imported nothing from Siemens and
only emitted a .py file against documented NX Open API patterns.
The workflow on the laptop:
- The repo produced
output/part.pyandoutput/part.step. - In NX, open File > Execute > Journal and pick
part.py. - The features appeared in the Part Navigator, fully parametric.
- Edit by hand from there.
A possible later add-on, never committed to the repo, was a small NX journal that
watched output/ and ran new files automatically. That stayed local, as
licensed-tool glue rather than project code.
What v0 shipped, and what came next
v0 (shipped first)
- Intent schema (minimal, around 10 fields)
- Planner and worker, no evaluator loop yet
- build123d execution and STEP export
- NX Open adapter (basic features only)
- CLI:
maquette design "a 50mm cube with a 20mm hole through the center"
Planned for v0.1
- Vision-based evaluator and refinement loop
- Render bundle (3 axes plus isometric)
- Examples and regression suite
Planned for v0.2
- Conversational mode (multi-turn refinement before commit)
- Parameter sliders after generation (regenerate with tweaked dimensions, no model call)
- Expanded Intent schema based on what
extraswas being used for most
Possible later
- FreeCAD adapter (parallel to NX, also free)
- Assembly support (multi-part)
- Constraint solving and mate inference
The v0.2 line about conversational, multi-turn refinement is roughly where the project turned into Touch. The need to point at geometry and ask clarifying questions could not be met from a single text prompt, so the interface itself had to change.
Open design decisions at the time
- Intent strictness. A loose schema means more freedom for the model and more failures. A strict one is robust but constrained. The plan was to lean strict and use
extrasas the pressure valve. - Single-shot vs loop in v0. The plan was to ship single-shot, measure the failure rate, then add the loop in v0.1 with data on what it needed to catch.
- Model provider. Default to one provider for v0 (Claude is strong at constrained code generation), with the client abstracted so swapping is cheap, but without building a provider-agnostic layer up front.
- State store. Filesystem as state machine fits this naturally. Each generation was a folder under
output/with intent.json, code.py, renders, the STEP file, and the NX journal. No database needed.
Non-goals
- Replacing CAD. The system was a fast first-draft generator. Final engineering judgement stays human.
- Closed-loop assembly generation. Single parts only in v0.
- Mesh-only output. STEP and B-rep only; meshes are a dead end for editable CAD.
- Web UI. CLI first.
License and repo hygiene
- Code under a permissive license (MIT or Apache-2.0).
- Never commit recorded NX journals from a licensed session. The patterns the adapter emits are written from scratch against public docs.
- API keys via
.env, never in prompts or examples. output/gitignored;examples/holds hand-curated reference sessions only.