No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Thomas Eichinger 83bec21372 Make the report interactive, and add a drawings target
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>
2026-08-09 19:23:32 -07:00
partsreport Make the report interactive, and add a drawings target 2026-08-09 19:23:32 -07:00
.gitignore Make the report interactive, and add a drawings target 2026-08-09 19:23:32 -07:00
build.py Initial commit: parametric sheet-goods project template 2026-08-09 15:12:09 -07:00
cli.py Make the report interactive, and add a drawings target 2026-08-09 19:23:32 -07:00
config.yaml Make the report interactive, and add a drawings target 2026-08-09 19:23:32 -07:00
deepnest_export.py Initial commit: parametric sheet-goods project template 2026-08-09 15:12:09 -07:00
export_dxf.py Stop export_dxf.py crying wolf about missing DXF libraries 2026-08-09 15:20:15 -07:00
geometry.py Initial commit: parametric sheet-goods project template 2026-08-09 15:12:09 -07:00
params.py Initial commit: parametric sheet-goods project template 2026-08-09 15:12:09 -07:00
project_config.py Make the report interactive, and add a drawings target 2026-08-09 19:23:32 -07:00
pyproject.toml Initial commit: parametric sheet-goods project template 2026-08-09 15:12:09 -07:00
README.md Make the report interactive, and add a drawings target 2026-08-09 19:23:32 -07:00
run_build.py Initial commit: parametric sheet-goods project template 2026-08-09 15:12:09 -07:00
SKILL.md Make the report interactive, and add a drawings target 2026-08-09 19:23:32 -07:00
uv.lock Initial commit: parametric sheet-goods project template 2026-08-09 15:12:09 -07:00
verify.py Initial commit: parametric sheet-goods project template 2026-08-09 15:12:09 -07:00

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 model and dxf stages. On macOS freecadcmd isn't on PATH; it lives at /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd, which cli.py finds automatically. Set $FREECADCMD if yours is elsewhere. Everything runs headless — no GUI needed, though you can open the generated .FCStd afterwards to look around.
  • uv for 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 drawings writes 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.json is for.