- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Three things, all downstream of dxf/ and all in partsreport/, which stays
project-agnostic.
Checkboxes on the report's shape list. Each distinct shape gets a box in a new
"Cut" column for marking off as it is cut. They are AcroForm widget
annotations, so they tick in any viewer that does forms and the state saves
with the file. Matplotlib's PDF backend can't emit widgets, so the empty
square is drawn as ordinary page content and a pypdf pass lays a field over
it afterwards; the widget's "off" appearance is deliberately empty and only
its "on" state draws the tick, which keeps the boxes visible and printable in
viewers that ignore annotations. A printed copy is still a checklist.
Verified by rendering with pdfium, with /AS flipped, that the ticks land in
the right boxes on both A4 and Letter.
Links on the shape index. Every thumbnail, title included, jumps to that
shape's detail page. pypdf's own Link class writes /Dest [2 /Fit] - the page
as a number, which is how a destination in *another* document is addressed
and which many viewers won't resolve - so the link dictionaries are built by
hand with /A << /S /GoTo /D [<page ref> /Fit] >>. Checked by resolving every
destination back through the page table.
Both are annotations laid over finished pages, so they share one module and
one post-render pass (annotations.py) rather than two.
`uv run build drawings` is the new target: drawings/<part>.pdf, one per DXF,
plus drawings/drawings.pdf with all of them in one document. Where the report
is a document about the whole job, a sheet is what you hand to whoever cuts
that one part, so it isn't a detail page with a border round it:
- The view is plotted at the largest ISO scale that fits and that ratio is
in the title block, so a print at 100% can be measured. Everything is laid
out in millimetres of paper, so text height and arrowheads are drafting
sizes rather than fractions of a page, whatever the sheet size.
- Dimensions are dimension lines, not callouts keyed to a table: overall
size furthest out, then each feature sized and positioned from the
lower-left datum on the edge it is cut in from, one level per feature.
Radii and diameters are leader callouts.
- A profile that isn't a rectangle gets its corners called out by
coordinate, since the overall size cannot describe it.
- The title block names the part, its material and thickness, and a note
lists the other parts cut to the same shape, so nobody sets up twice for
one toolpath.
Sheet size is config-driven (report.drawing_sheet: a4, a3 or letter), like
everything else project-specific.
Four placement rules came out of looking at rendered sheets of hello_box,
whose finger-jointed ends are the awkward case: corner cut-outs are
dimensioned on the two edges their corner is made of and given no position
(being in the corner *is* the position, and doing it the naive way measured a
top-right notch from the bottom-left datum); zero-length position dimensions
are dropped; holes are positioned by centre, not by a bounding-box corner
nothing on the part corresponds to; and a figure too wide for its arrows stays
centred over the line with the arrows turned outward, since setting it beside
them collides with the next dimension along.
pypdf is a new dependency of partsreport/, for the annotations only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
| partsreport | ||
| .gitignore | ||
| build.py | ||
| cli.py | ||
| config.yaml | ||
| deepnest_export.py | ||
| export_dxf.py | ||
| geometry.py | ||
| params.py | ||
| project_config.py | ||
| pyproject.toml | ||
| README.md | ||
| run_build.py | ||
| SKILL.md | ||
| uv.lock | ||
| verify.py | ||
Wood project template
Tooling scaffold for a parametric FreeCAD woodworking project: model → DXF
flat patterns → sheet nesting → printable parts report, wired together as one
staleness-aware uv run build command.
Everything here is project-agnostic. The geometry is yours to write — see What you must provide.
uv sync # installs the `build` command
$EDITOR config.yaml # name your project, set your stock
uv run build help # what every target does, plus all options
uv run build report # model -> dxf -> parts_report.pdf, rebuilding what's stale
uv run build drawings # + one dimensioned drawing sheet per part -> drawings/
What's in here
| Path | Purpose |
|---|---|
config.yaml |
Start here. Project name, title, material, units, sheet size, nesting settings and report notes. The single source of truth; nothing else needs editing to rename or re-stock a project. |
project_config.py |
Loads and validates config.yaml. Imported from both this project's venv and FreeCAD's bundled Python, so it sticks to the stdlib plus PyYAML (which FreeCAD 1.1 ships). |
cli.py |
The uv run build orchestration. Targets: model, dxf, deepnest, report, drawings, help. Each rebuilds its dependencies first if they look stale; --force overrides. Reads config.yaml; you should not need to edit it. |
export_dxf.py |
Exports each part's flat 2D cutting profile as its own DXF into dxf/, plus dxf/parts.json carrying per-part thickness. Generic: it works on any model whose parts are prismatic (a 2D profile pushed through a constant thickness). |
run_build.py |
Build + save <PROJECT>.FCStd/.step with no assertions. |
deepnest_export.py |
Rescales dxf/*.dxf into dxf_deepnest/ for DeepNest, which otherwise reads millimetre DXFs at 1/2.83 of their real size. |
partsreport/ |
Turns dxf/*.dxf into two documents: a printable PDF cut list (dimensioning each part's slots, notches, holes and radii, with a tickable checkbox per shape and a navigable shape index), and one to-scale technical drawing sheet per part. Its own uv project, and reusable outside this template. |
*.FCStd, *.step, dxf/, dxf_deepnest/, report/ and drawings/ are build outputs
and are .gitignore'd — regenerate them with uv run build.
What you must provide
The pipeline needs four scripts in this directory. cli.py checks for them
and tells you if they're missing.
| File | Contract |
|---|---|
params.py |
Your dimensions. The pattern used by the project this was extracted from: a PARAM_ROWS list of (alias, value) pairs, where a value is a literal or an "=formula" string referencing other aliases, written into a FreeCAD Spreadsheet object named params. Nothing outside build.py depends on this shape — except export_dxf.py, which reads one alias (see below). |
geometry.py |
Pure shape builders: take numbers, return Part.Shape. No document state. Keeps the geometry testable and the document assembly separate. |
build.py |
Assembles the document. Must expose: DOC_NAME (str, the base filename), build_all() → (doc, params), leaf_objects(doc) → the list of individually-cuttable part objects, and set_all_visible(path, doc). Everything else keys off these four. |
verify.py |
Your geometric assertions — clearances, collisions, whatever must hold. Run by uv run build model, and expected to build and save at the end. Should exit non-zero on the first failure. |
Have build.py take its document name from the config, so there's one
source of truth for it:
import project_config
DOC_NAME = project_config.load()["project"]["name"]
And name your sheet-thickness parameter to match stock.thickness_alias
(default stock_t) in your params spreadsheet — export_dxf.py writes it
into dxf/parts.json so the report can tell how many blanks a part thicker
than one sheet actually takes. Set that key to null if your model has no
such parameter; the report then shows thickness as n/a rather than failing.
Configuration
Everything project-specific is in config.yaml:
| Key | Purpose |
|---|---|
project.name |
Base filename for <name>.FCStd / .step; match it in build.py's DOC_NAME |
project.title |
Title on the parts report |
stock.material |
Named on the report's stock line; empty to omit |
stock.units |
mm, cm, m or in — used by the model and the report |
stock.thickness_alias |
params alias holding sheet thickness, or null |
stock.sheet_width/height |
Sheet stock size; printed by deepnest as the numbers to enter in DeepNest |
stock.part_spacing |
Gap between parts: kerf plus handling margin |
report.notes |
Free text on the report's summary page |
report.drawing_sheet |
Sheet size for uv run build drawings: a4, a3 or letter, landscape |
freecad.cmd |
Path to freecadcmd; null to autodetect or use $FREECADCMD |
Missing keys fall back to documented defaults, and bad values fail with a specific message rather than a stack trace.
partsreport/ deliberately does not read config.yaml — it's a
project-agnostic tool, and cli.py passes it these values as flags. That's
what keeps it reusable outside this template.
Requirements
- FreeCAD 1.1+ for the
modelanddxfstages. On macOSfreecadcmdisn't onPATH; it lives at/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd, whichcli.pyfinds automatically. Set$FREECADCMDif yours is elsewhere. Everything runs headless — no GUI needed, though you can open the generated.FCStdafterwards to look around. uvfor everything else.
Why there's no automatic nesting
Laying parts out on sheets is left to DeepNest. The one
good open-source nester with Python bindings (pynest2d) has no wheel and
needs a from-source Conan build against its own CPython — too much ceremony
for the payoff, and its placer has a bug that can overlap parts anyway.
uv run build deepnest writes correctly-scaled DXFs and prints the sheet size
and spacing to enter, which is the whole of what you lose.
A freecadcmd quirk worth knowing
freecadcmd script.py sets script.__name__ to the script's own basename
(e.g. "build"), not "__main__". An if __name__ == "__main__": guard
in build.py would never fire when run directly, but would be ambiguous
when build.py is imported by verify.py (same basename either way). That's
why run_build.py exists as its own tiny file instead — keep build.py free
of top-level side effects so it's always safe to import.
Four things the report is built to surface
Worth knowing before you trust a cut list, and the reason partsreport/
reports what it does:
- A bounding box isn't a cut list. Each detail page carries a Cut
features table: every slot, notch, hole and corner radius in the part, with
its size, its position from the part's lower-left corner and any radius
terminating it, keyed by a lettered ref on the drawing. Edge-open features
are stated deep × wide relative to the edge they're cut in from, which is
how they get cut. All of it is derived from the exported outline rather than
from your
params.py, so the report can't disagree with the DXF the cutter is handed. Parts whose profile isn't rectangular also get their corner coordinates listed, since a bounding box can't describe a triangle. The summary page's shape list carries a checkbox per shape — a real PDF form field, tickable in any viewer that does forms and saved with the file, with the empty square drawn as page content so a printed copy is still a checklist. Every thumbnail on the shape index links to that shape's detail page. - A drawing sheet is not a report page.
uv run build drawingswrites one landscape sheet per part: the view plotted at the largest ISO scale that fits (stated in the title block, so a print at 100% can be measured), real dimension lines rather than a table, and a title block naming the part, its material and thickness, and the other parts cut to the same shape. Hand those out one part at a time; hand the report to whoever is planning the job. - "How many distinct shapes" has three answers. Parts that match only after being rotated share a toolpath but not a grain orientation. Parts that match only after being mirrored need the blank turned face-down, which swaps which face ends up visible. The report gives all three counts — as-cut, rotated, flipped — and prints the full as-cut breakdown, since that's what actually gets programmed.
- Parts and blanks are different counts. A part thicker than your stock is
a glue-up of several blanks, and a flat pattern can't show that — it exports
as one outline like anything else. That's what
dxf/parts.jsonis for.