No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Thomas Eichinger b6c8c0d1d8 Make the report interactive, and add uv run build drawings
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>
2026-08-09 19:23:02 -07:00
nest Extract the parts report into a project-agnostic partsreport/ tool 2026-08-09 14:11:03 -07:00
partsreport Make the report interactive, and add uv run build drawings 2026-08-09 19:23:02 -07:00
.gitignore Make the report interactive, and add uv run build drawings 2026-08-09 19:23:02 -07:00
build.py Shorten the foot's panel notch to the bridge height, add a real fit check 2026-07-29 20:57:08 -07:00
cli.py Make the report interactive, and add uv run build drawings 2026-08-09 19:23:02 -07:00
explode.py Add uv run build explode and an exploded view (backdrop_exp.FCStd) 2026-08-08 16:24:28 -07:00
export_dxf.py Stop export_dxf.py crying wolf about missing DXF libraries 2026-08-09 15:22:06 -07:00
geometry.py Shorten the foot's panel notch to the bridge height, add a real fit check 2026-07-29 20:57:08 -07:00
params.py Widen the mortise to actually fit the spine, fixing the collision for real 2026-07-27 23:53:04 -07:00
pyproject.toml Add uv run build {model,dxf,nest} and a real sheet-nesting implementation 2026-07-29 21:22:40 -07:00
README.md Make the report interactive, and add uv run build drawings 2026-08-09 19:23:02 -07:00
run_build.py Group lamination, shelf pad, and shelf pieces in the document tree 2026-07-28 00:08:02 -07:00
specification.md Bring specification.md fully up to date with the current design 2026-08-08 16:25:46 -07:00
uv.lock Add uv run build {model,dxf,nest} and a real sheet-nesting implementation 2026-07-29 21:22:40 -07:00
verify.py Shorten the foot's panel notch to the bridge height, add a real fit check 2026-07-29 20:57:08 -07:00

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, freecadcmd isn't on PATH by 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_Lower that'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); for Shelf it'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 of params.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_* is back_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 why export_dxf.py writes per-part thickness into dxf/parts.json rather than having the report re-derive it from params.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():

  1. Creates a brand-new FreeCAD document — it never loads the existing backdrop.FCStd from disk.
  2. Writes every value in PARAM_ROWS into a fresh params spreadsheet.
  3. Computes all geometry in Python from those resolved values.
  4. (verify.py/run_build.py only) saves over backdrop.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 static Part::Feature results 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 scripted Part booleans 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 in params.py like 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 (in params.py, currently 3) 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, and hinge_pin_offset are 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.py gives per-part flat patterns, not a nested sheet layout — bin-packing isn't something to bolt onto this macro. See nest/ for the sheet-nesting step (uv run build nest), which packs the dxf/ output onto sheet stock via pynest2d. 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) — see export_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.py runs 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 from nest are 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 1416kg estimate as the design evolved).

Open backdrop.step in a viewer after every change before cutting anything.