- 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 comes off the saw. 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 30 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 (1:1 for a shelf
pad, 1:10 for a panel) 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.
- 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.
R3.0 on every relief-hole blind end, R25.4 (4x) on the shelf fillets.
- The feet, whose profile a bounding box cannot describe, get their apex
corners called out by coordinate.
- The title block names the part, its material and thickness (18 mm, 2
layers for a shelf pad), and a note lists the other parts cut to the same
shape - the six shelves are three setups, not six.
Four placement rules came out of looking at rendered sheets rather than from
theory: 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>
|
||
| nest | ||
| partsreport | ||
| .gitignore | ||
| build.py | ||
| cli.py | ||
| explode.py | ||
| export_dxf.py | ||
| geometry.py | ||
| params.py | ||
| pyproject.toml | ||
| README.md | ||
| run_build.py | ||
| specification.md | ||
| uv.lock | ||
| verify.py | ||
Foldable Plywood Backdrop
Parametric FreeCAD model of a knock-down, foldable plywood backdrop. See
specification.md for the physical design (joinery,
dimensions, resolved decisions). This file covers the tooling: what's in this
directory and how to (re)build the model.
Requirements
- FreeCAD 1.1+. On macOS,
freecadcmdisn't onPATHby default; it lives at:/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd
Everything here runs headless via freecadcmd — no GUI required, though you
can open the generated backdrop.FCStd in the FreeCAD GUI afterward to look
around.
Files
| File | Purpose |
|---|---|
params.py |
The params Spreadsheet object: every dimension in the design, as literals or =formula cells referencing other aliases. This is the source of truth — edit values here. |
geometry.py |
Pure shape builders (Part boolean operations) for the panel, lamination/mortise stack, shelves, spines, and feet. No FreeCAD document state; just takes numbers, returns Part.Shape. |
build.py |
Orchestrates params.py + geometry.py into a FreeCAD document: creates Panel_Lower, Panel_Upper, Lam1_*/Lam2_* and ShelfPad_* (lamination and shelf pads, separate individually-cuttable pieces — see below), Spine_L/R, Shelf_*, Foot_L/R objects, and computes the fold-hinge placement. Also defines build_all(), the single entry point both other scripts call. |
run_build.py |
Standalone entry point: build + save backdrop.FCStd/backdrop.step, no checks. |
verify.py |
Runs the geometric assertions from specification.md §5 (fold-sweep collision check, foot/lamination clearance, shelf/lamination clearance, tipping guard, etc.), then builds + saves. Fails loudly (non-zero exit) on the first broken assertion. |
export_dxf.py |
Exports each part's flat 2D cutting profile as a separate DXF into dxf/ — one file per part, not a nested sheet layout (see Known gaps). Also writes dxf/parts.json, a small sidecar carrying each part's thickness (which a 2D DXF can't express) for the parts report. |
explode.py |
Builds the exploded view — the same assembly with every part translated off it along the direction it comes apart in — and saves backdrop_exp.FCStd (see below). |
partsreport/ |
A project-agnostic tool that turns a directory of flat-pattern DXFs into a printable PDF cut list, dimensioning each part's slots, notches, holes and radii as well as its outline. Knows nothing about this model — cli.py passes in the title, material and caveats. |
specification.md |
The design handoff doc — physical dimensions, joinery decisions, and why they are what they are. |
backdrop.FCStd, backdrop.step, backdrop_exp.FCStd, dxf/,
dxf_deepnest/, report/ and drawings/ are build outputs, not checked in
(.gitignore'd) — regenerate them by running one of the scripts below.
Why lamination and shelf pads are separate objects, not fused into the panel
Lam1_*/Lam2_* (the rear lamination/mortise stack) and ShelfPad_* (D4's
local reinforcement pads behind each shelf slot) are built as their own
Part::Feature objects, glued onto the panel during assembly rather than
modeled as one fused solid — each layer is its own sheet-good part that needs
its own flat cutting pattern, which a single fused Panel_Lower/Panel_Upper
shape couldn't represent. Lam1 (the strip layer, with the spine channel cut
through it) usually splits into two disconnected pieces flanking the channel
— build.py handles this generically by exporting one Part::Feature per
disconnected solid (Lam1_Lower_L_1, Lam1_Lower_L_2, etc.), since that's
two genuinely separate boards in real fabrication, not one part with a gap in
it. check_fold_sweep in verify.py still fuses panel + lamination + shelf
pads back together on the fly for its collision check (these are the
permanently-glued features closest to the hinge, unlike the removable
Shelf_*/Spine_*/Foot_* pieces), even though the built/exported objects
stay separate.
Rebuilding the model
The whole pipeline - model → DXF export → sheet nesting - is wired up as a
uv command (cli.py/pyproject.toml at the repo root; uv sync once to
install it):
uv run build model # verify.py under freecadcmd -> backdrop.FCStd/.step
uv run build dxf # + export_dxf.py under freecadcmd -> dxf/*.dxf
uv run build nest # + nest/main.py in nest/'s own venv -> nest/output/sheet_*.{dxf,svg}
uv run build deepnest # + nest/deepnest_export.py -> dxf_deepnest/*.dxf (for DeepNest instead of `nest`)
uv run build report # + partsreport/ -> report/parts_report.pdf (printable cut list)
uv run build drawings # + partsreport/ -> drawings/*.pdf (one dimensioned sheet per part)
uv run build explode # explode.py under freecadcmd -> backdrop_exp.FCStd (exploded view)
uv run build help # prints all of this, plus every option, for an agent picking this up cold
Each target rebuilds its dependencies first only if they look stale (source
newer than output) - uv run build nest alone takes you from an edited
params.py all the way to nested sheet layouts. Pass --force to rebuild a
stage regardless. model/dxf shell out to freecadcmd (set FREECADCMD if
it's not at the default macOS path below); nest shells out to uv run inside
nest/, which has its own venv - see nest/README.md for
why that one can't just be a normal dependency of this project.
nest's sheet stock size, part spacing, and large-part anchoring threshold
(see nest/README.md) are all overridable, e.g. to check a different sheet
size:
uv run build nest --sheet-width 1219.2 --sheet-height 1219.2 # 4x4ft instead of the default 5x5ft
With the default 1525x1525mm (5x5ft) stock this model's 30 parts nest onto
2 sheets. 4x4ft (1219.2mm) sheets are tighter than the parts can
actually fit into (see nest/README.md) - fitting requires 3 there.
If nesting by hand in DeepNest instead of uv run build nest, use uv run build deepnest's dxf_deepnest/*.dxf output, not
dxf/*.dxf directly — DeepNest has a known, unresolved unit bug
(Jack000/Deepnest#204) that
silently shrinks true-millimeter DXFs by 72/25.4 (confirmed directly: a real
914.4mm panel shows up in DeepNest as 12.7in). dxf_deepnest/*.dxf is
dxf/*.dxf pre-scaled by that same factor so it cancels back out and
DeepNest shows the correct real-world size.
The commands below are what uv run build actually runs, useful if you want
to invoke a single stage directly without the staleness checks:
FREECADCMD=/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd
# Build + save only
$FREECADCMD run_build.py
# Build + run all geometric assertions + save (use this one day-to-day)
$FREECADCMD verify.py
# Export each part's flat cutting profile to dxf/
$FREECADCMD export_dxf.py
# Build the exploded view -> backdrop_exp.FCStd
$FREECADCMD explode.py
The first two write backdrop.FCStd and backdrop.step in this directory.
The parts report (report/parts_report.pdf)
uv run build report turns dxf/ into a printable cut list: a summary page
listing every distinct shape with a checkbox to tick it off as it is cut, a
common-scale index of those shapes where each thumbnail links to its own
page, then one detail page per shape with its outline drawn, every cut
feature in it dimensioned, and quantity.
The tool itself lives in partsreport/ and is
project-agnostic —
everything specific to this model (title, "plywood", the grain and
export caveats) is passed in from cli.py. Three non-obvious things it
surfaces:
- The bounding box is the least of what has to be cut. Each detail page
carries a Cut features table listing every slot, notch, hole and corner
radius in the part — size, position from the part's lower-left corner, and
the radius terminating it — keyed by a lettered ref on the drawing. For
Panel_Lowerthat's the four shelf slots (130.0 deep × 9.4 wide, R3.0 relief hole at the blind end) and the two foot cross-lap notches (50.8 × 9.1 up from the bottom edge); forShelfit's the 151.9 × 28.0 lock slot and the R25.4 corner fillets. All of it is recovered from the exported outline rather than read back out ofparams.py, so the report can't disagree with the DXF the cutter is handed. Parts whose profile isn't rectangular (the feet) also get their corner coordinates listed, since a bounding box can't describe a triangle. The summary list's checkboxes are real PDF form fields, tickable in any viewer that does forms and saved with the file; the empty squares are drawn as page content, so a printed copy is still a checklist. - "How many distinct shapes" has three answers, and they differ. The 30 parts reduce to 8 shapes if a blank may be turned face-down, 9 if it may only be rotated in-plane, and 10 as genuinely distinct as-cut toolpaths. The gap is the shelves (L and R are mirror images) and the shelf pads (L and R differ by a rotation). Flipping a blank swaps which plywood face ends up visible and reverses grain relative to the cut, so the 8 is only real if neither face quality nor grain matters — see specification.md's "Grain direction" note. The report prints all three plus the full as-cut breakdown.
- Parts and blanks aren't the same count.
ShelfPad_*isback_buildup(18mm) thick, i.e. two plies glued up, so the 6 pads are 12 blanks from 9mm stock — 36 blanks total for 30 parts. The DXFs alone can't show this (a pad exports as one outline like everything else), which is whyexport_dxf.pywrites per-part thickness intodxf/parts.jsonrather than having the report re-derive it fromparams.py.
Per-part drawing sheets (drawings/)
uv run build drawings writes one landscape A4 technical drawing per part —
drawings/Panel_Lower.pdf, drawings/Shelf_Lower_L_H1.pdf, … — plus
drawings/drawings.pdf with all 30 sheets in one document. Same
partsreport/ tool, different output: where the
report is a document about the whole job, a sheet is what you hand to whoever
is cutting that one part.
Each sheet draws the part at the largest ISO scale that fits (1:1 for a shelf
pad, 1:10 for a panel), states that ratio in the title block, and dimensions
it with real dimension lines: overall size, then every slot and notch sized
and positioned from the part's lower-left corner, R3.0 on each relief-hole
blind end, R25.4 on the shelf fillets. The feet, whose profile a bounding
box can't describe, get their apex corners called out by coordinate. The title
block names the part, plywood 9 mm (or 18 mm (2 layers) for a shelf pad),
the scale and the sheet number, and a note above it lists the other parts cut
to the same shape — the six shelves are three sheets' worth of setups, not
six.
The exploded view (backdrop_exp.FCStd)
explode.py builds the same 30 parts build.py does — identical geometry,
only Placement differs — and pulls each one off the assembly along the
direction it actually comes apart in, so the result reads as an assembly
diagram rather than a scatter:
| Parts | Direction |
|---|---|
Lam1_* → Lam2_* → Spine_* |
backward in +Y, one layer per step, in glue-up order (the spine drops into Lam1's channel, so it explodes out furthest) |
left/right lamination sections, ShelfPad_* |
outward along ±X, so each side's pieces stay grouped with their side |
Shelf_* |
outward along ±X, the direction they slide in |
Foot_L/R |
straight down −Z, the direction the panel's edge lifts out of the cross-lap slot |
Panel_Upper and everything glued to it |
up +Z, opening the hinge joint |
Every offset is a fixed multiple of one SPREAD distance (150mm), so the
whole diagram scales with a single number. No two parts end up closer than
MIN_GAP (25mm) — and that's enforced, not assumed: the script measures
real Part.distToShape() distances between all 435 placed-solid pairs, and if
any pair is too close it widens SPREAD and rechecks. So the guarantee
survives params.py changes that move geometry out from under the offsets
above. It fails loudly (rather than shipping a touching pair) if widening
can't fix it — which happens when two parts share the same offset, e.g. the
Lam1_*_1/Lam1_*_2 strips flanking one spine channel, whose ~100mm gap is
fixed by the geometry and never grows; separating those further would need a
new axis in Y_LAYER/X_SPREAD, not a bigger spread.
The view is always built at fold_angle = 0 (deployed), regardless of what
params.py says — an exploded view of a half-folded assembly would be showing
two things at once. It runs no geometric assertions; verify.py is still the
safety net.
How parameters actually flow through
params.py is the only source of truth. Every run of build_all():
- Creates a brand-new FreeCAD document — it never loads the existing
backdrop.FCStdfrom disk. - Writes every value in
PARAM_ROWSinto a freshparamsspreadsheet. - Computes all geometry in Python from those resolved values.
- (
verify.py/run_build.pyonly) saves overbackdrop.FCStd/backdrop.step.
Practical implications:
- To change a parameter, edit
params.py, then rerun a script. Editing a cell directly in the FreeCAD GUI and hitting recompute does nothing — the panel/shelf/foot shapes are staticPart::Featureresults computed once in Python, not live expressions bound to spreadsheet cells (this is a deliberate tradeoff: this model is almost entirely boolean joinery — mortises, cross-laps, relief-hole-terminated slots — which is far more reliable to generate as scriptedPartbooleans than as a Sketcher/PartDesign constraint tree driven by cell expressions). - If you edit the spreadsheet in the GUI and then rerun a script, your edit is
discarded — the script always rewrites the spreadsheet from
params.py. fold_angle(0 = deployed, 180 = folded flat against the lower panel) is a regular parameter inparams.pylike any other; edit it there to regenerate the model in a different fold pose.
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 — build.py has no
top-level side effects and is always safe to import.
Known gaps
shelf_count_per_side(inparams.py, currently3) is not wired into the geometry yet —build_shelves()always builds exactly two heights per side (H1/H2), regardless of this value. Treat it as unused until someone implements a real N-height loop.- Hinges are hardware, not modeled as solids.
hinge_count,hinge_spacing,hinge_leaf_l, andhinge_pin_offsetare kept in the spreadsheet for reference (and for a future flat-pattern layout) but no geometry builder consumes them. retention_mode = "bayonet"cuts the panel-side notch but does not model a matching L-shaped tab on the shelf — see specification.md's D2 open item."dowel"(the default) is fully modeled.export_dxf.pygives per-part flat patterns, not a nested sheet layout — bin-packing isn't something to bolt onto this macro. Seenest/for the sheet-nesting step (uv run build nest), which packs thedxf/output onto sheet stock viapynest2d. Only one face is exported per part (front vs back isn't tracked), and vertical dowel retention holes don't appear (they're bored perpendicular to the sheet, a separate drilling step, not a 2D cutting-plane feature) — seeexport_dxf.py's docstring for the full reasoning.pynest2d's placer isn't trustworthy on its own — verified directly that it can place two parts overlapping instead of using a second sheet (confirmed with just the two main panels alone in an empty bin).nest/main.pyruns a mandatory Shapely overlap check after nesting and re-bins any part found on top of another, so a bad placement costs sheet efficiency, never correctness — but it does mean sheet counts fromnestare a safe upper bound, not necessarily an optimal packing. Grain direction also isn't respected (the packer is free to rotate parts to any of its configured angles) — check the layout by eye before cutting, same as for the per-part DXFs.
Verification
verify.py is the safety net — run it after any parameter change. It checks,
among other things:
- No solid collision anywhere in a 0°–180° fold sweep.
- The foot/panel cross-lap and shelf/panel interlock actually clear each other and the lamination strips (both are checked by real boolean intersection where it matters, not just by comparing numbers).
- The tipping guard (
foot_fwd >= 150mm) still holds. - Reports total assembly mass from the model's actual computed volume (currently ~17.1kg — see specification.md §6 for how this moved from the original rough 14–16kg estimate as the design evolved).
Open backdrop.step in a viewer after every change before cutting anything.