**This PR is the 0.7.19 release** (`scripts/release/bump-version.sh
patch`): merging it runs Publish Release. Its receiver changes under
`apps/api` deploy on the same merge through Deploy API, minutes before
PyPI has 0.7.19, so schema 4 is read before any client sends it.
Fixes for what PostHog's first day of telemetry showed (2026-10-08
00:14Z to about 21:40Z: about 209 installs and 59 crash reports). It
covers three bugs people are hitting, crash reports that were not
cadgen's bugs, and gaps in what the receiver lets us see. There is one
commit per fix.
## Bugs
**1. Builds that export a mesh crashed on Windows** (7 installs, all
Windows, about 26 crashes). `mesh_export.py` ran the Node exporter with
`text=True` and no encoding, so Windows read its UTF-8 output in the
local code page. The exporter's JSON report names every output path, so
any output folder whose name the code page cannot read (for example
`Рабочий стол` under cp1252, or most Chinese text under cp936) made
CPython's Windows output reader die quietly. `proc.stdout` came back
`None`, and `.splitlines()` raised an `AttributeError`. The exporter now
reads `utf-8` with `errors="replace"`, which keeps the JSON line intact.
The same fix goes into `run_node_builder`, whose input was also silently
empty under cp1252. ffmpeg, `gz sdf` and `doctor` now read `utf-8` with
`errors="backslashreplace"`, and doctor's child process is set to
`PYTHONIOENCODING=utf-8`. The tests force subprocess's default encoding
to cp1252, and both fail without the fix.
**2. `cad_file` failed on 48 of 49 calls on Windows** (5 of 6 installs).
Codex for Windows names a file opened from its file tree as
`openai/resource.path = "/C:/Users/…"`, read from the desktop bundle.
Python 3.13's `ntpath.isabs("/C:/…")` is False, so every call answered
"not an absolute path". The `file.resourceUri` alongside it is a
`codex-resource://` handle, so the fallback never helped. A new
`local_path` drops the slash before a drive on Windows, both for file
URIs and for plain paths, for `cad_file`, `cad_open` and `cad_show`.
This most likely also explains Antigravity's `cad_show` failures on
Windows (7 of 12). The Windows CI job now passes the path the way Codex
spells it.
**3. `cad_screenshot` failed on 30% of calls** (11 of 19 installs). The
most likely cause is an agent capturing straight after build, show or
open, while the view is still loading or has not synced yet. The view
refused with "Wait for the displayed model revision to finish loading",
"That viewer is not open" or "No CAD viewer with a model is open", or a
large model ran past the fixed 10 s wait.
- The page now waits until the view shows the requested model, loaded
and drawn (`CAPTURE_SETTLE_MS`, 20 s).
- The server waits for a view it just opened to sync (`OPENING_SECONDS`,
15 s) within one budget for the whole capture (`CAPTURE_SECONDS`, 40 s).
- The capture's reply still goes on its own call (`void answer(event)`),
so no view call is held open.
## Crash reports that were not cadgen's bugs
- **Windows viewer disconnects.** `ConnectionAbortedError` (WinError
10053) made up most of the crash volume: 23 installs. The viewer caught
only `BrokenPipeError` and `ConnectionResetError`, and the header write
had no guard. Every write to the socket now treats any `ConnectionError`
as the page having left.
- **A model's own mistakes.** A build123d name that does not exist,
raised through the `cadgen.build123d` re-export, and a non-string passed
to `srgb()`. Both now raise deliberately, so the existing rule counts
them as the person's error, and `srgb` raises a `TypeError` naming what
it was given.
- **Stopped workers.** A worker stopped by SIGTERM, SIGINT or SIGHUP (a
person quitting it, a logout) now counts as cancelled, not crashed.
SIGSEGV, SIGABRT and SIGKILL are still reported.
## Telemetry: what we can now see
- **Why a tool call failed.** There is a new `tool_failure {tool,
reason, count}` event in batch schema 4, which PostHog receives as
`tool_failed`. The reason is one word from a fixed list (`no_path`,
`relative_path`, `no_file`, `not_cad`, `no_view`, `wrong_view`,
`bad_request`, `timeout`, `view_error`, `too_large`, `no_viewer`, `bug`,
`other`), chosen where the call fails and never taken from a message. A
test checks that every `ToolFailed` and `NoAnswer` names one.
- **Rollout: the receiver goes first.** The API is its own Vercel
project now (#587) and deploys on merge to `main`, so merging this PR
puts the schema 4 receiver live before any release sends schema 4. A
refused batch is dropped, as before; there is no fallback in the client.
- **Refused batches are logged.** Each 400, 403 or 415 is one
`console.warn` line naming the rule that failed and the cadgen version.
Values, install ids and service messages are never logged. Vercel's
per-status counts need Observability Plus, so this is the only way to
see a refusal. The privacy policy says so.
- **Errors are logged by name**, for example `TimeoutError` instead of
`23`. A `/v1/forget` timed out at 17:02Z, and the client retries it.
- **`$session_id`** is now set, so error tracking can count sessions.
Our ids are UUIDv4, so PostHog's sessions table leaves them out; error
tracking should still read them, which needs checking after deploy.
Privacy policy, README and `apps/api/README.md` are updated where what
is sent or logged changed.
## Not in this PR
- **Deduplicating a resent batch.** The sender rebuilds a failed window
instead of resending it, and a batch has no id, so there is nothing
stable to dedupe on yet. It needs a per-batch id from the sender.
- **Dashboard totals.** PostHog's error-tracking "occurrences" counts
events, not each event's `count`; for the mesh-export crash that is 5
against 22. That is fixed on the dashboard side (t2c-analytics).
- **5 of 15 DXF builds failed.** DXF builds don't go through Node, so
the encoding fix doesn't cover them and they still need a look.
## Needs a real host
- Windows Codex: open a `.step` from the file tree; capture from a tab
hidden behind another tab.
- Claude Desktop: capture right after `cad_show` on a large STEP, or
while the card waits on Allow.
- Antigravity on Windows: confirm the path spelling it sends.
## Tests
Full suites on this branch, in a provisioned worktree (`.venv` from
`requirements-dev.txt`, `npm ci`, `bundle.sh --check`,
`CADGEN_DAEMON=0`): all pass.
- `scripts/test/test-python.sh --keep-going`: 2,774 tests in 8 groups,
OK.
- `scripts/test/test-js.sh`: every group passes (core, ui, web, mcp).
- `scripts/test/test-docs.sh`: receiver tests 30/30 and the rest 16/16.
- `scripts/test/test-global.sh`: 210 tests, OK (1 skipped).
Each new regression test was run against the old code, and each fails
there.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
17 KiB
name, description, license
| name | description | license |
|---|---|---|
| dxf | Generate, regenerate, and validate 2D DXF drawings from Python build123d sources. Use for DXF files, `.py` drawing scripts, @dxf models, 2D profiles, outlines, templates, gaskets, panels, flat patterns, laser/plasma/waterjet cut layouts, and 2D drawing exports of CAD geometry. Open and visually review existing DXF files in CAD Viewer. | MIT |
DXF generation and validation
Provenance: maintained in earthtojake/text-to-cad. Use the installed local skill files as the runtime source of truth; the repository link is only for provenance and release review.
Setup
Run cadgen through uv, so this skill's commands share one installation, and its warm build daemon, with the CAD app's server:
cadgenbelow meansuvx --no-config --managed-python --python 3.13 --from cadgen==0.7.19 cadgenpythonbelow meansuvx --no-config --managed-python --python 3.13 --from cadgen==0.7.19 python
The first run downloads that installation and the first snapshot its headless browser; later runs reuse both.
cadgen doctor <skill-dir> reports the installation in use and checks that it is
the one this skill pins, and that the CAD kernel loads; use it for installation or
kernel load errors.
Drawings are build123d geometry, so a drawing build loads the CAD kernel like a
STEP build does (~2.5s cold; the warm daemon absorbs it on re-runs).
cadgen dxf snapshot needs no Node at all: it flattens the drawing with ezdxf
(which arrives with cadgen) and paints it in the bundled headless browser.
Purpose
Create or modify 2D DXF drawings from natural-language requirements or from CAD
geometry, generate validated drawing artifacts, and return checked outputs. A
DXF drawing's source of truth is a Python file named <name>.py defining one
parameterless @dxf model function.
A drawing is a model. It has the same wrapper, record, freshness gate and
build job a @step part has; its one output is the .dxf file; it has no
geometry tree (nothing links to a drawing). Every run writes the sibling
<name>.dxf (or the out= the decorator names); an unchanged source is a
no-op; a drawing that calls a part model — bracket() inside its body — is
stale whenever that part's GEOMETRY changes and current when it does not;
cadgen store why <drawing>.py explains the verdict; --force rebuilds it
anyway. The CAD Viewer and dxf snapshot read the .dxf file itself, so the
file you hand a cutting service, the file the viewer draws and the file a
snapshot renders are one and the same.
The contract
A @dxf function takes no parameters and returns build123d 2D geometry. The
engine writes the DXF. You never construct a document, name a file, or place
an entity — the same division of labor @step has.
from cadgen import build123d as bd
from cadgen import dxf
HOLE_D = 4.5
@dxf
def gasket():
with bd.BuildSketch() as cut:
bd.Rectangle(60, 40)
bd.Circle(HOLE_D / 2, mode=bd.Mode.SUBTRACT)
return cut.sketch # bare shape -> the CUT layer
if __name__ == "__main__":
gasket()
- Bare shape → one
CUTlayer. That is the whole contract for most drawings. {layer: shape}→ named layers, when the drawing genuinely has more than one CAM operation (CUT/ENGRAVE/SCORE). ACompoundwhose children are all labelled means the same thing.- No parameters. Dimensions are module constants (
HOLE_D = 4.5) or constants imported from the part the drawing derives from; a different drawing is a different file. - Text is
bd.Text(...)engraved OUTLINES on a marking layer, never a DXFTEXTentity: cut and marking toolchains consume geometry, and font rendering inside CAM is unreliable. - Geometry must lie in the XY plane. A face taken from a solid sits at that
solid's height; relocate it (
flatten.flatten_face(face), orbd.Location((0, 0, -z)) * face). The engine REFUSES off-plane geometry rather than silently writing its XY shadow. - Output bytes are a function of the geometry. Layers are sorted by name and entities by geometric content, so an unchanged drawing rebuilds to an identical file, cold or warm, on any machine.
The three DXF workflows
Copy the full template for the applicable workflow from
references/generator-templates.md when creating a new drawing.
-
Drafted from scratch (gaskets, panels, templates, cut layouts with no 3D model behind them): a
<name>.pythat builds sketches and returns them. -
Flat pattern of a generated STEP part: a drawing script beside the model it derives from, with its OWN stem (one model per file —
bracket_drawing.pybesidebracket.py). Import the model and call it, exactly as an assembly composes a child: importing never builds, and inside the drawing's build the call returns the part's geometry (building the part first if it is stale).from cadgen import dxf, flatten from bracket import bracket # a child: tracked by its RESULT KERF = 0.15 @dxf def bracket_drawing(): return flatten.flat_pattern(bracket(), coordinate=3.0, kerf=KERF) if __name__ == "__main__": bracket_drawing()The drawing's record pins the part's tree, so a part edit that changes its geometry makes the drawing stale, and one that does not (a comment, a refactor, a colour) leaves it current. Constants imported from the part (
from bracket import THICKNESS) are tracked by value the same way. -
Flat pattern of an imported STEP (a
.step/.stpwith no Python source): read it withcadgen.read_step(warm from the store, the same geometry asbuild123d.import_step). Like every file a build reads, it is an input: replacing the vendor STEP makes the drawing stale on its own, with no--force.from pathlib import Path from cadgen import dxf, flatten, read_step _HERE = Path(__file__).resolve().parent KERF = 0.15 @dxf def panel_flat(): panel = read_step(_HERE / "imported" / "vendor_panel.step") # recorded input return flatten.flat_pattern(panel, coordinate=3.0, kerf=KERF) if __name__ == "__main__": panel_flat()Never read a STEP this project generates. Reading the
.stepa@stepmodel writes is not a loop, it is a drawing whose input changes on every run of the model: the freshness gate can never say "current", every build is a full rebuild, and the flat pattern depends on what the last run left on disk. Keep source STEPs in animported/directory beside the drawing, committed like any other input — input path and output path being different files is the whole rule. For a STEP this project DOES generate, use workflow 2 instead: import the model script and call it, which is tracked by result and never touches an artifact.
One model per file is the recommendation, and a drawing gets its own script:
a file MAY declare several models — two @dxf drawings, or a @dxf beside a
@step — and each is its own record, output and job (a sole model writes
<file>.dxf; models sharing a file write <function>.dxf), but they share the
file's closure, so editing one rebuilds them all. A drawing composes models,
never the reverse: calling a @dxf function from a @step body is just its 2D
geometry and links nothing. The viewer catalog is artifacts-only: scripts never
list; the .dxf the run writes is the entry the viewer renders.
Use this skill when
Use this skill when the user asks for DXF files, 2D drawings, profiles, outlines, templates, gaskets, panels, flat patterns, or cut layouts for laser, plasma, waterjet, or CNC routing.
Use $cad for the 3D part or assembly a DXF derives from. Use $sendcutsend for
SendCutSend-specific upload preflight.
Defaults
Use these defaults unless the user specifies otherwise:
- Units: millimeters. The engine sets them; a drawing never declares units.
- Geometry lives at 1:1 scale in the XY plane.
- Cut profiles close. Open contours belong on bend/engrave/reference layers — generation validation enforces this (see Validation).
- For CAD-backed parts, derive contours from the real topology with
cadgen.flattenrather than redrawing them:planar_facesselects,flatten_facelays a face into XY exactly,union_facesfuses, andflat_patterndoes all of it in one call. Hand-drawn parametric outlines only when there is no reliable 3D topology. - Kerf / tool-radius compensation is
flatten.offset_profile(shape, amount)orflat_pattern(..., kerf=...); never hand-offset coordinates. - Curves stay curves. The union and the offset are exact OCC operations, so a
filleted corner exports as an
ARCand a hole as aCIRCLE, kerf included. A profile that comes out as hundreds of shortLINEs means something fell back to the sampled path — investigate rather than accept it. - Layers carry intent: keep cut geometry and bend/fold lines on separate layers, and include "bend" in bend-layer names so downstream tools classify them as bends rather than cuts.
- DXF layers are drawing structure, not STEP part/assembly structure.
Tool
python <drawing>.py [flags] # its __main__ calls the @dxf model, which writes the .dxf
cadgen dxf snapshot <drawing.dxf> <file.png> # render it
cadgen store why <drawing>.py # why the drawing is stale or current
Running the script (its __main__ call) is the only door. There is no
cadgen dxf build: a .dxf has no derived state a command must materialize —
the file IS the product, and both the CAD Viewer and dxf snapshot draw it
straight from its own bytes. The drawing's gate makes a rebuild cheap: an unchanged
source whose .dxf still verifies and whose part children are unchanged is a
no-op, and --force rebuilds anyway. The bytes are a function of the
drawing's GEOMETRY, so a cold run and a warm daemon worker write the same
file. Builds never wait on or cancel one another; a drawing that calls parts
builds them in parallel like any parent.
An imported .dxf needs nothing at all — hand it straight to snapshot or the
Viewer.
Use the active project Python interpreter; treat python as an interpreter
placeholder, and use --help for the full interface. Target paths resolve from
the command's current working directory; run from the workspace that owns the
artifacts with cwd-relative target paths. Keep a drawing script in the same
directory as the geometry it derives from, named <name>.py.
Flags (a model script runs itself; there is no generation CLI):
--force— regenerate even when the recorded output is current.--verbose,--json.
A run answers on stdout exactly as a STEP model's does — built DXF/plate_drawing.dxf
or current DXF/plate_drawing.dxf — with progress on stderr; --json makes the
result one JSON line (outcome, document, and tree, which is null for a
drawing) and the progress one JSON line per transition.
One script, one drawing: run each script you want built. Do not put output paths
in the @dxf function's return value; out= on the decorator is the only
place a drawing names its destination (relative to the script).
cadgen dxf snapshot draws a drawing flat, to a PNG still — the same picture
the CAD Viewer shows, from the same flattening, through the same drawing code:
cadgen dxf snapshot path/to/imported.dxf review.png
cadgen dxf snapshot path/to/drawing.dxf review.png --appearance dark
It takes the .dxf document only — a model script is refused by name (run
python <drawing>.py, then snapshot the drawing it wrote). The whole drawing is
fitted to the image and painted head on, in the pens the file declares; an
entity with no pen of its own (ACI 7) takes the appearance's foreground on its
background. The command flattens the drawing with ezdxf and renders it through
the shared snapshot CLI (cadgen.snapshot_cli) and the same headless browser
runtime every rendering skill uses.
OUT — the second positional — is written exactly as given, with a relative path resolved against the
current working directory. The target is deleted before the render starts and the
finished image is written atomically, so: reuse one name while iterating (every read
is provably the render you just ran), name the iterations when you genuinely need to
compare two. Invalid request combinations fail before touching OUT; after a request is
accepted, OUT is cleared first so a later failure leaves a missing file instead of
a stale image. A directory (tmp/ as OUT) is the
don't-care case and gets a generated timestamped name inside it, printed on the
saved snapshot: line.
Grammar: cadgen dxf snapshot TARGET [OUT] [flags]. Flags: --appearance light|dark, --size-profile, --width/--height, --job, --debug,
--json. That is the whole surface: a drawing is not a scene, so there is no
camera to pose, no display settings to configure, no render mode, no parts to
list, no section to cut and no view to label — --camera, --display,
--mode and --view-labels are not flags this command has. A --job file that
carries any of them (or scale, an output label/viewLabel, or
output.padding/viewLabels/tightFrame) is refused by name before anything
is rendered; a job's output.renderScale and output.transparent still apply.
No CLI inspects an existing .dxf. For entity/layer checks read it with ezdxf
directly (it arrives with build123d), and validate_dxf_file for the drawing checks;
review geometry visually (see Show the model).
Workflow
- Convert the request into a short brief: outline dimensions, holes and slots, layers, units, output path, and validation targets.
- Pick the workflow: drafted from scratch, flat pattern of a generated model (create and validate the 3D geometry with
$cadfirst), or flat pattern of an imported STEP. - Write or edit the
<name>.pysource with meaningful dimensions as named constants, reusing the model's geometry helpers instead of duplicating formulas. - Run each drawing script directly (
python <drawing>.py); do not sweep directories.
python path/to/source.py
python path/to/source.py --force
- Validate the generated DXF deterministically, then hand off and report.
Show the model
Show the user each file you create or change, and any they ask to see. Snapshots and validation don't replace this.
-
If your tools include
cad_show(your host may prefix it), use it with the file's absolute path, and follow its description for when to call it again.cad_viewreads what the user selected;cad_screenshotshows you what they see. Neither is a review of your own work. -
Otherwise run the CAD Viewer, from any folder:
cadgen viewer --host 127.0.0.1 --json --detach--detachreturns once the viewer answers requests and leaves it running in the background: always pass it, since a foreground viewer never exits (and piping its output throughtailcan hide the URL for good). It starts this machine's one viewer, or reuses it. Readurlfrom its one JSON line (never guess the port), and for each file returnurl?file=<its URL-encoded absolute path>. If it fails to launch, say so.
The viewer renders saved DXF files as read-only 2D drawings; it never runs generation scripts. Drag to pan, wheel/pinch to zoom, double-click to fit.
Validation
Validation happens IN generation, not after: every @dxf build runs the drawing
checks on the document the engine just serialized, before anything is written, and
a build with error findings fails. The checks: cut-layer profiles must close
(polylines, circles, or chained line/arc loops), zero-length/degenerate entities are
rejected, exact duplicate geometry (double-cut risk) is rejected, explicitly unitless
documents are rejected, and an empty modelspace is rejected. Open geometry is allowed
only on bend/engrave/reference-intent layers (matched by name).
The same checks run post-hoc on any existing .dxf file — including one that
never came from a generator — through cadgen.drawing_checks:
from cadgen.drawing_checks import validate_dxf_file
for finding in validate_dxf_file("path/to/file.dxf"):
print(finding.render())
Beyond the built-in checks, verify requested dimensions with targeted ezdxf reads
(entity counts by layer, drawing extents, every dimension the user specified) against
the generated sibling .dxf (or the out= path when one is declared), and
review geometry visually in the CAD Viewer:
import ezdxf
doc = ezdxf.readfile("path/to/source.dxf")
msp = doc.modelspace()
cut = msp.query('*[layer=="CUT"]')
holes = msp.query('CIRCLE[layer=="CUT"]')
Report only checks that actually ran.
Handoff
Show every drawing you created or changed (Show the model). Report any failure explicitly.
Final responses should include generated files, returned viewer links, validation actually run, and assumptions.