Compare commits

..

39 Commits

Author SHA1 Message Date
rob 274e87e239 Phase 1: bounded-exact lumber, guillotine plywood, Best-of-N
- _min_bins: branch-and-bound minimum stick count (FFD-seeded + count bound)
- _pack_lumber_exact: provably-minimum packing for small jobs (<=12 pieces)
- _pack_plywood_guillotine: free-rectangle best-area-fit packing + rotation
- build_cut_plan dispatches strategy=="exact"/"guillotine"; added to STRATEGIES
- richer scoring: reusable_in (longer offcuts) as _plan_key tie-break
- best_cut_plan tries exact+guillotine; "Best of 100" button in Cut Layout tab
- tests: exact<=FFD, oversize handling, guillotine packs/validates, best-of-N

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:37:11 -03:00
rob c81633b699 Lock-aware re-optimization
reoptimize(scene, base_plan, strategy) preserves locked placements where they sit
and re-packs the unlocked items around them: unlocked lumber goes into the free
segments beside locked pieces (then new sticks) via _pack_lumber_seeded +
_free_segments; unlocked plywood goes onto fresh sheets (locked sheets keep their
locked panels). The BOM window's "Find better layout" / "Try alternative" now call
reoptimize when any piece is locked (Find better tries all strategies and keeps the
best), so locks survive re-optimization instead of just blocking drags.

Tests: locked placement keeps its id/position, nothing is lost, plan stays valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:27:39 -03:00
rob 38391175b4 Fix drag-drop crash + cover it with offscreen GUI tests
_drop_piece looked up plan.item(item.pid), but item.pid is a Placement id (pl2)
while CutPlan.item() expects a CutItem id (ci2) — every drop raised StopIteration
before validate/revert could run. Use the already-found placement's item_id
(plan.item(p.item_id)) for the stock-compat check and message.

Added tests/test_bom_window.py (offscreen QGraphicsScene): drop-overlap reverts
without crashing, drop-onto-incompatible-stock reverts, and a valid move commits.

128 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:18:01 -03:00
rob 7256b54719 Address shop-packet review (consistency, determinism, validation, jigs)
- One active CutPlan: the BOM window holds self._plan; Cut List, Shopping,
  Instructions, and Cut Layout all render from it (no more "shopping says 3 sticks
  while the optimized layout uses 2"). _set_plan/_refresh_all keep them in sync.
- Unplaced/oversize parts now appear in the Shopping list ("Won't fit standard
  stock — source/cut specially"), not just a layout warning.
- Process-stable shuffle ordering via hashlib (built-in hash() is salted per run).
- Kerf-gap validation: placement_fits/validate now reject pieces closer than a
  kerf (not just overlap beyond a kerf).
- Manual edits: drop checks stock-type compatibility (no 2x4 onto a plywood
  sheet); waste regions + score recompute after every move/rotate/lock; rotation
  respects allow_plywood_rotation/grain and validate flags illegal rotation.
- Jig specificity: holes/mortises grouped by face + position + size (not just
  cutter size), so a registration template is only suggested when the position
  actually repeats.

125 tests pass; verified offscreen that Shopping tracks the optimized plan and
unplaced parts surface. Phase 1 noted as partial in SHOP_PACKET_PLAN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:11:02 -03:00
rob 93d1b186e3 Phase 4: constrained manual layout editing (drag/snap/lock/rotate)
cutplan.py gains deterministic editing helpers: find_placement, placement_fits
(bounds + kerf-aware overlap), snap_x (snap to stock edges / neighbour ±kerf),
relocate (move a placement to a stock piece / position), rotate_placement.

The BOM Cut Layout tab is now editable: it holds a persistent CutPlan; pieces are
draggable (_Piece) — on drop they snap, validate, and either commit or revert with
a status message; you can drag a piece onto another stick/sheet to reassign it,
double-click a panel to rotate it, and right-click to lock (locked pieces can't be
dragged). "Find better layout" / "Try alternative" regenerate the auto plan.

119 tests pass (snap, fits/overlap, relocate-between-sticks, rotate). Window +
drag/rotate handlers verified offscreen; interactive drag/print needs a display.

Known follow-up: lock-aware re-optimization (locked pieces currently protect against
drags but aren't yet preserved across a fresh auto-layout).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:52:03 -03:00
rob ee00ec7ce5 Phase 3: jig suggestions (rule-based detection + AI explanation)
jigs.py: detect repeated operations deterministically and propose shop aids with
COMPUTED dimensions — stop block (repeated identical crosscuts, e.g. 10× a length),
drilling template (repeated hole diameters), mortise template (repeated mortises),
rip-fence setting (repeated panel widths). Jigs are shop aids kept SEPARATE from
the project BOM (no silent material adds). explain_prompt() lets the AI describe
build/use without changing any dimension.

BOM window gains a Jigs tab: deterministic suggestions + "Explain jigs (AI)"
(background claude) + Print.

116 tests pass (10× crosscut -> stop block, below-threshold -> none, repeated
holes/mortises -> templates).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:41:58 -03:00
rob ecce86ddb5 Phase 2: structured build instructions (deterministic + optional AI polish)
instructions.py: build_steps(scene, plan) emits an ordered, DETERMINISTIC step
list from the CutPlan + scene — gather stock, cut to size (per cut layout), mark
& cut joinery, sand, dry-fit/glue/fasten (per connection), finish. Every number
and part name comes from the model. polish_prompt() asks the AI to rephrase into
friendly prose while forbidding any change to measurements.

BOM window gains an Instructions tab: shows the deterministic steps immediately,
"Rewrite in plain English (AI)" runs claude in a background thread to polish, and
Print.

111 tests pass (steps cover phases, carry real data, prompt guards numbers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:36:50 -03:00
rob d44d36a773 Phase 1: smarter auto-layout (best-fit, plywood rotation, optimize)
- Lumber packing supports first-fit (FFD) and best-fit (BFD, tightest fit) via
  a `fit` mode; strategy "bestfit" selects it.
- Plywood panels now rotate to fit (when allowed and grain isn't honored);
  placements record `rotated`. Rotation-disabled oversize panels are flagged.
- best_cut_plan() tries decreasing/bestfit/increasing + shuffle restarts and
  keeps the best by (stock_count, waste_area, -reusable_offcuts); marks it
  "optimized". STRATEGIES drives "Try alternative".
- BOM Cut Layout tab: "Find better layout" (optimize) + "Try alternative"
  (cycle strategies) buttons; the score line explains the result.

107 tests pass (rotation fits/!fits, optimizer no-worse-than-baseline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:31:58 -03:00
rob 77444c546a Phase 0: CutPlan model (deterministic shop-output artifact)
Adds SHOP_PACKET_PLAN.md (living plan) and cutplan.py: ShopSettings, CutItem,
StockPiece, Placement, WasteRegion, CutPlan — plain dataclasses, JSON-serializable,
stable ids throughout. build_cut_plan(scene, settings, strategy) packs lumber
(first-fit-decreasing, kerf-aware) and plywood (shelf) into the model with waste
regions and a detailed score {strategy, stock_count, waste_area, reusable_offcuts,
yield_pct, warnings}. validate_cut_plan checks bounds/overlap/kerf/placed-or-warned.

Old APIs kept as thin wrappers over build_cut_plan: layout.nest_lumber/nest_plywood/
stock_counts/waste_summary (so existing tests/UI/cutlist.shopping keep working).
The BOM window's Cut Layout tab now renders directly from the CutPlan (score line,
stick/sheet placements, waste, warnings).

104 tests pass incl. kerf, tenon-extended items, oversize warnings, JSON roundtrip,
validation, custom settings. UI says "Try another arrangement", not "optimal".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:25:45 -03:00
rob 3643aac50d BOM window: tabs + cut-layout nesting + print (phase 1 of shop output)
layout.py: cutting-stock nesting — 1D lumber (first-fit-decreasing into 8' sticks,
kerf-aware) and 2D plywood (shelf packing onto 4x8 sheets), plus stock_counts
(now drives the accurate buy-counts) and waste_summary. Tested headlessly.

gui/bom_window.py replaces the cut-list QMessageBox popup with a tabbed window:
- Cut List + Shopping List tabs (printable via QPrinter).
- Cut Layout tab: a QGraphicsScene diagram of pieces packed onto each stick/sheet
  with waste, a "Try another arrangement" button (cycles ordering heuristics),
  and Print. Verified offscreen — the layout renders correctly.

96 tests pass.

Deferred (phase 2): drag-to-rearrange pieces, true-optimal nesting, generated
step-by-step instructions, and jig suggestions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:07:49 -03:00
rob e4f9cedf4a Add plywood (sheet stock)
Plywood is sheet stock — fixed thickness, width cut per-panel. lumber.py adds
ply-1/8…ply-3/4 specs + is_plywood/plywood_thickness and normalizes "3/4
plywood" -> "ply-3/4". scene.place(stock, length, width_in=) requires a width
for plywood (lumber ignores it). Cut list reports plywood in sq-ft and buys it
in 4×8 sheets (lumber stays board-feet / 8' sticks).

Wired through CLI (place --width), voice (wood-place width arg + prompt note),
and the Parts-tab manual add (plywood in the dropdown + a width field enabled
for plywood). Geometry/export/render work unchanged (section = thickness×width).

91 tests pass; verified a plywood top renders as a thin panel and exports to STEP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:33:49 -03:00
rob 52be9ee5b1 Spatial feedback to the AI + manual add-board control
Spatial feedback (direction #1): scene.spatial_summary() reports each board's
world bounding box (Part.bbox) and flags interpenetrating pairs; the GUI feeds
it into the interpreter prompt. The SYSTEM prompt now tells the AI to use the
layout to position boards flush against each other (computed wood-move) and to
fix overlaps — so relative commands like "position the left side flush against
p2" work. Verified live: "stack p2 on top of p1" moved p2 onto p1's top face.

Manual add: the Parts tab gained a stock dropdown + length + "Add board" button
to place a board instantly without the AI (controller.place).

88 tests pass (bbox axis-aligned, spatial_summary flags/clears overlap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:17:41 -03:00
rob 35adf5ee0d Joinery list: show what each feature connects to (not just "connected")
The connected indicator now reads "🔗 → <board>" naming the mating board, so a
part legitimately in two connections shows each feature's distinct mate, and a
free feature shows nothing. (A connection always links two features on two
different boards, so a board showing both features connected means it's in two
connections — verified the model never over-marks from a single connect.)

86 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:17:51 -03:00
rob 3e7375344e Right-click a feature to break its connection
The Joinery tab feature list now has a context menu: right-click a mortise/tenon
to "Break this connection" (only shown when it's connected) or "Delete feature".
controller.break_feature_connection breaks just the connection(s) that feature
is part of, in one undo step.

86 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:06:25 -03:00
rob 5e8a1c7926 Joinery tab: mark connected features in the list
Each feature row in the Joinery tab now shows "🔗 connected" when that feature
is part of a recorded connection (its id appears as an anchor or moving feature),
so it's clear which mortises/tenons are already mated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:59:44 -03:00
rob a1f6145115 Connect: choose which board moves + drag the sub-assembly along
- The Fit dialog's "Make connection" now has a "Reposition the other board /
  this board" choice (controller.make_connection(move_self=)), so you pick which
  side moves to seat the joint.
- scene.connect now group-moves: it captures the moving board's pre-seat pose,
  seats it, then applies the same rigid transform (_drag_group) to every board
  in its existing sub-assembly (excluding the anchor's group) — so previously
  connected parts travel with it instead of being left behind.

85 tests pass (sub-assembly stays rigid through a connect; verified by render:
a post seated into a rail carried its attached board along).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:49:38 -03:00
rob e530bf7656 Fix feature highlight wiped by scene re-render
Selecting a feature drew the cyan overlay, but the concurrent scene re-render
(render_scene -> plotter.clear) wiped it, leaving only the yellow selected
board. _on_changed now re-applies the feature overlay after render_scene, so
the cyan highlight (and red edit preview) persist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:31:27 -03:00
rob 20327ee9d3 Highlight the selected feature in the 3D scene (cyan)
Clicking a feature in the Joinery tab now highlights it in the viewport with a
cyan ghost so you can see which mortise/tenon it is; browsing candidates in the
"Fit to…" dialog highlights each one as you select it (restored to the active
feature on cancel). Reuses the overlay mechanism with a kind ("edit"=red pending
vs "highlight"=cyan); controller.highlight_feature drives it.

84 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:56:49 -03:00
rob 774ddd3480 GUI: assembly subtree + connection right-click menu
Parts tab is now a QTreeWidget: connected boards group under an "⛓ Assembly"
node (expandable) showing their members; standalone boards stay top-level. A
board with features is flagged ⊕. Multi-select still drives controller.selected
(selecting an assembly node selects its members).

Right-click menu: Back off connections (explode), Re-fit connections (assemble),
Break this board's connections / Break all. Controller wrappers: explode/
assemble/break_connections/groups.

83 tests pass; GUI imports clean (live tree behavior needs a display to verify).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:39:48 -03:00
rob fad56f4fc3 Track connections as assemblies (back-off / break / re-fit)
connect() now RECORDS a Connection (anchor feature, moving feature) instead of
just moving a board, so connected parts form an assembly:
- scene.groups(): connected-component part groups via the connection graph.
- explode(distance): back each moving board off along its joint axis (exploded
  view); assemble(): re-seat all (reverse); disconnect(cid/part): break a
  connection — pieces stay in place but become independent.
- _seat() extracted from connect() so re-fit re-runs the mate math.
- delete() drops connections referencing the removed part; clear() resets them;
  connections persist in scene.json.

Parts stay SEPARATE boards (not fused) so the cut list and disassembly keep
working — the assembly is a group, not a merge.

CLI: connections / disconnect / explode / assemble; voice: wood-connect/explode/
assemble/disconnect (25 tools). Fit dialog shows part names. 83 tests pass
(records+groups, explode/assemble roundtrip, disconnect keeps position).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:35:34 -03:00
rob e35020382d Add auto-assembly (Make connection) + feature rotation
"Make connection" checkbox in the Fit dialog moves/orients the other board so
its tenon seats into the mortise (faces meet, insertion axes aligned, cross-axes
matched):
- scene.connect(anchor, moving): builds the moving feature's desired world frame
  from Part.feature_world_frame, solves R = [dN|dU|dV]·[n|u|v]^T, decomposes to
  yaw/tilt/roll via matrix_to_ypr (inverse of local_frame's Rz·Ry(-tilt)·Rx(roll)),
  and positions so the contact points coincide. Verified: tenon-board stands and
  seats into a top mortise; Euler round-trip exact.
- Feature.rotation_deg: spin a feature about its face normal (geometry rotates
  the cut/add solid; preview + connect honor it) so cross-sections line up.
- Shared face_frame/rotation math moved to scene.py (geometry imports it).
- CLI `connect`, `--rotation` on features; voice `wood-connect`; GUI rotation
  field + "Make connection" checkbox. 22 wood-* tools.

79 tests pass (ypr round-trip, connect seats tenon, rotated feature cuts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:15:51 -03:00
rob 6f829a2c50 Add "Fit to mate" — size a mortise to a tenon (and vice versa)
In the Joinery tab, a tenon/mortise shows a "Fit to mortise…/tenon…" button
that opens a dialog listing the complementary features on other boards; picking
one resizes the active feature to mate:
- mortise = tenon cross-section + 1/32" clearance, pocket slightly deeper;
- tenon = mortise opening − 1/32" clearance, tongue reaching the pocket bottom.
controller.fit_feature + features_of_kind; commits + re-renders.

71 tests pass (fit mortise->tenon dims).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:17:08 -03:00
rob aabf289562 Cut list accounts for protruding tenons
A tenon adds length beyond the board's end, so the real piece you cut is longer
than length_in. cutlist.cut_length() now adds end-tenon protrusions to the cut
length used by the cut list, board-feet, and the buy-list (subtractive features
like mortises/holes don't change the stock you buy, so they're ignored). The
cut list notes when lengths include tenons.

70 tests pass (end-tenon extends cut length; cut features don't).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:04:20 -03:00
rob 70f8e9f0a2 Live red preview + Apply for feature editing
Adjusting a feature's fields was abstract and gave unclear feedback. Now:
- Dragging any field (Face/Along/Across/Width/Height/Depth/Diameter) shows a
  live translucent RED ghost of the pending feature over the committed one —
  a cheap pyvista box/cylinder (viewer.feature_preview_mesh), no re-tessellation,
  so it updates instantly.
- An Apply button commits the pending edit (controller.set_preview /
  apply_preview, preview_changed -> viewport.set_preview red overlay).
- Per-kind hint text + per-field tooltips explain what each parameter does.

68 tests pass (preview-then-apply, preview-mesh builds). Verified by render:
committed mortise + red ghost of a moved/resized pending edit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:39:00 -03:00
rob d0e40cdcbc Show real edges on featured boards (fix flat/edgeless look)
Featured (tessellated) boards were drawn with show_edges off to avoid triangle
noise, which left them looking flat and edgeless. Now overlay only the true
geometric edges via pyvista extract_feature_edges (corners, hole rims, chamfer
bevels, mortise walls) — crisp like plain boards, no mesh noise. Selected
boards get yellow edges. Applied in both the standalone viewer and the GUI
viewport. Verified by render: hole, mortise, tenon, and chamfer all read clearly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:32:42 -03:00
rob 9cbff4ec78 Add GUI Joinery panel (Phase B) + chamfers
GUI feature panel (gui/feature_panel.py), a "Joinery" tab beside Parts:
- Add buttons (Tenon/Mortise/Hole/Slot/Chamfer) drop a sensibly-sized feature
  on the selected board (add-with-default), then edit fields (face, along,
  across, width, height, depth, diameter) live; a feature list to pick which to
  edit; Delete. controller.active_feature tracks the one being edited, with
  size defaults derived from the board (controller._feature_defaults).

Chamfers (edge bevels):
- New EDGE_KINDS={"chamfer"}; geometry._apply_chamfer selects the edges around a
  face and bevels them with build123d chamfer(), clamped + try/except so an
  over-sized bevel can't crash the build. Verified: end + top-edge chamfers
  render and reduce volume.

66 tests pass (added chamfer volume + oversize-fallback). Verified GUI imports;
live window still needs a real display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:54:04 -03:00
rob a0072e6271 Add joinery features (parametric boolean tenon/mortise/hole/slot)
Features as re-editable objects attached to a board, each a boolean op:
- scene.py: Feature dataclass (kind/face/position/size/depth), Part.features,
  add_feature/edit_feature/delete_feature/find_feature, serialization + counter.
- geometry.py: part_solid now builds the local board then fuses tenons / cuts
  mortise/hole/slot/dado/rabbet via build123d booleans, then places it. _face_frame
  maps each board face; holes are oriented cylinders, others oriented boxes.
- viewer.py: featured boards render the tessellated true solid (edges off to
  avoid triangle noise); plain boards keep the fast pyvista box.
- cli.py: feature / feature-edit / feature-delete / features commands; status
  shows feature kinds. gui/controller: wood-feature(-delete) dispatch.
- 21 wood-* tools (added wood-feature, wood-feature-delete).

64 tests pass (feature model + build123d volume/tessellation checks). Verified
with a render: tenon + mortise + through-hole on one board, and STEP/STL export.

Phase A (model + geometry + CLI/voice). Next: GUI feature panel; chamfers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:27:57 -03:00
rob 417bf39d09 Add multi-select + numberpad control panel
Multi-select:
- Ctrl+click in the 3D view (viewport.picked carries an additive flag) and
  Ctrl/Shift in the parts list (ExtendedSelection) build controller.selected.
- Group ops (move_selected/rotate_selected/stand/lay/sand/delete) apply to every
  selected board in ONE undo step via new scene.batch() context manager.
- Voice "move these 4 inches in +y" works: the selected ids are fed into the
  interpreter prompt, which expands to one call per selected board.

Numberpad panel (gui/numpad.py):
- Buttons laid out like a numpad: 4/6/8/2 move X/Y, +/- move Z, 7/9 yaw, 1/3
  tilt, 0 front, . iso, 5 fit. Configurable move-step and angle-step.
- The physical numpad keys do the same — MainWindow.keyPressEvent forwards
  KeypadModifier keys to the panel (unless typing in the command box).

Scene: batch() coalesces checkpoints so a group action is a single undo.
56 tests passing (added batch, toggle-multiselect, group-move-undo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:47:39 -03:00
rob 9d21816542 Flush-by-default joins (corner alignment)
Boards now align to A's reference corner when they butt — top faces level and
one side flush — instead of B floating centered on A. The flush step snaps B's
+faces onto A's +faces along A's cross-section axes, skipping the axis B extends
along so the butt contact is preserved. Equal-size flat joints are unchanged;
mixed sizes (e.g. a 1x8 shelf on a 2x4) now line up cleanly (tops level).

Test: a 1x8 joined to a 2x4 sits tops-flush (center z=0.375), not centered.
53 tests passing; verified with a render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:21:55 -03:00
rob 7d01144143 Fix stuck "thinking…" — background task GC dropped the done signal
The QRunnable was auto-deleted the instant its work finished, destroying its
signals object before Qt delivered the queued result to the UI thread, so the
"done" callback never fired and the command bar stayed disabled. Now tasks keep
a strong reference (autoDelete off) until the result is delivered, then drop it.

Also: the WoodShop reply summary is now logged to the transcript on success
(previously computed but never shown), and the error path re-enables input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:15:22 -03:00
rob e9422aa133 Add unified desktop studio (woodshop / woodshop-gui)
A single PySide6 window combining the 3D viewport, parts panel, and command
bar — mouse, keyboard, and voice all drive the same scene and the same visible
selection (which resolves the "delete that" ambiguity).

- gui/controller.py: one in-memory Scene; buttons call typed methods, voice/
  typed commands go through driver.interpret and apply via execute_call, which
  REUSES the CLI command functions (no drift). Saves to disk + emits `changed`.
- gui/viewport.py: embedded pyvistaqt QtInteractor; click-to-select a board;
  camera presets; reuses _part_mesh/_PALETTE.
- gui/panels.py: parts list + selected inspector (editable length/yaw/tilt) +
  quick actions (stand/lay/rotate90/sand/duplicate/rename/delete).
- gui/command_bar.py + workers.py: text + push-to-talk mic + transcript +
  speak toggle; LLM/dictate/TTS run on a QThreadPool so the UI never blocks.
- gui/main_window.py: layout + menus (File open/save/export/render, Edit
  undo/redo/clear/delete, View cameras, Build templates + cut list, Help).
- Scene: added select() and redo() (+ _redo stack, CLI select/redo, wood-select/
  wood-redo tools). driver.dispatch takes a pluggable executor; interpret takes
  scene_text so the GUI feeds its in-memory state.
- Bare `woodshop` launches the studio; 'gui' extra; woodshop-gui entry point.

52 tests (incl. controller); GUI verified by import + offscreen controller
exercise (live VTK window needs a real display, untested headless).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:05:39 -03:00
rob b24e65548e Fix woodshop-view not closing
The live-update loop spun forever and ignored window close, so the viewport
window couldn't be dismissed. Now it detects closure (close flag via q/Escape
key events, plotter._closed, or render_window going away) and breaks the loop,
then calls plotter.close() to tear down cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:26:14 -03:00
rob 391bbcb3f9 Real butt-joint geometry (faces, not centerlines)
Boards now connect like real lumber: B's end butts flush against A's surface,
offset from A's centerline by A's cross-section half-extent in B's approach
direction (width/thickness, whichever B faces). Previously B's center landed on
A's centerline, so boards interpenetrated and shared centerlines.

- Added Part.local_frame() (length/width/thickness world axes via composed
  rotation matrices, matching geometry/viewer).
- join() computes the surface-contact offset; handles perpendicular T/L joints
  and vertical legs (leg base butts the rail face).
- Tests: butt joint meets surface not centerline; example sentence updated;
  vertical-leg join still correct. 45 passing.

Default alignment is B centered on A at the attach point. Not yet: joinery cuts
and flush-outer-face alignment options.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 02:31:20 -03:00
rob 17e7554ff1 Add `woodshop render <file.png>` for headless viewing
Saves an off-screen PNG of the scene (labels, grid, isometric) so the model can
be inspected without an interactive GUI window — useful over SSH or when
woodshop-view can't open a display. 44 tests passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 02:15:17 -03:00
rob 7b5c58902c Harden command parsing (review fix)
interpret() now extracts the FIRST balanced [...] array and tolerates code
fences / trailing prose, instead of a greedy [.*] that could swallow trailing
bracketed text and fail to parse. Falls back gracefully to a spoken apology.

Added regression tests for trailing brackets, fenced objects, and garbage.
44 tests passing; edge cases (angle 0, offset 0, negative moves, unknown
stock) verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 01:55:12 -03:00
rob 892a376669 Polish viewport, add named projects, concise voice summaries, docs
Viewport (woodshop-view): part labels (id/name), dimensioned floor grid in
inches, parallel-projection isometric default, selection highlight, quieter VTK.

Named projects: woodshop save/open/projects (slugified names under
~/.local/share/woodshop/projects/); wood-save/open/projects tools.

Driver: concise spoken summaries (verb+count roll-up so "build a table" speaks
one short line, not 12; queries/clarifications spoken verbatim); per-utterance
errors no longer kill the session; auto-discovers all wood-* tools.

Docs: real README and CLAUDE.md (architecture, full command set, limitations).
17 wood-* tools. 41 tests passing.

Verified end-to-end: "build a coffee table" and "make a bookshelf side frame"
each produce correct multi-board models with cut lists and STEP/STL export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 01:50:07 -03:00
rob 914c86303f Add 3D orientation, richer operations, and cut list
3D orientation (the key gap): boards now have yaw/tilt/roll, so legs and
uprights can stand vertically. geometry.py and viewer.py apply the full
rotation; join is orientation-aware (vertical boards rest their base on the
target face). Old rotation_deg scenes migrate transparently.

New operations + CLI subcommands + wood-* tools: stand, lay, rotate, move,
trim (cut to length), copy, rename (human aliases, resolvable by name), clear.
Parts resolve by id OR name.

Cut list (cutlist.py): grouped cut list, board-feet (nominal), and an 8'-stick
shopping estimate with waste — the workshop-assistant payoff.

Driver: auto-discovers all wood-* tools (glob), richer prompt that decomposes
"build a table" into place/stand/join/move and labels parts. Verified: one
sentence -> an 8-board table base with a correct cut list.

14 wood-* CmdForge tools regenerated. 36 tests passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 01:42:33 -03:00
rob fa03ee71d3 Add voice/conversational loop reusing CmdForge tools
- driver.py (woodshop-talk): the conversational loop. Reuses dictate (STT),
  pa-load-tools (schemas), claude -p (interpret), pa-execute-tool (dispatch),
  read-aloud (TTS). Resolves $N symbols so multi-op utterances can reference
  boards placed earlier in the same sentence; tolerates fenced/garbage output.
- wood-* CmdForge tools generator (scripts/gen_wood_tools.py): place/join/sand/
  delete/undo wrappers over the woodshop CLI; arg descriptions double as the
  LLM's command documentation.
- UX/realism fixes: lenient anchor parsing (end/start/far/near), and joins now
  stack board B on A's face in Z instead of interpenetrating centerlines.
- Tests: 25 passing (added anchor, Z-stack, and driver symbol-resolution tests).
- CLAUDE.md: architecture, entry points, setup, known limitations.

Verified end-to-end (typed): the canonical sentence produces the correct 4-op
scene; follow-up commands on a non-empty scene resolve ids correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 01:28:36 -03:00
rob a688623caf Add PoC core: scene model, operations, geometry, viewer
Voice-driven woodworking modeler core (the woodshop-specific half;
voice/AI plumbing will reuse existing CmdForge tools).

- scene.py: Part/Joint/Scene model, place/join/sand/delete/undo, JSON
  persistence (atomic), selection + undo stack
- lumber.py: nominal->actual dimensional lumber table
- units.py: parse "6 ft" / "3 ft 6 in" / "10 inches" to inches
- cli.py: `woodshop` CLI (place/join/sand/delete/undo/export/status)
- geometry.py: build123d solids + STL/STEP export
- viewer.py: live pyvista viewport watching scene.json
- tests: 20 passing, including the canonical example sentence
- pyproject: woodshop + woodshop-view entry points, [viewer] extra

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 01:15:01 -03:00
40 changed files with 6870 additions and 20 deletions

147
CLAUDE.md
View File

@ -4,7 +4,152 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
**WoodShop** - Voice-driven conversational 3D woodworking & furniture modeler
**WoodShop** - Voice-driven conversational 3D woodworking & furniture modeler.
Speak (or type) commands like *"place a 6 foot 2x4, sand it, attach a 2 foot 2x4
at 90 degrees 10 inches from the end"* and watch the model build in a live 3D
viewport — Holodeck-style.
## Architecture
**Design principle:** reuse existing CmdForge tools for everything that isn't
woodshop-specific; don't reinvent voice/AI plumbing.
```
woodshop-talk (driver.py) ── the conversational loop
│ dictate ............... speech→text (CmdForge tool, reused)
│ pa-load-tools ......... wood-* → Claude schemas (reused)
│ claude -p ............. interpret utterance → JSON tool calls (reused provider)
│ pa-execute-tool ....... dispatch each wood-* tool (reused)
│ read-aloud ........... speak confirmation (reused)
scene.json ← single source of truth (parts, joints, selection, undo stack)
▲ │ writes
│ reads/mutates ▼
wood-* CmdForge tools woodshop-view (viewer.py)
(place/join/sand/delete/undo) watches scene.json → live pyvista 3D
thin wrappers over `woodshop` CLI
```
Only woodshop-specific code lives in this repo: the scene model
(`scene.py`), nominal→actual lumber table (`lumber.py`), length parsing
(`units.py`), the `woodshop` CLI (`cli.py`), build123d geometry + STL/STEP
export (`geometry.py`), the pyvista viewport (`viewer.py`), and the driver
(`driver.py`). The driver uses Claude (not `pa-tool-loop`, which hard-wires a
small local model) for reliable structured tool-calling.
### Entry points
| Command | Purpose |
|---------|---------|
| `woodshop` (no args) / `woodshop-gui` | **The unified desktop studio** (viewport + parts panel + command bar) |
| `woodshop <op>` | CLI ops: place, join, stand, lay, rotate, move, trim, copy, rename, sand, delete, select, undo, redo, clear, status, cutlist, export, render, save, open, projects |
| `woodshop-view` | Standalone live 3D viewport (watches `scene.json`; labels, grid, isometric) |
| `woodshop-talk` | Standalone conversational driver (`--voice` for mic, `--once "..."` for one command) |
The studio (`src/woodshop/gui/`) is a thin PySide6 shell over the same Scene +
operations + interpreter:
- `controller.py` — one in-memory `Scene`; buttons/menus call typed methods,
voice/typed commands go through `driver.interpret` and are applied via
`execute_call`, which **reuses the CLI command functions** (no behavioral
drift). Every mutation saves to disk and emits `changed`.
- `viewport.py` — embedded `pyvistaqt.QtInteractor`; click a board to select.
- `panels.py` — parts list (ExtendedSelection: Ctrl/Shift multi-select) +
selected-part inspector (editable length/yaw/tilt) + quick-action buttons.
`command_bar.py` — text + push-to-talk + transcript, with slow work
(LLM/dictate/TTS) on a `QThreadPool` (`workers.py`).
- `numpad.py` — a numberpad control panel (2/4/6/8 move, 1/3/7/9 rotate, +/
raise/lower, 0/. front/iso, 5 fit) that also responds to the **physical
numpad keys** (MainWindow.keyPressEvent forwards them when not typing).
- **Spatial feedback**: `scene.spatial_summary()` (each board's world bounding
box via `Part.bbox()` + flagged interpenetrations) is fed into the interpreter
prompt, so the AI can reason about where boards are — e.g. "position the left
side flush against p2" becomes a computed `wood-move`, and it can fix overlaps.
- **Manual add**: the Parts tab has a stock dropdown + length + "Add board" to
place a board instantly without the AI (`controller.place`).
- **Multi-selection**: `controller.selected` is a list driven by 3D Ctrl+click
(`viewport.picked` carries an additive flag) and list multi-select. Group ops
(`move_selected`/`rotate_selected`/stand/lay/sand/delete) apply to all selected
in one undo step via `scene.batch()`. Voice "move these" works because the
selected ids are fed into the interpreter prompt.
Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`.
Named projects: `~/.local/share/woodshop/projects/<slug>.json`.
Stock is dimensional lumber (`lumber.NOMINAL_TO_ACTUAL`, fixed cross-section) or
**plywood** sheet stock (`ply-3/4`, `ply-1/2`, …): fixed thickness, but a width
given per-panel — `place(stock, length, width_in=)` requires a width for plywood.
The cut list reports plywood in sq-ft and buys it in 4×8 sheets (lumber stays
board-feet / 8' sticks).
Parts have full 3D orientation (`yaw_deg`/`tilt_deg`/`roll_deg`) so legs and
uprights stand vertically. Parts can be referred to by id (`p1`) or by a name
set with `rename`. The cut list (`cutlist.py`) reports board-feet and an 8'-stick
shopping estimate.
### CmdForge tools (the documented command vocabulary)
`wood-place`, `wood-join`, `wood-sand`, `wood-delete`, `wood-undo` live in
`~/.cmdforge/<name>/` and wrap the `woodshop` CLI. Regenerate them with
`/tmp/gen_wood_tools.py` (kept in the repo plan) if their schemas change. The
arg descriptions ARE the LLM's documentation, so keep them clear.
### Setup
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[viewer,dev]" # viewer extra pulls build123d + pyvista
pytest # 25 tests
```
### Known limitations / next steps
1. **Joins are flush butt joints**: B's end sits flush against A's surface, and
B is aligned to A's reference corner (top faces level + one side flush) rather
than centered. The flush corner is fixed (A's +width/+thick side; no per-join
choice of which corner / centered).
2. **Joinery features** (`Feature` on each `Part`) are parametric ops applied in
`geometry.part_solid`: `tenon` fuses a protruding tongue; `mortise`/`hole`/
`slot`/`dado`/`rabbet` cut a box/cylinder into a face; `chamfer` bevels the
edges around a face via build123d `chamfer()` (handled specially —
`_apply_chamfer`, with a try/except fallback for over-sized bevels). The
viewport tessellates featured boards (plain boards stay fast pyvista boxes).
Edit paths: CLI `feature/feature-edit/feature-delete/features`; voice
`wood-feature`/`wood-feature-delete`; **GUI Joinery tab** (`feature_panel.py`)
— add-with-default, then dragging a field shows a **live red preview ghost**
(`viewer.feature_preview_mesh` — a cheap box/cylinder, no re-tessellation) of
the pending change over the committed feature; **Apply** commits it
(`controller.set_preview`/`apply_preview`, `preview_changed` signal →
`viewport.set_preview`). Per-kind hints + field tooltips explain the
parameters. `controller.active_feature` is the one being edited. A **Fit to
mate…** button (`controller.fit_feature`) resizes a mortise to a chosen tenon
(or vice versa) — pocket = tongue + clearance (1/32"), pocket slightly deeper;
a dialog lists the complementary features (`features_of_kind`). The dialog's
**Make connection** checkbox calls `controller.make_connection`
`scene.connect(anchor, moving)`, which moves/orients the moving feature's
board so the tenon seats into the mortise (faces meet, axes aligned). Connect
builds the target world rotation from the feature frames and decomposes it to
the board's yaw/tilt/roll via `matrix_to_ypr` (inverse of `Part.local_frame`'s
Rz·Ry(-tilt)·Rx(roll)); `Part.feature_world_frame` gives each feature's world
point/normal/u/v. Features also have `rotation_deg` (spin about the face
normal) to line up cross-sections. CLI `connect`; voice `wood-connect`.
3. **Connections / assemblies**: `connect` RECORDS a `Connection`; connected
boards form an assembly (`scene.groups()`, connection-graph union-find). They
stay separate boards (not fused — cut list & disassembly keep working). Ops:
`scene.explode(d)` backs each moving board off along its joint axis,
`assemble()` re-seats (reverse), `disconnect(cid/part)` breaks (pieces stay
put). `_seat()` is the shared mate math; `connect()` then `_drag_group()`
applies the same rigid transform to the moving board's existing sub-assembly
(minus the anchor's group) so connected boards travel together. The GUI Fit
dialog lets you pick which board repositions (`make_connection(move_self=)`). CLI `connections/disconnect/explode/
assemble`; voice `wood-explode/assemble/disconnect`. The GUI Parts tab is a
QTreeWidget grouping connected boards under an assembly node, with a
right-click menu (back off / re-fit / break). Not yet: countersinks,
click-a-face-to-place, per-assembly naming/rename.
2. **Latency** ~713s per utterance (one `claude -p` call).
3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against
failures but the mic path isn't exercised in the unit tests.
4. Auto-placement of parts in a multi-step "build a table" request depends on
the LLM choosing good offsets; geometry is correct but corners may need nudging.
## ⚠️ CRITICAL: Updating Todos, Milestones, and Goals

124
README.md
View File

@ -1,39 +1,125 @@
# WoodShop
Voice-driven conversational 3D woodworking & furniture modeler
**Voice-driven conversational 3D woodworking & furniture modeler.**
Talk to it like the Star Trek holodeck and watch furniture build itself:
> *"Place a 6 foot 2x4, sand it, then attach a 2 foot 2x4 at 90 degrees, 10 inches from the end."*
> *"Build a coffee table: a four foot by two foot frame from 2x4s, with four legs 18 inches tall standing at the corners."*
Each board is real dimensional lumber (a 2x4 is modeled at its true 1.5″ × 3.5″),
so the result is buildable — export to **STEP** (CAD/CNC) or **STL** (3D print),
and get a **cut list with board-feet and a shopping estimate**.
## How it works
WoodShop reuses the existing [CmdForge](https://gitea.brrd.tech/rob/cmdforge)
tool ecosystem for everything that isn't woodworking-specific, so no wheels are
reinvented:
```
woodshop-talk ── the conversational loop
│ dictate ............. speech → text (CmdForge tool)
│ pa-load-tools ....... wood-* → Claude schemas (CmdForge tool)
│ claude -p ........... interpret → tool calls (provider)
│ pa-execute-tool ..... dispatch each wood-* (CmdForge tool)
│ read-aloud .......... speak confirmation (CmdForge tool)
scene.json ← single source of truth (parts, joints, selection, undo)
▲ │ writes
│ reads/mutates ▼
wood-* CmdForge tools woodshop-view
(place/join/stand/move/...) live pyvista 3D, watches scene.json
```
The `wood-*` tools are thin wrappers over the `woodshop` CLI, so the modeling
logic lives in one place and the tools double as the LLM's documented command
vocabulary.
## Installation
```bash
pip install -e .
python -m venv .venv && source .venv/bin/activate
pip install -e ".[gui,dev]" # 'gui' pulls build123d + pyvista + PySide6 + pyvistaqt
python scripts/gen_wood_tools.py # register the wood-* CmdForge tools
```
## Usage
*TODO: Add usage instructions*
### The studio (recommended)
## Documentation
```bash
woodshop # launches the unified desktop app
```
Full documentation is available at: https://pages.brrd.tech/rob/woodshop/
One window with the **3D viewport** (click a board to select it; Ctrl+click to
select several), a **parts panel** (list + selected-part inspector +
quick-action buttons), a **numberpad control panel** (move/rotate the selection
by clicking or with your keyboard's numpad — 2/4/6/8 move, 1/3/7/9 rotate, +/
raise/lower, 0/. front/iso, 5 fit), and a **command bar** where you type or
push-to-talk (🎤). Mouse, keyboard, and voice all drive the same scene and the
same visible selection — so "move these 4 inches", the numpad 8 key, and the
move button are interchangeable, and act on every selected board at once (one
undo). Menus cover New/Open/Save projects, Export STL/STEP, Save Image,
Undo/Redo, camera views, and Build templates.
### Standalone tools (headless / scripting)
```bash
woodshop-view & # just the live 3D window (watches the scene)
woodshop-talk # just the voice/text loop; --voice to speak
woodshop-talk --once "build a workbench top from five 2x6 boards 6 feet long"
```
Or drive it directly from the CLI:
```bash
woodshop place 2x4 "6 ft" # place a board
woodshop stand # stand it up (a leg)
woodshop join p2 --to p1 --angle 90 --offset "10 in"
woodshop rename "front-left leg"
woodshop cutlist # bill of materials
woodshop export table.step # STEP / STL export
woodshop save "coffee table" # named projects
woodshop open "coffee table"
```
Run `woodshop --help` for the full command list (place, join, stand, lay,
rotate, move, trim, copy, rename, sand, delete, undo, clear, status, cutlist,
export, save, open, projects).
The active scene lives at `$WOODSHOP_SCENE` or
`~/.local/share/woodshop/scene.json`; named projects in
`~/.local/share/woodshop/projects/`.
## Development
```bash
# Clone the repository
git clone https://gitea.brrd.tech/rob/woodshop.git
cd woodshop
# Create virtual environment
python -m venv .venv
source .venv/bin/activate
# Install for development
pip install -e ".[dev]"
# Run tests
pytest
pytest # 41 tests
```
Key modules:
| Module | Role |
|--------|------|
| `scene.py` | Part/Joint/Scene model, operations, undo, persistence |
| `lumber.py` | nominal → actual dimensional lumber table |
| `units.py` | parse "6 ft" / "3 ft 6 in" / "-2 ft" → inches |
| `cli.py` | the `woodshop` command |
| `geometry.py` | build123d solids + STL/STEP export |
| `cutlist.py` | cut list, board-feet, shopping estimate |
| `viewer.py` | live pyvista 3D viewport (`woodshop-view`) |
| `driver.py` | conversational loop (`woodshop-talk`) |
| `scripts/gen_wood_tools.py` | (re)generate the `wood-*` CmdForge tools |
### Known limitations
- Joins are flush butt joints: B's end sits against A's face and B aligns to
A's reference corner (tops level + one side flush), so mixed-size boards line
up. Joinery *cuts* (mortise/tenon, lap, pocket holes) aren't modeled yet.
- Command interpretation latency is ~713s per utterance (one `claude -p` call).
## License
*TODO: Add license*
MIT

95
SHOP_PACKET_PLAN.md Normal file
View File

@ -0,0 +1,95 @@
# Shop Packet Plan
A living plan for turning the BOM into a **shop-packet generator**. Adjust as we go.
**Status:** Phases 04 implemented (cutplan.py model; multi-strategy auto-layout;
deterministic instructions + AI polish; rule-based jig suggestions; constrained
drag-edit layout). Logic is unit-tested; the drag/print GUI needs a real display to
verify interactively.
Review fixes applied: one active CutPlan rendered by every tab; unplaced parts
surfaced in Shopping; process-stable shuffle (hashlib); kerf-gap validation; drop
stock-type compatibility; waste/score recompute after manual edits; rotation legality
(settings/grain); position-aware jig grouping.
**Phase 1 now complete:** bounded branch-and-bound exact lumber packing
(`_min_bins`/`_pack_lumber_exact`, ≤12 pieces, FFD-seeded with a count bound),
guillotine free-rectangle plywood packing (`_pack_plywood_guillotine`, best-area-fit +
rotation), a real "Best of 100" control in the Cut Layout tab, and richer scoring that
prefers more & longer reusable offcuts (`reusable_in` tie-break). Lock-aware
re-optimization also landed (locked pieces preserved through "Find better layout"/"Best of N").
Remaining follow-ups: grain-direction in auto-layout, on-hand offcut inventory,
opt-in jig material in the BOM.
## Guiding principle
The **math layer is deterministic and inspectable**; AI is used **only for narrative**
(instruction wording, jig explanations). Cut lengths, kerf, counts, layouts, jig
dimensions, validation, and warnings all come from code — the AI never invents a number.
UI language: say **"Optimize" / "Find better layout"**, never "optimal" (woodworking
wants explainable good layouts, not slow provably-perfect ones).
## Data flow
```
Scene → CutItems → StockInventory → CutPlan → ShopPacket(view)
```
## The keystone: `CutPlan` (cutplan.py)
Dataclasses, JSON-friendly, **stable IDs everywhere** (`CutItem.id`, `StockPiece.id`,
`Placement.id`) — never rely on list position. Serializable from day one
(`to_dict`/`from_dict`) so we can save manual layouts, compare strategies, export, debug.
- `ShopSettings` — kerf, stick/sheet sizes, offcut-usable thresholds, plywood rotation
allowed, grain direction (future), tolerances (mortise/tenon clearance, sanding
allowance, reveal). Defaults present from day one even before they're in the UI.
- `CutItem` — a required piece (part id, stock, length, width, is_sheet, note e.g. "incl. tenon").
- `StockPiece` — a physical stick/sheet with its `placements` and `waste` regions.
- `Placement` — a cut item on a stock piece: position (x[,y]), rotated?, locked?.
- `WasteRegion` — leftover, with a `reusable` flag (≥ threshold).
- `CutPlan` — settings, items, stock_pieces, unplaced, strategy, **score**, warnings.
- `score = {stock_count, waste_area, reusable_offcuts, warnings, strategy_name}`
detailed, so the UI can explain *why* one layout beats another.
- `build_cut_plan(scene, settings=None, strategy="decreasing") -> CutPlan`.
- `validate_cut_plan(plan) -> [problems]` — no piece outside stock, no overlaps, kerf
respected, every item placed-or-warned, stock dims respected, rotations legal.
`ShopPacket` stays thin (a view/composition over cut rows + shopping rows + cut plan +
warnings) until `CutPlan` is solid.
## Phases (commit after each)
**Phase 0 — CutPlan + ShopSettings (keystone).**
New `cutplan.py` with the model + `build_cut_plan` + `validate_cut_plan`. Port the current
FFD (lumber) / shelf (plywood) packers behind it. **Keep old APIs** (`layout.nest_lumber/
nest_plywood/stock_counts/waste_summary`, `cutlist.shopping/waste_summary`) as thin wrappers
over `build_cut_plan` so existing tests/UI keep working. BOM window renders from `CutPlan`.
Tests: lumber, plywood, kerf, tenon extra length, unplaced/oversize warnings, JSON roundtrip.
**Phase 1 — smart auto-layout.** Strategies behind the buttons: FFD, BFD, bounded exact
(small jobs, capped), random restarts / best-of-N for big jobs; objective "minimize stock,
then maximize useful offcuts (bonus for common 12/24/36″)". Plywood: per-panel rotation;
shelf/guillotine/maxrects; score by sheet count, waste area, reusable-offcut size. Buttons:
**Optimize · Try Alternative · Best of N**; surface warnings.
**Phase 2 — structured instructions.** Deterministic ordered steps from CutPlan + scene
(buy → cut per plan → mark joinery → repeated cuts/jigs → cut/drill features → dry-fit →
assemble → finish); **then** AI polishes wording (numbers stay from code). Instructions tab.
**Phase 3 — jig suggestions (rule-based → AI explanation).** Detect patterns (identical
crosscuts, repeated end-offsets, repeated mortises/holes, mirrored L/R, repeated angles,
repeated panel widths) → candidates with **computed dims** (stop block, spacer, drill
template, story stick, mortise template, angle sled). AI explains build/use. Jigs are
**shop aids** kept separate from project parts — optional, opt-in before any jig material
enters the BOM. Jigs tab.
**Phase 4 — constrained manual layout editing.** Drag in the layout view as a *constrained
planner*: snap to stock edges / kerf / neighbors; invalid = red; move pieces between
sticks/sheets; rotate plywood (if grain allows); **lock** a piece so re-optimization works
around it; live "valid / invalid / saves a stick / wastes more" feedback. Builds on
`CutPlan.locked` + `validate_cut_plan`.
## Deterministic vs AI
| Code (deterministic) | AI (narrative only) |
|---|---|
| lengths, kerf, counts, layouts, scores, jig dims, validation, warnings | instruction wording, jig build/use explanations, summaries |

View File

@ -10,7 +10,25 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = []
[project.scripts]
woodshop = "woodshop.cli:main"
woodshop-gui = "woodshop.gui.app:main"
woodshop-view = "woodshop.viewer:main"
woodshop-talk = "woodshop.driver:main"
[project.optional-dependencies]
# Heavy 3D stack (OpenCASCADE etc.) — only needed to run the live viewport.
viewer = [
"build123d>=0.6",
"pyvista>=0.43",
]
# The unified desktop studio (embeds the viewport in a Qt window).
gui = [
"build123d>=0.6",
"pyvista>=0.43",
"PySide6>=6.6",
"pyvistaqt>=0.11",
]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",

271
scripts/gen_wood_tools.py Normal file
View File

@ -0,0 +1,271 @@
"""Generate the wood-* CmdForge tools: the documented woodworking command
vocabulary. Each is a thin wrapper over the `woodshop` CLI so the logic lives in
one place; pa-load-tools turns these into Claude function schemas.
The arg descriptions ARE the LLM's documentation — keep them clear and example-rich.
Run this after changing the woodshop CLI to refresh the tools:
python scripts/gen_wood_tools.py
"""
import os
import stat
from pathlib import Path
import yaml
CMDFORGE_PY = "/home/rob/.local/share/pipx/venvs/cmdforge/bin/python"
CMDFORGE_DIR = Path.home() / ".cmdforge"
BIN_DIR = Path.home() / ".local" / "bin"
WS = 'ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")'
def code(body: str) -> str:
"""Wrap a command-building body that sets `cmd`, then runs it."""
return (f"import subprocess, os\n{WS}\n{body}\n"
"r = subprocess.run(cmd, capture_output=True, text=True)\n"
"out = (r.stdout + r.stderr).strip()\n")
TOOLS = {
"wood-place": {
"description": "Place a new board of dimensional lumber, or a plywood panel. Use for 'place', 'add', 'put', 'grab', 'cut me a' board/panel.",
"arguments": [
{"flag": "--stock", "variable": "stock", "description": "Lumber size e.g. 2x4, 2x6, 1x4, 4x4; or plywood e.g. ply-3/4, ply-1/2, ply-1/4"},
{"flag": "--length", "variable": "length", "description": "Length with units, e.g. '6 ft', '72 in', '3 ft 6 in'"},
{"flag": "--width", "variable": "width", "default": "", "description": "Panel width (REQUIRED for plywood, ignored for lumber), e.g. '24 in'"},
],
"code": code(
'cmd = [ws, "place", stock, length]\n'
'if width != "": cmd += ["--width", str(width)]'
),
},
"wood-join": {
"description": "Attach one board to another at an angle, optionally offset along the target. Use for 'attach', 'join', 'connect', 'fasten'.",
"arguments": [
{"flag": "--part-b", "variable": "part_b", "description": "Id or name of the board being attached, e.g. p2"},
{"flag": "--to", "variable": "to", "default": "", "description": "Board to attach to, e.g. p1 or 'front rail'. Omit for the most recent board."},
{"flag": "--angle", "variable": "angle", "default": "90", "description": "Angle in degrees between the boards (default 90)"},
{"flag": "--offset", "variable": "offset", "default": "", "description": "Distance from the anchor end, e.g. '10 in'. Omit to attach at the end."},
{"flag": "--anchor", "variable": "anchor", "default": "end_b", "description": "Measure offset from 'end' (far end) or 'start'"},
],
"code": code(
'cmd = [ws, "join", part_b]\n'
'if to: cmd += ["--to", to]\n'
'if angle: cmd += ["--angle", str(angle)]\n'
'if offset: cmd += ["--offset", offset]\n'
'if anchor: cmd += ["--anchor", anchor]'
),
},
"wood-stand": {
"description": "Stand a board up vertically (e.g. a table or chair leg). Use for 'stand up', 'make it vertical', 'upright'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."},
{"flag": "--tilt", "variable": "tilt", "default": "90", "description": "Tilt degrees, 90 = straight up (default 90)"},
],
"code": code('cmd = [ws, "stand"] + ([part] if part else []) + ["--tilt", str(tilt)]'),
},
"wood-lay": {
"description": "Lay a board flat / horizontal. Use for 'lay it down', 'make it flat', 'horizontal'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."},
],
"code": code('cmd = [ws, "lay"] + ([part] if part else [])'),
},
"wood-rotate": {
"description": "Rotate / re-orient a board. Use for 'rotate', 'turn', 'angle it'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
{"flag": "--yaw", "variable": "yaw", "default": "", "description": "Heading in the horizontal plane, degrees"},
{"flag": "--tilt", "variable": "tilt", "default": "", "description": "Elevation toward vertical, degrees"},
{"flag": "--roll", "variable": "roll", "default": "", "description": "Rotation about the board's own length, degrees"},
],
"code": code(
'cmd = [ws, "rotate"] + ([part] if part else [])\n'
'if yaw != "": cmd += ["--yaw", str(yaw)]\n'
'if tilt != "": cmd += ["--tilt", str(tilt)]\n'
'if roll != "": cmd += ["--roll", str(roll)]'
),
},
"wood-move": {
"description": "Move/slide a board by an offset (or to an absolute position). Use for 'move', 'slide', 'shift', 'nudge'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
{"flag": "--dx", "variable": "dx", "default": "", "description": "X offset, e.g. '5 in', '-2 ft' (X = along the first board)"},
{"flag": "--dy", "variable": "dy", "default": "", "description": "Y offset"},
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset (up/down)"},
],
"code": code(
'cmd = [ws, "move"] + ([part] if part else [])\n'
'if dx != "": cmd += ["--dx", dx]\n'
'if dy != "": cmd += ["--dy", dy]\n'
'if dz != "": cmd += ["--dz", dz]'
),
},
"wood-trim": {
"description": "Cut a board down to a new length. Use for 'cut it to', 'trim to', 'shorten to', 'make it N feet'.",
"arguments": [
{"flag": "--length", "variable": "length", "description": "New length with units, e.g. '4 ft'"},
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
],
"code": code('cmd = [ws, "trim", length] + (["--part", part] if part else [])'),
},
"wood-copy": {
"description": "Duplicate a board, offset by dx/dy/dz. Use for 'copy', 'duplicate', 'another one like that'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board to copy (default: most recent)"},
{"flag": "--dx", "variable": "dx", "default": "", "description": "X offset for the copy, e.g. '46 in'"},
{"flag": "--dy", "variable": "dy", "default": "", "description": "Y offset"},
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset"},
],
"code": code(
'cmd = [ws, "copy"] + ([part] if part else [])\n'
'if dx != "": cmd += ["--dx", dx]\n'
'if dy != "": cmd += ["--dy", dy]\n'
'if dz != "": cmd += ["--dz", dz]'
),
},
"wood-rename": {
"description": "Give a board a human-friendly name so it can be referred to by name later. Use for 'call it', 'name it', 'this is the'.",
"arguments": [
{"flag": "--name", "variable": "name", "description": "The name, e.g. 'front-left leg'"},
{"flag": "--part", "variable": "part", "default": "", "description": "Board id (default: most recent)"},
],
"code": code('cmd = [ws, "rename", name] + (["--part", part] if part else [])'),
},
"wood-sand": {
"description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit to sand the most recent board ('it')."},
],
"code": code('cmd = [ws, "sand"] + ([part] if part else [])'),
},
"wood-delete": {
"description": "Remove a board. Use for 'delete', 'remove', 'get rid of', 'scrap'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
],
"code": code('cmd = [ws, "delete"] + ([part] if part else [])'),
},
"wood-select": {
"description": "Set the current selection — the board future commands like 'rotate that' or 'delete it' act on. Use for 'select', 'pick', 'grab the', 'use the'.",
"arguments": [
{"flag": "--part", "variable": "part", "description": "Board id or name to select, e.g. p3 or 'front-left leg'"},
],
"code": code('cmd = [ws, "select", part]'),
},
"wood-undo": {
"description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.",
"arguments": [],
"code": code('cmd = [ws, "undo"]'),
},
"wood-redo": {
"description": "Redo the last undone operation. Use for 'redo', 'put it back', 'never mind that undo'.",
"arguments": [],
"code": code('cmd = [ws, "redo"]'),
},
"wood-clear": {
"description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.",
"arguments": [],
"code": code('cmd = [ws, "clear"]'),
},
"wood-feature": {
"description": "Add a joinery feature to a board: tenon (male tongue), mortise (pocket), hole, slot, dado, or rabbet. Use for 'add a tenon', 'cut a mortise', 'drill a hole', 'cut a slot'.",
"arguments": [
{"flag": "--kind", "variable": "kind", "description": "tenon | mortise | hole | slot | dado | rabbet"},
{"flag": "--part", "variable": "part", "default": "", "description": "Board id/name (default: most recent)"},
{"flag": "--face", "variable": "face", "default": "end_b", "description": "Which face: end_a, end_b, top, bottom, left, right"},
{"flag": "--along", "variable": "along", "default": "", "description": "Position along the board (e.g. '3 in'), or 1st offset on an end"},
{"flag": "--across", "variable": "across", "default": "", "description": "Offset across the face from centre"},
{"flag": "--width", "variable": "width", "default": "", "description": "Feature width, e.g. '1.5 in'"},
{"flag": "--height", "variable": "height", "default": "", "description": "Feature height/thickness"},
{"flag": "--depth", "variable": "depth", "default": "", "description": "Cut depth, or tenon protrusion length"},
{"flag": "--diameter", "variable": "diameter", "default": "", "description": "Hole diameter, e.g. '0.5 in'"},
{"flag": "--rotation", "variable": "rotation", "default": "", "description": "Rotate the feature about its face normal, degrees"},
],
"code": code(
'cmd = [ws, "feature", kind]\n'
'if part: cmd += ["--part", part]\n'
'if face: cmd += ["--face", face]\n'
'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n'
' ("--height", height), ("--depth", depth), ("--diameter", diameter),\n'
' ("--rotation", rotation)]:\n'
' if val != "": cmd += [flag, str(val)]'
),
},
"wood-connect": {
"description": "Move/orient one board so its tenon/mortise seats into another's matching feature. Use for 'connect', 'assemble', 'join the pieces together', 'fit them together'.",
"arguments": [
{"flag": "--anchor", "variable": "anchor", "description": "Feature id that stays put, e.g. f1"},
{"flag": "--moving", "variable": "moving", "description": "Feature id whose board moves to mate, e.g. f2"},
],
"code": code('cmd = [ws, "connect", anchor, moving]'),
},
"wood-explode": {
"description": "Back connected boards off along their joint axes for an exploded view. Use for 'explode', 'back off the connections', 'show it pre-assembled'.",
"arguments": [
{"flag": "--distance", "variable": "distance", "description": "How far to separate, e.g. '3 in'"},
],
"code": code('cmd = [ws, "explode", distance]'),
},
"wood-assemble": {
"description": "Re-seat all connections (reverse an explode / re-fit the joints). Use for 'assemble', 'put it back together', 're-fit', 'close it up'.",
"arguments": [],
"code": code('cmd = [ws, "assemble"]'),
},
"wood-disconnect": {
"description": "Break a connection so the pieces become independent (they stay where they are). Use for 'disconnect', 'break the connection', 'separate them'.",
"arguments": [
{"flag": "--connection", "variable": "connection", "description": "Connection id, e.g. c1"},
],
"code": code('cmd = [ws, "disconnect", connection]'),
},
"wood-feature-delete": {
"description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.",
"arguments": [
{"flag": "--fid", "variable": "fid", "description": "Feature id, e.g. f1"},
],
"code": code('cmd = [ws, "feature-delete", fid]'),
},
"wood-cutlist": {
"description": "Report the cut list / bill of materials: every board, board-feet, and how much lumber to buy. Use for 'cut list', 'what do I need to buy', 'bill of materials', 'how much wood'.",
"arguments": [],
"code": code('cmd = [ws, "cutlist"]'),
},
"wood-save": {
"description": "Save the current design as a named project. Use for 'save this as', 'save the project', 'remember this design'.",
"arguments": [
{"flag": "--name", "variable": "name", "description": "Project name, e.g. 'coffee table'"},
],
"code": code('cmd = [ws, "save", name]'),
},
"wood-open": {
"description": "Open a previously saved project (replaces the current scene). Use for 'open', 'load the', 'go back to my'.",
"arguments": [
{"flag": "--name", "variable": "name", "description": "Name of the project to open"},
],
"code": code('cmd = [ws, "open", name]'),
},
"wood-projects": {
"description": "List saved projects. Use for 'what projects do I have', 'list my designs'.",
"arguments": [],
"code": code('cmd = [ws, "projects"]'),
},
}
WRAPPER = ('#!/bin/bash\n# CmdForge wrapper for \'{name}\'\n# Auto-generated - do not edit\n'
'exec "{py}" -m cmdforge.runner "{name}" "$@"\n')
for name, spec in TOOLS.items():
tool_dir = CMDFORGE_DIR / name
tool_dir.mkdir(parents=True, exist_ok=True)
config = {
"name": name, "description": spec["description"], "category": "Other",
"version": "0.2.0", "arguments": spec["arguments"],
"steps": [{"type": "code", "code": spec["code"], "output_var": "out"}],
"output": "{out}",
}
(tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
wrapper = BIN_DIR / name
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY))
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
print(f"created {name}")
print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}")

11
src/woodshop/__init__.py Normal file
View File

@ -0,0 +1,11 @@
"""WoodShop - voice-driven conversational 3D woodworking & furniture modeler.
Architecture (see CLAUDE.md):
- The *scene* (parts + joints) is the single source of truth, persisted as JSON.
- Voice/AI/agent-loop plumbing is reused from existing CmdForge tools
(`dictate`, `read-aloud`, `pa-load-tools`, `pa-reason-core`, `pa-tool-loop`).
- This package owns only what is genuinely woodshop-specific: the scene model,
the woodworking operations, and a live 3D viewport.
"""
__version__ = "0.1.0"

3
src/woodshop/__main__.py Normal file
View File

@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())

444
src/woodshop/cli.py Normal file
View File

@ -0,0 +1,444 @@
"""WoodShop command-line interface.
Each subcommand loads the active scene, applies one operation, saves, and prints
a short human-readable confirmation (which the driver speaks back via TTS). The
CmdForge `wood-*` tools are thin wrappers around these subcommands, so the
operation logic lives here once.
"""
from __future__ import annotations
import argparse
import sys
from .scene import Scene, SceneError
from .units import to_inches
def _fmt_len(inches: float) -> str:
feet, rem = divmod(round(inches, 2), 12)
if feet and rem:
return f"{int(feet)} ft {rem:g} in"
if feet:
return f"{int(feet)} ft"
return f"{rem:g} in"
def cmd_place(scene: Scene, args) -> str:
length = to_inches(args.length, default_unit=args.unit)
width = to_inches(args.width, default_unit=args.unit) if getattr(args, "width", None) else None
part = scene.place(args.stock, length, width_in=width)
extra = f" ({_fmt_len(part.section_in[1])} wide)" if width else ""
return f"Placed {part.id}: a {_fmt_len(length)} {part.stock}{extra}."
_ANCHOR_ALIASES = {
"end_a": "end_a", "start": "end_a", "near": "end_a", "beginning": "end_a",
"end_b": "end_b", "end": "end_b", "far": "end_b", "tip": "end_b",
}
def normalize_anchor(value: str) -> str:
"""Accept loose spoken anchors ('the end', 'start') -> end_a/end_b."""
return _ANCHOR_ALIASES.get((value or "end_b").strip().lower(), "end_b")
def cmd_join(scene: Scene, args) -> str:
anchor = normalize_anchor(args.anchor)
offset = to_inches(args.offset, default_unit=args.unit) if args.offset else 0.0
joint = scene.join(args.part_a, args.part_b, angle_deg=args.angle,
offset_in=offset, anchor=anchor)
where = f" {_fmt_len(offset)} from {'the start' if anchor == 'end_a' else 'the end'}" if offset else ""
return f"Joined {joint.part_b} to {joint.part_a} at {args.angle:g} degrees{where}."
def cmd_sand(scene: Scene, args) -> str:
part = scene.finish(args.part, kind="sanded")
return f"Sanded {part.id}."
def cmd_delete(scene: Scene, args) -> str:
return scene.delete(args.part)
def cmd_select(scene: Scene, args) -> str:
part = scene.select(args.part)
return f"Selected {part.id}" + (f" ('{part.name}')" if part.name else "") + "."
def cmd_undo(scene: Scene, args) -> str:
return scene.undo()
def cmd_redo(scene: Scene, args) -> str:
return scene.redo()
def cmd_stand(scene: Scene, args) -> str:
part = scene.stand(args.part, tilt_deg=args.tilt)
how = "standing up" if part.is_vertical else f"tilted to {args.tilt:g}°"
return f"Set {part.id} {how}."
def cmd_lay(scene: Scene, args) -> str:
part = scene.stand(args.part, tilt_deg=0.0)
return f"Laid {part.id} flat."
def cmd_rotate(scene: Scene, args) -> str:
part = scene.orient(args.part, yaw=args.yaw, tilt=args.tilt, roll=args.roll)
return (f"Oriented {part.id}: yaw {part.yaw_deg:g}°, "
f"tilt {part.tilt_deg:g}°, roll {part.roll_deg:g}°.")
def cmd_move(scene: Scene, args) -> str:
dx = to_inches(args.dx, args.unit) if args.dx else 0.0
dy = to_inches(args.dy, args.unit) if args.dy else 0.0
dz = to_inches(args.dz, args.unit) if args.dz else 0.0
part = scene.move(args.part, dx, dy, dz, absolute=args.absolute)
verb = "Positioned" if args.absolute else "Moved"
return f"{verb} {part.id}."
def cmd_trim(scene: Scene, args) -> str:
length = to_inches(args.length, default_unit=args.unit)
part = scene.set_length(args.part, length)
return f"Cut {part.id} to {_fmt_len(length)}."
def cmd_copy(scene: Scene, args) -> str:
dx = to_inches(args.dx, args.unit) if args.dx else 0.0
dy = to_inches(args.dy, args.unit) if args.dy else 0.0
dz = to_inches(args.dz, args.unit) if args.dz else 0.0
part = scene.copy(args.part, dx, dy, dz)
return f"Copied to {part.id}."
def cmd_rename(scene: Scene, args) -> str:
part = scene.rename(args.part, args.name)
return f"Named {part.id} '{part.name}'."
def cmd_clear(scene: Scene, args) -> str:
return scene.clear()
def cmd_save(scene: Scene, args) -> str:
from .scene import project_path
path = scene.save(project_path(args.name))
return f"Saved project '{args.name}' ({len(scene.parts)} parts)."
def cmd_open(scene: Scene, args) -> str:
from .scene import project_path
path = project_path(args.name)
if not path.exists():
from .scene import list_projects
avail = ", ".join(list_projects()) or "none"
raise SceneError(f"No project '{args.name}'. Available: {avail}")
loaded = Scene.load(path)
scene.__dict__.update(loaded.__dict__)
return f"Opened project '{args.name}' ({len(scene.parts)} parts)."
def cmd_projects(scene: Scene, args) -> str:
from .scene import list_projects
names = list_projects()
return "Saved projects: " + (", ".join(names) if names else "none yet")
def _optlen(v, unit="inch"):
return to_inches(v, default_unit=unit) if v not in (None, "") else None
def _optdeg(v):
return float(v) if v not in (None, "") else None
def cmd_feature(scene: Scene, args) -> str:
feat = scene.add_feature(
args.part, args.kind, face=args.face,
along_in=_optlen(args.along), across_in=_optlen(args.across),
width_in=_optlen(args.width), height_in=_optlen(args.height),
depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter),
rotation_deg=_optdeg(args.rotation))
part = scene.find_feature(feat.id)[0]
return f"Added {feat.kind} ({feat.id}) to {part.id} on {feat.face}."
def cmd_feature_edit(scene: Scene, args) -> str:
feat = scene.edit_feature(
args.fid, face=args.face,
along_in=_optlen(args.along), across_in=_optlen(args.across),
width_in=_optlen(args.width), height_in=_optlen(args.height),
depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter),
rotation_deg=_optdeg(args.rotation))
return f"Updated feature {feat.id}."
def cmd_connect(scene: Scene, args) -> str:
return scene.connect(args.anchor, args.moving)
def cmd_connections(scene: Scene, args) -> str:
if not scene.connections:
return "No connections."
lines = []
for c in scene.connections:
if not scene._conn_valid(c):
lines.append(f" {c.id}: (stale)")
continue
ap, mp = scene.feature_owner(c.anchor), scene.feature_owner(c.moving)
off = f" (backed off {c.backed_off_in:g}\")" if c.backed_off_in else ""
lines.append(f" {c.id}: {mp.id}.{c.moving}{ap.id}.{c.anchor}{off}")
groups = [g for g in scene.groups() if len(g) > 1]
if groups:
lines.append("Assemblies: " + "; ".join("+".join(g) for g in groups))
return "\n".join(lines)
def cmd_disconnect(scene: Scene, args) -> str:
return scene.disconnect(cid=args.connection)
def cmd_explode(scene: Scene, args) -> str:
return scene.explode(to_inches(args.distance))
def cmd_assemble(scene: Scene, args) -> str:
return scene.assemble()
def cmd_feature_delete(scene: Scene, args) -> str:
return scene.delete_feature(args.fid)
def cmd_feature_list(scene: Scene, args) -> str:
rows = [(p, f) for p in scene.parts for f in p.features
if not args.part or p.id == args.part or p.name == args.part]
if not rows:
return "No features."
lines = []
for p, f in rows:
dims = (f"{f.diameter_in:g}" if f.kind == "hole"
else f"{f.width_in:g}×{f.height_in:g}×{f.depth_in:g}")
lines.append(f" {f.id}: {f.kind} on {p.id} {f.face} ({dims})")
return "\n".join(lines)
def cmd_export(scene: Scene, args) -> str:
from .geometry import export # lazy: keeps build123d out of the core path
path = export(scene, args.path)
return f"Exported {len(scene.parts)} part(s) to {path}."
def cmd_cutlist(scene: Scene, args) -> str:
from .cutlist import format_cutlist # lazy
return format_cutlist(scene)
def cmd_render(scene: Scene, args) -> str:
from .viewer import render_to_file # lazy: pulls in pyvista
if not scene.parts:
return "Nothing to render — the scene is empty."
path = render_to_file(scene, args.path)
return f"Rendered {len(scene.parts)} part(s) to {path}."
def _describe_part(p) -> str:
bits = [f"{_fmt_len(p.length_in)} {p.stock}"]
if p.name:
bits.append(f'"{p.name}"')
if p.is_vertical:
bits.append("vertical")
elif p.tilt_deg:
bits.append(f"tilt {p.tilt_deg:g}°")
if p.yaw_deg:
bits.append(f"yaw {p.yaw_deg:g}°")
if p.finishes:
bits.append(f"[{', '.join(p.finishes)}]")
if p.features:
bits.append(f"{{{', '.join(f.kind for f in p.features)}}}")
return f" {p.id}: " + ", ".join(bits)
def cmd_status(scene: Scene, args) -> str:
if not scene.parts:
return "The scene is empty."
lines = [f"{len(scene.parts)} part(s), {len(scene.joints)} joint(s); "
f"selection: {scene.selection or 'none'}"]
lines += [_describe_part(p) for p in scene.parts]
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="woodshop", description="Voice/CLI woodworking operations.")
p.add_argument("--scene", help="Path to scene.json (default: $WOODSHOP_SCENE or XDG data dir)")
# No subcommand launches the GUI studio (see main()).
sub = p.add_subparsers(dest="command", required=False)
sp = sub.add_parser("place", help="Place a new board")
sp.add_argument("stock", help="Nominal stock, e.g. 2x4, or plywood like ply-3/4")
sp.add_argument("length", help="Length, e.g. '6 ft' or '72'")
sp.add_argument("--width", help="Panel width (required for plywood), e.g. '24 in'")
sp.add_argument("--unit", default="inch", help="Default unit for bare numbers (inch|foot)")
sp.set_defaults(func=cmd_place)
sp = sub.add_parser("join", help="Join one board to another")
sp.add_argument("part_b", help="Board being attached, e.g. p2")
sp.add_argument("--to", dest="part_a", default=None, help="Board to attach to (default: selection)")
sp.add_argument("--angle", type=float, default=90.0, help="Angle in degrees")
sp.add_argument("--offset", default=None, help="Distance from anchor, e.g. '10 in'")
sp.add_argument("--anchor", default="end_b",
help="Measure offset from start (end_a/start) or far end (end_b/end)")
sp.add_argument("--unit", default="inch")
sp.set_defaults(func=cmd_join)
sp = sub.add_parser("sand", help="Sand a board")
sp.add_argument("part", nargs="?", default=None, help="Board id (default: selection)")
sp.set_defaults(func=cmd_sand)
sp = sub.add_parser("delete", help="Delete a board")
sp.add_argument("part", nargs="?", default=None)
sp.set_defaults(func=cmd_delete)
sp = sub.add_parser("stand", help="Stand a board up (vertical), e.g. a leg")
sp.add_argument("part", nargs="?", default=None)
sp.add_argument("--tilt", type=float, default=90.0, help="Tilt degrees (90 = straight up)")
sp.set_defaults(func=cmd_stand)
sp = sub.add_parser("lay", help="Lay a board flat (horizontal)")
sp.add_argument("part", nargs="?", default=None)
sp.set_defaults(func=cmd_lay)
sp = sub.add_parser("rotate", help="Set a board's orientation angles")
sp.add_argument("part", nargs="?", default=None)
sp.add_argument("--yaw", type=float, default=None, help="Heading in the XY plane")
sp.add_argument("--tilt", type=float, default=None, help="Elevation toward vertical")
sp.add_argument("--roll", type=float, default=None, help="Rotation about the board's axis")
sp.set_defaults(func=cmd_rotate)
sp = sub.add_parser("move", help="Move a board by an offset (or set its position)")
sp.add_argument("part", nargs="?", default=None)
sp.add_argument("--dx", default=None, help="e.g. '5 in', '-2 ft'")
sp.add_argument("--dy", default=None)
sp.add_argument("--dz", default=None)
sp.add_argument("--absolute", action="store_true", help="Treat dx/dy/dz as absolute position")
sp.add_argument("--unit", default="inch")
sp.set_defaults(func=cmd_move)
sp = sub.add_parser("trim", help="Cut a board to a new length")
sp.add_argument("length", help="New length, e.g. '4 ft'")
sp.add_argument("--part", default=None)
sp.add_argument("--unit", default="inch")
sp.set_defaults(func=cmd_trim)
sp = sub.add_parser("copy", help="Duplicate a board, offset by dx/dy/dz")
sp.add_argument("part", nargs="?", default=None)
sp.add_argument("--dx", default=None)
sp.add_argument("--dy", default=None)
sp.add_argument("--dz", default=None)
sp.add_argument("--unit", default="inch")
sp.set_defaults(func=cmd_copy)
sp = sub.add_parser("rename", help="Give a board a human-friendly name")
sp.add_argument("name", help="e.g. 'front-left leg'")
sp.add_argument("--part", default=None)
sp.set_defaults(func=cmd_rename)
def add_dim_flags(parser, face_default="end_b"):
parser.add_argument("--face", default=face_default,
help="end_a|end_b|top|bottom|left|right")
parser.add_argument("--along", help="position along the board / 1st offset")
parser.add_argument("--across", help="offset across the face / 2nd offset")
parser.add_argument("--width", help="feature width")
parser.add_argument("--height", help="feature height/thickness")
parser.add_argument("--depth", help="cut depth / tenon protrusion")
parser.add_argument("--diameter", help="hole diameter")
parser.add_argument("--rotation", help="rotate the feature about its face normal (deg)")
sp = sub.add_parser("feature", help="Add a joinery feature (tenon/mortise/hole/slot)")
sp.add_argument("kind", help="tenon | mortise | hole | slot | dado | rabbet")
sp.add_argument("--part", default=None, help="Board id/name (default: selection)")
add_dim_flags(sp)
sp.set_defaults(func=cmd_feature)
sp = sub.add_parser("feature-edit", help="Adjust an existing feature")
sp.add_argument("fid", help="Feature id, e.g. f1")
add_dim_flags(sp, face_default=None)
sp.set_defaults(func=cmd_feature_edit)
sp = sub.add_parser("feature-delete", help="Remove a feature")
sp.add_argument("fid")
sp.set_defaults(func=cmd_feature_delete)
sp = sub.add_parser("features", help="List joinery features")
sp.add_argument("--part", default=None)
sp.set_defaults(func=cmd_feature_list)
sp = sub.add_parser("connect", help="Move a board so its feature seats into another")
sp.add_argument("anchor", help="Anchor feature id (stays put)")
sp.add_argument("moving", help="Feature id whose board moves to mate")
sp.set_defaults(func=cmd_connect)
sub.add_parser("connections", help="List connections / assemblies").set_defaults(func=cmd_connections)
sp = sub.add_parser("disconnect", help="Break a connection (pieces stay in place)")
sp.add_argument("connection", help="Connection id, e.g. c1")
sp.set_defaults(func=cmd_disconnect)
sp = sub.add_parser("explode", help="Back connections off along their joint axes")
sp.add_argument("distance", help="Distance, e.g. '3 in'")
sp.set_defaults(func=cmd_explode)
sub.add_parser("assemble", help="Re-fit all connections (seat the joints)").set_defaults(func=cmd_assemble)
sp = sub.add_parser("save", help="Save the current scene as a named project")
sp.add_argument("name", help="Project name, e.g. 'coffee table'")
sp.set_defaults(func=cmd_save)
sp = sub.add_parser("open", help="Open a saved project")
sp.add_argument("name", help="Project name to open")
sp.set_defaults(func=cmd_open)
sub.add_parser("projects", help="List saved projects").set_defaults(func=cmd_projects)
sp = sub.add_parser("export", help="Export the scene to STL or STEP")
sp.add_argument("path", help="Output file, e.g. table.stl or table.step")
sp.set_defaults(func=cmd_export)
sp = sub.add_parser("render", help="Save a PNG image of the scene (works headless)")
sp.add_argument("path", help="Output image, e.g. table.png")
sp.set_defaults(func=cmd_render)
sub.add_parser("cutlist", help="Show the cut list / bill of materials").set_defaults(func=cmd_cutlist)
sp = sub.add_parser("select", help="Set the current selection")
sp.add_argument("part", help="Board id or name to select")
sp.set_defaults(func=cmd_select)
sub.add_parser("undo", help="Undo the last operation").set_defaults(func=cmd_undo)
sub.add_parser("redo", help="Redo the last undone operation").set_defaults(func=cmd_redo)
sub.add_parser("clear", help="Clear the scene").set_defaults(func=cmd_clear)
sub.add_parser("status", help="Show the scene").set_defaults(func=cmd_status)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if not args.command: # bare `woodshop` -> launch the GUI studio
from .gui.app import main as gui_main # lazy: keep Qt out of CLI use
return gui_main(["--scene", args.scene] if args.scene else [])
scene = Scene.load(args.scene)
try:
message = args.func(scene, args)
except (SceneError, ValueError, KeyError) as exc:
print(str(exc).strip('"'), file=sys.stderr)
return 1
if args.command not in ("status", "export", "cutlist", "render", "save",
"projects", "features", "connections"):
scene.save(args.scene)
print(message)
return 0
if __name__ == "__main__":
raise SystemExit(main())

102
src/woodshop/cutlist.py Normal file
View File

@ -0,0 +1,102 @@
"""Cut list, board-feet, and a stock shopping estimate.
This is the workshop-assistant payoff: turn the model into something you can
actually build from. Board-feet use NOMINAL dimensions (the lumber-industry
convention) parsed from the stock name; the shopping estimate assumes standard
8-foot sticks.
"""
from __future__ import annotations
import math
from collections import defaultdict
from .scene import Scene
STICK_LENGTH_IN = 96.0 # a standard 8' stick
def nominal_dims(stock: str) -> tuple[float, float]:
"""'2x4' -> (2.0, 4.0). Falls back to (1, 1) for odd names."""
try:
t, w = stock.lower().split("x")[:2]
return float(t), float(w)
except (ValueError, IndexError):
return 1.0, 1.0
def board_feet(stock: str, length_in: float) -> float:
t, w = nominal_dims(stock)
return t * w * length_in / 144.0
def cut_length(part) -> float:
"""The length to cut the board to, including any tenon that protrudes past an
end. Subtractive features (mortise/hole/slot/chamfer) don't change the stock
length you buy, so they're ignored here."""
extra = sum(f.depth_in for f in part.features
if f.kind == "tenon" and f.face in ("end_a", "end_b"))
return part.length_in + extra
def _fmt_len(inches: float) -> str:
feet, rem = divmod(round(inches, 2), 12)
if feet and rem:
return f"{int(feet)}' {rem:g}\""
if feet:
return f"{int(feet)}'"
return f'{rem:g}"'
def cut_rows(scene: Scene) -> list[dict]:
"""One row per distinct (stock, length, width), with a count. Lumber rows
carry board_feet; plywood rows carry sq_ft (it's a cut panel)."""
from .lumber import is_plywood
groups: dict[tuple, int] = defaultdict(int)
for p in scene.parts:
groups[(p.stock, round(cut_length(p), 2), round(p.section_in[1], 2))] += 1
rows = []
for (stock, length, width), count in sorted(groups.items()):
row = {"stock": stock, "length_in": length, "width_in": width,
"count": count, "plywood": is_plywood(stock)}
if row["plywood"]:
row["sq_ft"] = (length * width / 144.0) * count
else:
row["board_feet"] = board_feet(stock, length) * count
rows.append(row)
return rows
def shopping(scene: Scene) -> dict[str, int]:
"""How many to buy per stock: lumber in 8' sticks, plywood in 4×8 sheets,
from the actual cutting-stock nesting (kerf-aware)."""
from .layout import stock_counts
return dict(sorted(stock_counts(scene).items()))
def format_cutlist(scene: Scene) -> str:
if not scene.parts:
return "Nothing to cut yet — the scene is empty."
rows = cut_rows(scene)
lines = ["CUT LIST"]
for r in rows:
if r["plywood"]:
lines.append(f" {r['count']:>2} × {r['stock']:<7} {_fmt_len(r['width_in'])} × "
f"{_fmt_len(r['length_in'])} ({r['sq_ft']:.1f} sq ft)")
else:
lines.append(f" {r['count']:>2} × {r['stock']:<7} @ {_fmt_len(r['length_in']):<8}"
f" ({r['board_feet']:.1f} bd-ft)")
total_bf = sum(r.get("board_feet", 0) for r in rows)
total_sf = sum(r.get("sq_ft", 0) for r in rows)
tot = f"{len(scene.parts)} board(s)"
if total_bf:
tot += f", {total_bf:.1f} board-feet"
if total_sf:
tot += f", {total_sf:.1f} sq ft plywood"
lines.append(" Total: " + tot)
if any(cut_length(p) > p.length_in for p in scene.parts):
lines.append(" (cut lengths include protruding tenons)")
lines.append("SHOPPING (8' sticks / 4×8 sheets, +10% waste)")
for stock, qty in shopping(scene).items():
unit = "sheet(s)" if stock.startswith("ply-") else "stick(s)"
lines.append(f" {qty} × {stock} {unit}")
return "\n".join(lines)

658
src/woodshop/cutplan.py Normal file
View File

@ -0,0 +1,658 @@
"""CutPlan: the deterministic shop-output artifact everything else builds on.
A CutPlan packs the scene's required pieces (CutItems) onto physical stock
(StockPieces) at explicit positions (Placements), with kerf, waste, warnings,
and a detailed score. It is plain-dataclass, JSON-serializable, and uses stable
ids (never list position) so manual edits, alternate strategies, instructions,
and jig references can all point at the same objects.
The math here is deterministic and inspectable; AI is never used for numbers.
"""
from __future__ import annotations
import hashlib
from dataclasses import asdict, dataclass, field, fields
from .cutlist import cut_length
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
_EPS = 1e-6
def _stable_hash(text: str) -> int:
"""Process-stable hash (unlike built-in hash(), which is salted per run)."""
return int(hashlib.md5(text.encode()).hexdigest()[:8], 16)
@dataclass
class ShopSettings:
kerf_in: float = 0.125
stick_len_in: float = 96.0 # an 8' stick
sheet_w_in: float = 48.0
sheet_l_in: float = 96.0
offcut_usable_in: float = 12.0 # lumber offcut ≥ this counts as reusable
offcut_usable_sqft: float = 1.0 # plywood reusable threshold
allow_plywood_rotation: bool = True
grain_direction: bool = False # honor grain (future; disables rotation)
# tolerances — defaults present from day one even before they're in the UI
mortise_tenon_clearance_in: float = 1 / 32
sanding_allowance_in: float = 0.0
reveal_in: float = 0.0
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, d: dict | None) -> "ShopSettings":
valid = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in (d or {}).items() if k in valid})
@dataclass
class CutItem:
id: str
part_id: str
stock: str
length_in: float
width_in: float
is_sheet: bool
note: str = "" # e.g. "incl. tenon"
@dataclass
class Placement:
id: str
item_id: str
x_in: float # along the stock length
y_in: float = 0.0 # across the stock width (plywood)
len_in: float = 0.0 # placed footprint along length
wid_in: float = 0.0 # placed footprint across width
rotated: bool = False
locked: bool = False
@dataclass
class WasteRegion:
x_in: float
length_in: float
width_in: float = 0.0 # 0 -> full section width (lumber offcut)
reusable: bool = False
@dataclass
class StockPiece:
id: str
stock: str
is_sheet: bool
length_in: float
width_in: float
placements: list = field(default_factory=list) # Placement
waste: list = field(default_factory=list) # WasteRegion
@dataclass
class CutPlan:
settings: ShopSettings
items: list = field(default_factory=list) # CutItem
stock_pieces: list = field(default_factory=list) # StockPiece
unplaced: list = field(default_factory=list) # CutItem ids that didn't fit
strategy: str = "decreasing"
score: dict = field(default_factory=dict)
warnings: list = field(default_factory=list)
def item(self, item_id: str) -> CutItem:
return next(i for i in self.items if i.id == item_id)
# ----- serialization (JSON-friendly) -------------------------------
def to_dict(self) -> dict:
return {
"settings": self.settings.to_dict(),
"items": [asdict(i) for i in self.items],
"stock_pieces": [asdict(sp) for sp in self.stock_pieces],
"unplaced": list(self.unplaced),
"strategy": self.strategy,
"score": self.score,
"warnings": list(self.warnings),
}
@classmethod
def from_dict(cls, d: dict) -> "CutPlan":
def sp_from(s):
return StockPiece(
id=s["id"], stock=s["stock"], is_sheet=s["is_sheet"],
length_in=s["length_in"], width_in=s["width_in"],
placements=[Placement(**p) for p in s.get("placements", [])],
waste=[WasteRegion(**w) for w in s.get("waste", [])])
return cls(
settings=ShopSettings.from_dict(d.get("settings")),
items=[CutItem(**i) for i in d.get("items", [])],
stock_pieces=[sp_from(s) for s in d.get("stock_pieces", [])],
unplaced=list(d.get("unplaced", [])),
strategy=d.get("strategy", "decreasing"),
score=d.get("score", {}),
warnings=list(d.get("warnings", [])))
# --------------------------------------------------------------------------
def _cut_items(scene) -> list:
items = []
for n, p in enumerate(scene.parts, 1):
ln = cut_length(p)
items.append(CutItem(
id=f"ci{n}", part_id=p.id, stock=p.stock,
length_in=round(ln, 3), width_in=round(p.section_in[1], 3),
is_sheet=is_plywood(p.stock),
note="incl. tenon" if ln > p.length_in + _EPS else ""))
return items
def _ordered(items, strategy):
key = lambda it: max(it.length_in, it.width_in)
if strategy == "increasing":
return sorted(items, key=key)
if strategy.startswith("shuffle"): # "shuffle", "shuffle1", ... distinct salts
salt = strategy[7:]
return sorted(items, key=lambda it: _stable_hash(it.id + salt))
return sorted(items, key=key, reverse=True) # decreasing (FFD) & bestfit (BFD)
def _lumber_avail(sp, s):
end = max((p.x_in + p.len_in for p in sp.placements), default=0.0)
cursor = end + (s.kerf_in if sp.placements else 0.0)
return sp.length_in - cursor, cursor # (room left, x where the next piece starts)
def _pack_lumber(items, stock, s: ShopSettings, ids, fit="first") -> tuple[list, list]:
"""Pack lengths into sticks. fit='first' (FFD) or 'best' (BFD = tightest fit)."""
sticks, unplaced = [], []
for it in items:
if it.length_in > s.stick_len_in + _EPS:
unplaced.append(it.id)
continue
candidates = [(sp,) + _lumber_avail(sp, s) for sp in sticks]
candidates = [(sp, room, x) for sp, room, x in candidates if it.length_in <= room + _EPS]
if candidates:
sp, _room, x = (min(candidates, key=lambda c: c[1]) if fit == "best"
else candidates[0])
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=x,
len_in=it.length_in, wid_in=it.width_in))
else:
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=False,
length_in=s.stick_len_in, width_in=it.width_in)
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0,
len_in=it.length_in, wid_in=it.width_in))
sticks.append(sp)
for sp in sticks: # offcut at the end of each stick
end = max((p.x_in + p.len_in for p in sp.placements), default=0.0)
off = round(sp.length_in - end, 3)
if off > 0.5:
sp.waste.append(WasteRegion(x_in=end, length_in=off, width_in=sp.width_in,
reusable=off >= s.offcut_usable_in))
return sticks, unplaced
def _pack_plywood(items, stock, s: ShopSettings, ids) -> tuple[list, list]:
"""Shelf packing onto sheets (no rotation yet — Phase 1 adds it)."""
sheets, rest = [], list(items)
unplaced = []
def orientations(it):
# (len_along_sheet, width_across, rotated). Rotation allowed unless grain is honored.
opts = [(it.length_in, it.width_in, False)]
if s.allow_plywood_rotation and not s.grain_direction and it.width_in != it.length_in:
opts.append((it.width_in, it.length_in, True))
return opts
def pack_one(panels):
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=True,
length_in=s.sheet_l_in, width_in=s.sheet_w_in)
shelves, leftover = [], [] # shelves: [y, height, x_cursor]
for it in panels:
done = False
for pl, pw, rot in orientations(it):
for sh in shelves: # fit into an existing shelf
x = sh[2] + (s.kerf_in if sh[2] else 0.0)
if pw <= sh[1] + _EPS and x + pl <= s.sheet_l_in + _EPS:
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=x, y_in=sh[0],
len_in=pl, wid_in=pw, rotated=rot))
sh[2] = x + pl
done = True
break
if done:
break
if not done:
for pl, pw, rot in orientations(it): # start a new shelf
y = (shelves[-1][0] + shelves[-1][1] + s.kerf_in) if shelves else 0.0
if y + pw <= s.sheet_w_in + _EPS and pl <= s.sheet_l_in + _EPS:
shelves.append([y, pw, pl])
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0, y_in=y,
len_in=pl, wid_in=pw, rotated=rot))
done = True
break
if not done:
leftover.append(it)
return sp, leftover
while rest:
sp, rest = pack_one(rest)
if not sp.placements: # a single panel bigger than a sheet
big = rest.pop(0)
unplaced.append(big.id)
continue
sheets.append(sp)
return sheets, unplaced
def _min_bins(order_sizes, cap, kerf):
"""Branch-and-bound minimum number of bins (sticks) for small lumber jobs.
order_sizes = [(idx, length)]; returns list of bins (each a list of idx)."""
order = sorted(order_sizes, key=lambda t: -t[1])
ffd = [] # first-fit-decreasing seed / bound
for idx, size in order:
for b in ffd:
if b[0] + kerf + size <= cap + _EPS:
b[0] += kerf + size
b[1].append(idx)
break
else:
ffd.append([size, [idx]])
best = [[list(b[1]) for b in ffd]]
best_count = [len(ffd)]
bins = []
def dfs(k):
if len(bins) >= best_count[0]:
return
if k == len(order):
best_count[0] = len(bins)
best[0] = [list(b[1]) for b in bins]
return
idx, size = order[k]
for b in bins:
if b[0] + kerf + size <= cap + _EPS:
b[0] += kerf + size
b[1].append(idx)
dfs(k + 1)
b[1].pop()
b[0] -= kerf + size
bins.append([size, [idx]])
dfs(k + 1)
bins.pop()
dfs(0)
return best[0]
def _pack_lumber_exact(items, stock, s, ids) -> tuple[list, list]:
"""Provably-minimum stick count for small jobs (≤12 pieces); else best-fit."""
if len(items) > 12:
return _pack_lumber(items, stock, s, ids, fit="best")
oversize = [it.id for it in items if it.length_in > s.stick_len_in + _EPS]
packable = [(i, it.length_in) for i, it in enumerate(items)
if it.length_in <= s.stick_len_in + _EPS]
sticks = []
for bin_idx in _min_bins(packable, s.stick_len_in, s.kerf_in):
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=False,
length_in=s.stick_len_in, width_in=items[bin_idx[0]].width_in)
x = 0.0
for idx in bin_idx:
it = items[idx]
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=round(x, 3),
len_in=it.length_in, wid_in=it.width_in))
x += it.length_in + s.kerf_in
end = x - s.kerf_in
off = round(s.stick_len_in - end, 3)
if off > 0.5:
sp.waste.append(WasteRegion(x_in=round(end, 3), length_in=off, width_in=sp.width_in,
reusable=off >= s.offcut_usable_in))
sticks.append(sp)
return sticks, oversize
def _pack_plywood_guillotine(items, stock, s, ids) -> tuple[list, list]:
"""Guillotine free-rectangle packing (best-area-fit + rotation) — usually
tighter than shelf packing for mixed panel sizes."""
sheets, unplaced, rects = [], [], {}
def orientations(it):
opts = [(it.length_in, it.width_in, False)]
if s.allow_plywood_rotation and not s.grain_direction and it.length_in != it.width_in:
opts.append((it.width_in, it.length_in, True))
return opts
def new_sheet():
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=True,
length_in=s.sheet_l_in, width_in=s.sheet_w_in)
sheets.append(sp)
rects[sp.id] = [[0.0, 0.0, s.sheet_l_in, s.sheet_w_in]]
return sp
for it in sorted(items, key=lambda i: -(i.length_in * i.width_in)):
best = None
for sp in sheets:
for ri, (rx, ry, rw, rh) in enumerate(rects[sp.id]):
for pl, pw, rot in orientations(it):
if pl <= rw + _EPS and pw <= rh + _EPS:
leftover = rw * rh - pl * pw
if best is None or leftover < best[0]:
best = (leftover, sp, ri, pl, pw, rot, rx, ry, rw, rh)
if best is None:
sp = new_sheet()
rx, ry, rw, rh = rects[sp.id][0]
for pl, pw, rot in orientations(it):
if pl <= rw + _EPS and pw <= rh + _EPS:
best = (0, sp, 0, pl, pw, rot, rx, ry, rw, rh)
break
if best is None: # bigger than a whole sheet
unplaced.append(it.id)
sheets.pop()
del rects[sp.id]
continue
_lo, sp, ri, pl, pw, rot, rx, ry, rw, rh = best
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=round(rx, 3), y_in=round(ry, 3),
len_in=pl, wid_in=pw, rotated=rot))
del rects[sp.id][ri]
k = s.kerf_in
for r in ([rx + pl + k, ry, rw - pl - k, pw], [rx, ry + pw + k, rw, rh - pw - k]):
if r[2] > 0.5 and r[3] > 0.5:
rects[sp.id].append(r)
return sheets, unplaced
def build_cut_plan(scene, settings: ShopSettings | None = None,
strategy: str = "decreasing") -> CutPlan:
s = settings or ShopSettings()
items = _cut_items(scene)
by_id = {it.id: it for it in items}
counter = {"n": 0}
def ids(prefix="pl"):
counter["n"] += 1
return f"{prefix}{counter['n']}"
fit = "best" if strategy == "bestfit" else "first"
by_stock: dict[str, list] = {}
for it in _ordered(items, strategy):
by_stock.setdefault(it.stock, []).append(it)
stock_pieces, unplaced, warnings = [], [], []
for stock, its in by_stock.items():
if its[0].is_sheet:
sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine"
else _pack_plywood(its, stock, s, ids))
elif strategy == "exact":
sps, un = _pack_lumber_exact(its, stock, s, ids)
else:
sps, un = _pack_lumber(its, stock, s, ids, fit=fit)
stock_pieces += sps
unplaced += un
for item_id in unplaced:
it = by_id[item_id]
warnings.append(f"{it.part_id} ({it.stock}) doesn't fit standard stock — too big.")
score = _score(stock_pieces, s, strategy, warnings)
if score["yield_pct"] < 50 and stock_pieces:
warnings.append(f"Low yield: only {score['yield_pct']:.0f}% of bought stock is used.")
return CutPlan(settings=s, items=items, stock_pieces=stock_pieces,
unplaced=unplaced, strategy=strategy, score=score, warnings=warnings)
def _score(stock_pieces, s, strategy, warnings) -> dict:
waste_area = used_area = bought_area = 0.0
reusable = 0
for sp in stock_pieces:
used = sum(p.len_in * p.wid_in for p in sp.placements)
used_area += used
if sp.is_sheet:
bought_area += sp.length_in * sp.width_in
waste_area += sp.length_in * sp.width_in - used
else:
bought_area += sp.length_in * sp.width_in
for w in sp.waste:
waste_area += w.length_in * (w.width_in or sp.width_in)
if w.reusable:
reusable += 1
reusable_in = sum(w.length_in for sp in stock_pieces if not sp.is_sheet
for w in sp.waste if w.reusable)
return {
"strategy_name": strategy,
"stock_count": len(stock_pieces),
"waste_area": round(waste_area, 1),
"reusable_offcuts": reusable,
"reusable_in": round(reusable_in, 1),
"yield_pct": round(used_area / bought_area * 100, 1) if bought_area else 0.0,
"warnings": list(warnings),
}
def _free_segments(sp: StockPiece, kerf: float) -> list:
"""Usable free intervals [start, length] on a lumber stick, leaving a kerf
margin beside each occupied placement."""
occ = sorted((p.x_in, p.x_in + p.len_in) for p in sp.placements)
segs, cursor = [], 0.0
for a, b in occ:
end = a - kerf
if end - cursor > 0.5:
segs.append([cursor, end - cursor])
cursor = b + kerf
if sp.length_in - cursor > 0.5:
segs.append([cursor, sp.length_in - cursor])
return segs
def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPlan:
"""Re-pack while PRESERVING locked placements where they sit. Unlocked lumber
is packed into the free space around locked pieces (then new sticks); unlocked
plywood goes onto fresh sheets (locked sheets keep their locked panels)."""
s = base_plan.settings
items = _cut_items(scene)
locked = [p for sp in base_plan.stock_pieces for p in sp.placements if p.locked]
locked_ids = {p.item_id for p in locked}
counter = {"n": 0}
def ids(prefix="rpl"):
counter["n"] += 1
return f"{prefix}{counter['n']}"
# Seed stock pieces from those holding locked placements (keep only locked).
seeds: dict[str, dict] = {}
for sp in base_plan.stock_pieces:
kept = [p for p in sp.placements if p.locked]
if not kept:
continue
seeds.setdefault(sp.stock, {})[sp.id] = StockPiece(
id=sp.id, stock=sp.stock, is_sheet=sp.is_sheet,
length_in=sp.length_in, width_in=sp.width_in,
placements=[Placement(id=p.id, item_id=p.item_id, x_in=p.x_in, y_in=p.y_in,
len_in=p.len_in, wid_in=p.wid_in, rotated=p.rotated, locked=True)
for p in kept])
unlocked = [it for it in _ordered(items, strategy) if it.id not in locked_ids]
by_stock: dict[str, list] = {}
for it in unlocked:
by_stock.setdefault(it.stock, []).append(it)
stock_pieces, unplaced, warnings = [], [], []
for stock in set(by_stock) | set(seeds):
its = by_stock.get(stock, [])
seed_pieces = list(seeds.get(stock, {}).values())
is_sheet = (its and its[0].is_sheet) or (seed_pieces and seed_pieces[0].is_sheet)
if is_sheet:
new_sheets, un = _pack_plywood(its, stock, s, ids) if its else ([], [])
stock_pieces += seed_pieces + new_sheets
else:
sps, un = _pack_lumber_seeded(its, stock, s, ids, seed_pieces)
stock_pieces += sps
unplaced += un
for iid in unplaced:
it = next(i for i in items if i.id == iid)
warnings.append(f"{it.part_id} ({it.stock}) doesn't fit standard stock — too big.")
plan = CutPlan(settings=s, items=items, stock_pieces=stock_pieces, unplaced=unplaced,
strategy=strategy + "+locked",
score=_score(stock_pieces, s, strategy + "+locked", warnings), warnings=warnings)
recompute(plan)
return plan
def _pack_lumber_seeded(items, stock, s, ids, seeds) -> tuple[list, list]:
"""Place items into the free segments of seeded sticks first, then new sticks."""
sticks = list(seeds)
free = {sp.id: _free_segments(sp, s.kerf_in) for sp in sticks}
unplaced = []
for it in items:
if it.length_in > s.stick_len_in + _EPS:
unplaced.append(it.id)
continue
cands = [(sp, seg) for sp in sticks for seg in free[sp.id] if it.length_in <= seg[1] + _EPS]
if cands:
sp, seg = min(cands, key=lambda c: c[1][1]) # tightest free segment
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=round(seg[0], 3),
len_in=it.length_in, wid_in=it.width_in))
used = it.length_in + s.kerf_in
seg[0] += used
seg[1] -= used
else:
sp = StockPiece(id=ids("rsp"), stock=stock, is_sheet=False,
length_in=s.stick_len_in, width_in=it.width_in)
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0,
len_in=it.length_in, wid_in=it.width_in))
sticks.append(sp)
free[sp.id] = [[it.length_in + s.kerf_in, s.stick_len_in - it.length_in - s.kerf_in]]
return sticks, unplaced
def _plan_key(plan: CutPlan):
"""Lower is better: fewest stock pieces, least waste, then prefer more & longer
reusable offcuts."""
sc = plan.score
return (sc["stock_count"], sc["waste_area"], -sc["reusable_offcuts"], -sc.get("reusable_in", 0))
# Strategies the "Try alternative" button cycles through.
STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shuffle"]
def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24) -> CutPlan:
"""Find a better layout by trying several strategies + shuffle restarts and
keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
best = None
for st in strategies:
plan = build_cut_plan(scene, settings, strategy=st)
if best is None or _plan_key(plan) < _plan_key(best):
best = plan
if best is not None:
best.strategy = "optimized"
best.score["strategy_name"] = "optimized"
return best
# --- manual editing helpers (deterministic; the drag UI builds on these) -----
def find_placement(plan: CutPlan, pid: str):
for sp in plan.stock_pieces:
for p in sp.placements:
if p.id == pid:
return sp, p
raise KeyError(pid)
def _too_close(a: Placement, b: Placement, kerf: float) -> bool:
"""True if a and b are closer than a saw kerf in BOTH axes (so a cut can't
separate them) i.e. they overlap or leave less than kerf between them."""
x_ov = min(a.x_in + a.len_in, b.x_in + b.len_in) - max(a.x_in, b.x_in)
y_ov = min(a.y_in + a.wid_in, b.y_in + b.wid_in) - max(a.y_in, b.y_in)
return x_ov > -kerf + _EPS and y_ov > -kerf + _EPS
def placement_fits(sp: StockPiece, placement: Placement, kerf: float) -> bool:
"""Is `placement` inside `sp` and kerf-clear of its other placements?"""
if placement.x_in < -_EPS or placement.x_in + placement.len_in > sp.length_in + _EPS:
return False
if placement.y_in < -_EPS or placement.y_in + placement.wid_in > sp.width_in + _EPS:
return False
return not any(_too_close(placement, q, kerf) for q in sp.placements if q.id != placement.id)
def snap_x(sp: StockPiece, placement: Placement, x: float, kerf: float, tol: float = 2.0) -> float:
"""Snap an x position to stock edges / neighbour edges (+kerf), within `tol`."""
cands = [0.0, sp.length_in - placement.len_in]
for q in sp.placements:
if q.id == placement.id:
continue
cands.append(q.x_in + q.len_in + kerf) # butt to the right of q (+kerf)
cands.append(q.x_in - placement.len_in - kerf) # butt to the left of q
best = min(cands, key=lambda c: abs(c - x))
return best if abs(best - x) <= tol else x
def relocate(plan: CutPlan, pid: str, target_sp_id: str, x_in: float, y_in: float = 0.0) -> None:
"""Move a placement to a stock piece at (x,y). Does not validate (caller checks)."""
sp, p = find_placement(plan, pid)
target = next(s for s in plan.stock_pieces if s.id == target_sp_id)
if target is not sp:
sp.placements.remove(p)
target.placements.append(p)
p.x_in, p.y_in = x_in, y_in
def rotate_placement(plan: CutPlan, pid: str) -> None:
"""Swap a placement's footprint (plywood rotation)."""
_sp, p = find_placement(plan, pid)
p.len_in, p.wid_in = p.wid_in, p.len_in
p.rotated = not p.rotated
def recompute(plan: CutPlan) -> None:
"""Rebuild waste regions (incl. gaps left by manual moves) and the score —
call after any manual edit so the diagram and yield stay truthful."""
s = plan.settings
for sp in plan.stock_pieces:
sp.waste = []
if sp.is_sheet:
continue
cursor = 0.0
for p in sorted(sp.placements, key=lambda p: p.x_in):
gap = round(p.x_in - cursor, 3)
if gap > 0.5:
sp.waste.append(WasteRegion(x_in=round(cursor, 3), length_in=gap,
width_in=sp.width_in, reusable=gap >= s.offcut_usable_in))
cursor = max(cursor, p.x_in + p.len_in)
tail = round(sp.length_in - cursor, 3)
if tail > 0.5:
sp.waste.append(WasteRegion(x_in=round(cursor, 3), length_in=tail,
width_in=sp.width_in, reusable=tail >= s.offcut_usable_in))
plan.score = _score(plan.stock_pieces, s, plan.strategy, plan.warnings)
def validate_cut_plan(plan: CutPlan) -> list:
"""Return a list of problems ([] means valid): pieces inside stock, no
overlaps, kerf respected, every item placed-or-warned."""
problems = []
s = plan.settings
items = {it.id: it for it in plan.items}
rot_ok = s.allow_plywood_rotation and not s.grain_direction
placed_items = set()
for sp in plan.stock_pieces:
for p in sp.placements:
placed_items.add(p.item_id)
if p.x_in < -_EPS or p.x_in + p.len_in > sp.length_in + _EPS:
problems.append(f"{p.id} runs off {sp.id} lengthwise")
if p.y_in < -_EPS or p.y_in + p.wid_in > sp.width_in + _EPS:
problems.append(f"{p.id} runs off {sp.id} widthwise")
it = items.get(p.item_id)
if it and it.stock != sp.stock:
problems.append(f"{p.id} ({it.stock}) is on a {sp.stock} stock piece")
if p.rotated and not rot_ok:
problems.append(f"{p.id} is rotated but rotation isn't allowed")
ps = sp.placements
for i in range(len(ps)):
for j in range(i + 1, len(ps)):
if _too_close(ps[i], ps[j], s.kerf_in):
problems.append(f"{ps[i].id} and {ps[j].id} are closer than a kerf on {sp.id}")
for it in plan.items:
if it.id not in placed_items and it.id not in plan.unplaced:
problems.append(f"{it.part_id} ({it.id}) is neither placed nor flagged unplaced")
return problems

273
src/woodshop/driver.py Normal file
View File

@ -0,0 +1,273 @@
"""The conversational driver: speak (or type) a command, watch it build.
Reuses existing CmdForge tools for everything that isn't woodshop-specific:
* `dictate` -> speech to text (with --voice)
* `pa-load-tools` -> turns the wood-* tools into Claude function schemas
* `claude -p` -> interprets the utterance into tool calls
* `pa-execute-tool`-> dispatches each wood-* tool
* `read-aloud` -> speaks the confirmation back
Only the orchestration here is woodshop-specific (it must be: we use Claude
rather than pa-tool-loop's hard-wired local model). Run the viewer alongside it:
woodshop-view & # 3D window
woodshop-talk # type commands; add --voice to speak them
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
TOOL_FILTER = "wood-*" # auto-discover every wood-* tool, no hardcoded list
REASON_PROVIDER = "claude -p" # chosen for reliable structured tool-calling
# A board placed earlier in the SAME utterance is referenced as $1, $2, ...
_SYMBOL = re.compile(r"\$(\d+)")
def _run(cmd: list[str], stdin: str = "") -> str:
proc = subprocess.run(cmd, input=stdin, capture_output=True, text=True)
return (proc.stdout or "").strip()
def load_schemas() -> str:
return _run(["pa-load-tools", "--filter", TOOL_FILTER, "--format", "anthropic"])
def scene_summary() -> str:
ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")
return _run([ws, "status"]) or "empty"
SYSTEM = """You are WoodShop, a voice-driven woodworking assistant. Translate the \
user's spoken command into a JSON array of tool calls that build/modify a 3D model \
of furniture from dimensional lumber.
Tools (JSON schemas):
{schemas}
Current scene:
{scene}
Rules:
- Respond with ONLY a JSON array. No prose, no markdown fences.
- Each element is {{"tool": "<name>", "args": {{...}}}}.
- Refer to boards that ALREADY exist by their real id (p1, p2, ...) or their name.
- For a board you place earlier in THIS response, refer to it later as $1, $2, ...
numbered by the order you place boards in this response (the first wood-place is $1).
- A "Layout" section gives each board's bounding box (in inches). Use it to
reason about where boards are. Boards should TOUCH at faces, never overlap or
leave gaps. To position one board flush against / next to another, compute the
offset from the two bounding boxes and emit wood-move with the relative
dx/dy/dz that makes their faces meet (e.g. move so the moving board's x-max
equals the target board's x-min). Fix any "Interpenetrating" pairs the same way.
- "these" / "them" / "the selected ones" refer to the currently-selected boards
listed under the scene; emit one call per selected board (e.g. wood-move for each).
- Plywood is sheet stock named like 'ply-3/4' (¾" thick), 'ply-1/2', 'ply-1/4'.
When placing plywood you MUST give wood-place a width as well as a length
(e.g. a tabletop or cabinet back). Lumber ignores width.
- Legs and uprights must be stood up: place the board, then wood-stand it.
- For wood-join, "part_b" is the board being attached (it gets moved into place);
"to" is the board it attaches to. Anchor is "end" (far end) or "start".
- Decompose multi-step requests (e.g. "build a table frame") into the full sequence
of place/stand/join/move calls. Use wood-rename to label important parts (legs, rails).
- For questions like "what do I have" or "cut list / how much wood", call
wood-cutlist (or answer with "say").
- If the command is ambiguous or not about woodworking, return a single
{{"tool": "say", "args": {{"text": "<short question or reply>"}}}}.
User said: "{utterance}"
"""
def _extract_calls(raw: str) -> list[dict] | None:
"""Pull a JSON array of calls out of a model response, tolerating code
fences and trailing prose. Tries the whole string, then the FIRST balanced
[...] (not greedy-to-last-bracket, which would swallow trailing text)."""
raw = raw.strip()
if raw.startswith("```"):
raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw)
raw = re.sub(r"\n?```$", "", raw).strip()
candidates = [raw]
start = raw.find("[")
if start != -1:
depth = 0
for i in range(start, len(raw)):
if raw[i] == "[":
depth += 1
elif raw[i] == "]":
depth -= 1
if depth == 0:
candidates.append(raw[start:i + 1])
break
for candidate in candidates:
try:
value = json.loads(candidate)
except json.JSONDecodeError:
continue
if isinstance(value, list):
return value
if isinstance(value, dict):
return [value]
return None
def interpret(utterance: str, schemas: str, scene_text: str | None = None) -> list[dict]:
scene = scene_text if scene_text is not None else scene_summary()
prompt = SYSTEM.format(schemas=schemas, scene=scene, utterance=utterance)
raw = _run(REASON_PROVIDER.split(), stdin=prompt)
calls = _extract_calls(raw)
if calls is None:
return [{"tool": "say", "args": {"text": "Sorry, I couldn't parse that command."}}]
return calls
def _subprocess_executor(tool: str, args: dict) -> str:
"""Default executor: dispatch a wood-* tool via the CmdForge pa-execute-tool."""
result = _run(["pa-execute-tool", "--tool-name", tool,
"--tool-args", json.dumps(args)])
try:
payload = json.loads(result)
except json.JSONDecodeError:
payload = {"success": False, "output": "", "error": result}
return payload.get("output") or payload.get("error") or "(no output)"
def dispatch(calls: list[dict], verbose: bool = True, executor=None) -> list[str]:
"""Execute calls in order, resolving $N to ids of boards placed this turn.
`executor(tool, args) -> message` performs one operation; defaults to the
CmdForge subprocess. The GUI passes an in-process executor that mutates its
live Scene directly while reusing this $N-resolution logic.
"""
executor = executor or _subprocess_executor
placed: list[str] = []
messages: list[str] = []
def resolve(value):
if isinstance(value, str):
def sub(m):
i = int(m.group(1)) - 1
return placed[i] if 0 <= i < len(placed) else m.group(0)
return _SYMBOL.sub(sub, value)
return value
for call in calls:
tool = call.get("tool", "")
args = {k: resolve(v) for k, v in (call.get("args") or {}).items()}
if tool == "say":
messages.append(args.get("text", ""))
continue
out = executor(tool, args)
if tool == "wood-place":
m = re.search(r"\b(p\d+)\b", out) # remember the new id for $N
if m:
placed.append(m.group(1))
messages.append(out)
if verbose:
print(f" {tool}{args} -> {out}")
return messages
def speak(text: str) -> None:
if text.strip():
subprocess.run(["read-aloud", "--strip-md", "true"], input=text, text=True)
# Concise spoken verbs per tool (for a short summary instead of reading every line).
_VERB = {
"wood-place": "placed", "wood-join": "joined", "wood-stand": "stood up",
"wood-lay": "laid flat", "wood-rotate": "rotated", "wood-move": "moved",
"wood-trim": "cut", "wood-copy": "copied", "wood-rename": "named",
"wood-sand": "sanded", "wood-delete": "removed", "wood-undo": "undid",
"wood-clear": "cleared the scene", "wood-save": "saved", "wood-open": "opened",
}
# Tools whose text output IS the answer and should be spoken verbatim.
_QUERY_TOOLS = {"wood-cutlist", "wood-projects"}
def summarize(calls: list[dict], messages: list[str]) -> str:
"""A short, speakable summary. Verbatim for queries/clarifications; otherwise
a verb+count roll-up so building a table doesn't read 12 sentences aloud."""
from collections import Counter
verbatim = [m for c, m in zip(calls, messages)
if c.get("tool") in _QUERY_TOOLS or c.get("tool") == "say"]
if verbatim:
return " ".join(verbatim).strip()
counts = Counter(c.get("tool", "") for c in calls)
chunks = []
for tool, n in counts.items():
verb = _VERB.get(tool)
if not verb:
continue
chunks.append(verb if "scene" in verb or n == 1 else f"{verb} {n}")
return ("Done — " + ", ".join(chunks) + ".") if chunks else "Done."
def handle(utterance: str, schemas: str, voice: bool, verbose: bool) -> None:
calls = interpret(utterance, schemas)
messages = dispatch(calls, verbose=verbose)
full = " ".join(m for m in messages if m).strip()
spoken = summarize(calls, messages)
print(f"WoodShop: {full or spoken}")
if voice:
speak(spoken)
def get_utterance(voice: bool, duration: int) -> str | None:
if voice:
print(f"[listening {duration}s...]")
text = _run(["dictate", "--duration", str(duration)])
print(f"You said: {text!r}")
return text or None
try:
return input("you> ").strip() or None
except (EOFError, KeyboardInterrupt):
return None
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="woodshop-talk", description="Conversational woodworking.")
ap.add_argument("--voice", action="store_true", help="Listen on the mic instead of typing")
ap.add_argument("--duration", type=int, default=6, help="Mic recording seconds (--voice)")
ap.add_argument("--once", help="Run a single command (non-interactive) and exit")
ap.add_argument("--quiet", action="store_true", help="Don't print per-call detail")
args = ap.parse_args(argv)
schemas = load_schemas()
if not schemas:
print("Could not load wood-* tool schemas (is CmdForge/pa-load-tools available?)",
file=sys.stderr)
return 1
if args.once is not None:
handle(args.once, schemas, voice=args.voice, verbose=not args.quiet)
return 0
print("WoodShop ready. Say things like 'place a 6 foot 2x4'. Ctrl-C to quit.")
while True:
utterance = get_utterance(args.voice, args.duration)
if utterance is None:
print()
return 0
if utterance.lower() in ("quit", "exit", "stop", "done"):
return 0
try:
handle(utterance, schemas, voice=args.voice, verbose=not args.quiet)
except Exception as exc: # never let one bad command kill the session
print(f"WoodShop: sorry, that command failed ({exc}).")
if __name__ == "__main__":
raise SystemExit(main())

136
src/woodshop/geometry.py Normal file
View File

@ -0,0 +1,136 @@
"""Turn a scene into real solids with build123d (accurate, exportable geometry).
This is the buildable side of the house: it produces watertight solids that can
be written to STL (3D printing) or STEP (CAD / CNC). The live viewer renders
lightweight boxes for speed; this module is the source of truth for export.
Coordinate convention matches scene.py: a board is length(X) x width(Y) x
thickness(Z), centered on its length axis, with end_a (the start) at the part's
``position_in`` before rotation about Z.
"""
from __future__ import annotations
from pathlib import Path
from .scene import Feature, Part, Scene, face_frame as _face_frame
def _orient_z_to(solid, n):
"""Rotate a Z-axis primitive (Cylinder) so its axis points along n."""
from build123d import Rot
if abs(n[2]) > 0.9:
return solid
if abs(n[1]) > 0.9:
return Rot(X=90) * solid
return Rot(Y=90) * solid
def _feature_solid_local(feat: Feature, L: float, w: float, t: float):
"""Return (solid, is_cut) for one feature, in the board's local frame."""
from build123d import Box, Cylinder, Pos
o, n, u, v = _face_frame(feat.face, L, w, t)
# Position on the face. When u is the length axis, `along_in` is measured
# from end_a; otherwise both offsets are from the face centre.
off_u = feat.along_in - (L / 2 if u == (1, 0, 0) else 0.0)
off_v = feat.across_in
fp = tuple(o[i] + off_u * u[i] + off_v * v[i] for i in range(3))
depth = feat.depth_in
if feat.kind == "hole":
r = feat.diameter_in / 2
thru = abs(n[0]) * L + abs(n[1]) * w + abs(n[2]) * t + 0.1
h = depth if depth > 0 else thru
cyl = _orient_z_to(Cylinder(radius=r, height=h), n)
c = tuple(fp[i] - n[i] * h / 2 for i in range(3)) # extend into the board
return Pos(*c) * cyl, True
# Box-shaped feature: cross-section width(u) × height(v), depth along normal.
sx = feat.width_in * abs(u[0]) + feat.height_in * abs(v[0]) + depth * abs(n[0])
sy = feat.width_in * abs(u[1]) + feat.height_in * abs(v[1]) + depth * abs(n[1])
sz = feat.width_in * abs(u[2]) + feat.height_in * abs(v[2]) + depth * abs(n[2])
sign = 1 if feat.kind == "tenon" else -1 # tenon protrudes; others cut inward
c = tuple(fp[i] + sign * n[i] * depth / 2 for i in range(3))
solid = Pos(*c) * Box(sx, sy, sz)
if feat.rotation_deg: # spin the cross-section about the normal
from build123d import Axis
solid = solid.rotate(Axis(fp, n), feat.rotation_deg)
return solid, (feat.kind != "tenon")
def _face_plane(face: str, L: float, w: float, t: float):
"""(axis index, coordinate) of a face's plane, for selecting its edges."""
return {
"top": (2, t / 2), "bottom": (2, -t / 2),
"right": (1, w / 2), "left": (1, -w / 2),
"end_b": (0, L), "end_a": (0, 0.0),
}[face]
def _apply_chamfer(solid, feat: Feature, L: float, w: float, t: float):
"""Bevel the edges around the feature's face by width_in."""
from build123d import chamfer
axis, value = _face_plane(feat.face, L, w, t)
size = feat.width_in or min(t, w) / 3
def coord(e):
c = e.center()
return (c.X, c.Y, c.Z)[axis]
edges = [e for e in solid.edges() if abs(coord(e) - value) < 1e-3]
if not edges:
return solid
try:
return chamfer(edges, min(size, min(t, w) / 2 - 1e-3))
except Exception:
return solid # over-sized / invalid chamfer: leave the board unbevelled
def part_solid(part: Part):
from build123d import Box, Pos, Rot
length = part.length_in
thickness, width = part.section_in
solid = Pos(length / 2, 0, 0) * Box(length, width, thickness) # local, start at origin
for feat in part.features: # apply joinery
if feat.kind == "chamfer":
solid = _apply_chamfer(solid, feat, length, width, thickness)
continue
fsolid, is_cut = _feature_solid_local(feat, length, width, thickness)
solid = (solid - fsolid) if is_cut else (solid + fsolid)
# place: roll about its own axis (X), tilt up toward Z (about Y), heading (Z).
solid = Rot(X=part.roll_deg) * solid
solid = Rot(Y=-part.tilt_deg) * solid
solid = Rot(Z=part.yaw_deg) * solid
solid = Pos(*part.position_in) * solid
return solid
def scene_compound(scene: Scene):
from build123d import Compound
solids = [part_solid(p) for p in scene.parts]
if not solids:
return None
return Compound(children=solids)
def export(scene: Scene, path: str | Path) -> Path:
"""Export the whole scene to STL or STEP based on the file extension."""
from build123d import export_step, export_stl
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
compound = scene_compound(scene)
if compound is None:
raise ValueError("Nothing to export: the scene is empty.")
if path.suffix.lower() == ".step":
export_step(compound, str(path))
elif path.suffix.lower() == ".stl":
export_stl(compound, str(path))
else:
raise ValueError(f"Unsupported export format: {path.suffix} (use .stl or .step)")
return path

View File

@ -0,0 +1,6 @@
"""The WoodShop desktop studio: a unified PySide6 window combining the live 3D
viewport, a parts panel with quick actions, and a voice/text command bar.
It's a thin shell over the same Scene model, operations, and Claude interpreter
used by the CLI and the standalone tools see controller.py.
"""

30
src/woodshop/gui/app.py Normal file
View File

@ -0,0 +1,30 @@
"""Entry point for the WoodShop desktop studio (`woodshop-gui`, or bare
`woodshop`)."""
from __future__ import annotations
import argparse
import os
import sys
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="woodshop-gui", description="WoodShop desktop studio.")
ap.add_argument("--scene", help="Path to scene.json")
args = ap.parse_args(argv)
# Make every subprocess we spawn (dictate, tools) use the same scene file.
if args.scene:
os.environ["WOODSHOP_SCENE"] = args.scene
from PySide6.QtWidgets import QApplication
from .main_window import MainWindow
app = QApplication.instance() or QApplication(sys.argv[:1])
app.setApplicationName("WoodShop")
window = MainWindow(args.scene)
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,449 @@
"""The shop-output window: tabbed Cut List / Shopping List / Cut Layout, each
printable. The Cut Layout tab draws the cutting-stock nesting and can try
alternative arrangements."""
from __future__ import annotations
import subprocess
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QBrush, QColor, QFont, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import (QDialog, QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QLabel,
QMenu, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
from collections import Counter
from ..cutlist import _fmt_len, board_feet
from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement,
placement_fits, recompute, relocate, reoptimize, rotate_placement,
snap_x, _plan_key)
from ..instructions import build_steps, format_steps, polish_prompt
from ..jigs import explain_prompt, format_jigs, suggest_jigs
from .workers import run_async
_PX = 7.0 # pixels per inch in the layout view
_PIECE = "#c8965a"
_WASTE = "#3a3a3a"
class _Piece(QGraphicsRectItem):
"""A draggable cut piece on the layout. Reports drops/rotate/lock back to the
window, which snaps + validates against the CutPlan."""
def __init__(self, win, pid, sp_id, w, h, locked, text):
super().__init__(0, 0, w, h)
self.win, self.pid, self.sp_id = win, pid, sp_id
self.setBrush(QBrush(QColor(_PIECE)))
self.setPen(QPen(QColor("#ffd700" if locked else "#111111"), 2 if locked else 1))
if not locked:
self.setFlag(QGraphicsItem.ItemIsMovable, True)
self.setCursor(Qt.OpenHandCursor)
if text:
t = QGraphicsSimpleTextItem(("🔒 " if locked else "") + text, self)
t.setBrush(QBrush(QColor("white")))
t.setPos(3, 3)
self._home = None
def mousePressEvent(self, e):
self._home = (self.sp_id, self.pos().x(), self.pos().y())
super().mousePressEvent(e)
def mouseReleaseEvent(self, e):
super().mouseReleaseEvent(e)
if self._home is not None:
self.win._drop_piece(self, self._home)
def mouseDoubleClickEvent(self, e):
self.win._rotate_piece(self.pid)
def contextMenuEvent(self, e):
self.win._piece_menu(self.pid, e.screenPos())
class BomWindow(QDialog):
def __init__(self, controller, parent=None):
super().__init__(parent)
self.c = controller
self.setWindowTitle("Cut List & BOM")
self.resize(820, 640)
self._order = 0
self._optimized = False
self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render
self._px = _PX
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
self.pool = QThreadPool.globalInstance()
self._cut_te = self._mono_te()
self._shop_te = self._mono_te()
tabs = QTabWidget()
tabs.addTab(self._print_wrap(self._cut_te), "Cut List")
tabs.addTab(self._print_wrap(self._shop_te), "Shopping List")
tabs.addTab(self._layout_tab(), "Cut Layout")
tabs.addTab(self._instructions_tab(), "Instructions")
tabs.addTab(self._jigs_tab(), "Jigs")
root = QVBoxLayout(self)
root.addWidget(tabs)
self._refresh_all()
# ----- one active plan; all tabs render from it ---------------------
def _set_plan(self, plan) -> None:
recompute(plan) # keep waste/score truthful after any change
self._plan = plan
self._refresh_all()
def _refresh_all(self) -> None:
self._cut_te.setPlainText(self._cut_text())
self._shop_te.setPlainText(self._shop_text())
self._instr.setPlainText(format_steps(build_steps(self.c.scene, self._plan)))
self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene)))
self._draw_layout()
# ----- text tabs ----------------------------------------------------
def _mono_te(self) -> QTextEdit:
te = QTextEdit(readOnly=True)
te.setFont(QFont("monospace"))
return te
def _print_wrap(self, te: QTextEdit) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
v.addWidget(te)
btn = QPushButton("Print…")
btn.clicked.connect(lambda: self._print_text(te))
row = QHBoxLayout(); row.addStretch(); row.addWidget(btn)
v.addLayout(row)
return w
def _cut_text(self) -> str:
plan = self._plan
groups = Counter((it.stock, round(it.length_in, 2), round(it.width_in, 2), it.is_sheet)
for it in plan.items)
lines = ["CUT LIST", ""]
for (stock, ln, wd, sheet), n in sorted(groups.items()):
if sheet:
lines.append(f" {n:>2} × {stock:<8} {_fmt_len(wd)} × {_fmt_len(ln)}"
f" ({wd * ln / 144 * n:.1f} sq ft)")
else:
lines.append(f" {n:>2} × {stock:<8} @ {_fmt_len(ln):<9}"
f" ({board_feet(stock, ln) * n:.1f} bd-ft)")
if not plan.items:
lines.append(" (nothing to cut yet)")
return "\n".join(lines)
def _shop_text(self) -> str:
plan = self._plan
lines = ["SHOPPING LIST", "", "Buy:"]
for stock, qty in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()):
s = "s" if qty != 1 else ""
unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')"
lines.append(f" {qty} × {stock} {unit}")
if not plan.stock_pieces:
lines.append(" (nothing yet)")
if plan.unplaced:
lines += ["", "⚠ Won't fit standard stock — source / cut specially:"]
for iid in plan.unplaced:
it = plan.item(iid)
lines.append(f" {it.part_id}: {_fmt_len(it.length_in)} {it.stock}")
lines += ["", "Yield (used / bought):"]
for stock in sorted({sp.stock for sp in plan.stock_pieces}):
sps = [sp for sp in plan.stock_pieces if sp.stock == stock]
sheet = sps[0].is_sheet
used = sum(p.len_in * p.wid_in for sp in sps for p in sp.placements)
cap = sum(sp.length_in * sp.width_in for sp in sps)
pct = used / cap * 100 if cap else 0
lines.append(f" {stock}: {pct:.0f}% used over {len(sps)} "
f"{'sheet' if sheet else 'stick'}{'s' if len(sps) != 1 else ''}")
return "\n".join(lines)
def _print_text(self, te: QTextEdit) -> None:
printer = QPrinter()
if QPrintDialog(printer, self).exec():
te.print_(printer)
# ----- instructions tab --------------------------------------------
def _instructions_tab(self) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
self._instr = QTextEdit(readOnly=True)
self._instr.setFont(QFont("monospace"))
self._instr.setPlainText(format_steps(build_steps(self.c.scene)))
v.addWidget(self._instr)
row = QHBoxLayout()
self._polish = QPushButton("Rewrite in plain English (AI)")
self._polish.clicked.connect(self._polish_instructions)
pr = QPushButton("Print…")
pr.clicked.connect(lambda: self._print_text(self._instr))
row.addWidget(self._polish); row.addStretch(); row.addWidget(pr)
v.addLayout(row)
return w
def _polish_instructions(self) -> None:
prompt = polish_prompt(build_steps(self.c.scene, self._plan))
self._polish.setEnabled(False)
self._polish.setText("Rewriting…")
def work():
r = subprocess.run(["claude", "-p"], input=prompt, capture_output=True, text=True)
return (r.stdout or "").strip()
def done(text):
self._polish.setEnabled(True)
self._polish.setText("Rewrite in plain English (AI)")
if text:
self._instr.setPlainText(text)
def failed(err):
self._polish.setEnabled(True)
self._polish.setText("Rewrite in plain English (AI)")
run_async(self.pool, work, on_done=done, on_error=failed)
# ----- jigs tab -----------------------------------------------------
def _jigs_tab(self) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
self._jigs = QTextEdit(readOnly=True)
self._jigs.setFont(QFont("monospace"))
self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene)))
v.addWidget(self._jigs)
row = QHBoxLayout()
self._jig_btn = QPushButton("Explain jigs (AI)")
self._jig_btn.clicked.connect(self._explain_jigs)
pr = QPushButton("Print…")
pr.clicked.connect(lambda: self._print_text(self._jigs))
row.addWidget(self._jig_btn); row.addStretch(); row.addWidget(pr)
v.addLayout(row)
return w
def _explain_jigs(self) -> None:
jigs = suggest_jigs(self.c.scene)
if not jigs:
return
prompt = explain_prompt(jigs)
self._jig_btn.setEnabled(False)
self._jig_btn.setText("Explaining…")
def work():
r = subprocess.run(["claude", "-p"], input=prompt, capture_output=True, text=True)
return (r.stdout or "").strip()
def done(text):
self._jig_btn.setEnabled(True)
self._jig_btn.setText("Explain jigs (AI)")
if text:
self._jigs.setPlainText(format_jigs(jigs) + "\n\n— HOW TO BUILD/USE —\n\n" + text)
def failed(err):
self._jig_btn.setEnabled(True)
self._jig_btn.setText("Explain jigs (AI)")
run_async(self.pool, work, on_done=done, on_error=failed)
# ----- layout tab (editable) ---------------------------------------
def _layout_tab(self) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
self.scene = QGraphicsScene()
self.view = QGraphicsView(self.scene)
v.addWidget(self.view)
self._status = QLabel("Drag a piece to re-place it · double-click a panel to rotate "
"· right-click to lock")
self._status.setStyleSheet("color:#aaaaaa; font-size:11px;")
v.addWidget(self._status)
row = QHBoxLayout()
opt = QPushButton("Find better layout")
opt.setToolTip("Try several packing strategies and keep the best-scoring one")
opt.clicked.connect(self._optimize)
bestn = QPushButton("Best of 100")
bestn.setToolTip("Run 100 packing attempts and keep the best")
bestn.clicked.connect(self._best_of_n)
alt = QPushButton("Try alternative")
alt.clicked.connect(self._next_arrangement)
pr = QPushButton("Print…")
pr.clicked.connect(self._print_layout)
row.addWidget(opt); row.addWidget(bestn); row.addWidget(alt)
row.addStretch(); row.addWidget(pr)
v.addLayout(row)
self._draw_layout()
return w
def _has_locks(self) -> bool:
return any(p.locked for sp in self._plan.stock_pieces for p in sp.placements)
def _optimize(self) -> None:
self._optimized = True
if self._has_locks():
best = min((reoptimize(self.c.scene, self._plan, st) for st in STRATEGIES),
key=_plan_key)
self._set_plan(best)
self._status.setText("✓ optimized around locked pieces")
else:
self._set_plan(best_cut_plan(self.c.scene))
self._status.setText("✓ optimized")
def _best_of_n(self) -> None:
self._optimized = True
if self._has_locks():
best = min((reoptimize(self.c.scene, self._plan, st) for st in STRATEGIES),
key=_plan_key)
self._set_plan(best)
self._status.setText("✓ best around locked pieces")
else:
self._set_plan(best_cut_plan(self.c.scene, attempts=100))
self._status.setText("✓ best of 100 attempts")
def _next_arrangement(self) -> None:
self._optimized = False
self._order = (self._order + 1) % len(STRATEGIES)
st = STRATEGIES[self._order]
plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks()
else build_cut_plan(self.c.scene, strategy=st))
self._set_plan(plan)
def _draw_layout(self) -> None:
plan = self._plan
self.scene.clear()
self._rows = []
names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
part_of = {it.id: it.part_id for it in plan.items}
label = lambda iid: names.get(part_of.get(iid, ""), iid)
px, y, bar = self._px, 30.0, 34.0
sc = plan.score
self._label(0, 2, f"{sc['strategy_name']} · {sc['stock_count']} stock · "
f"{sc['yield_pct']:.0f}% used · {sc['reusable_offcuts']} reusable")
n = m = 0
for sp in plan.stock_pieces: # lumber sticks first
if sp.is_sheet:
continue
n += 1
self._label(0, y - 15, f"{sp.stock} stick {n}")
self._rows.append((y, y + bar, sp))
for w in sp.waste:
self._rect(w.x_in * px, y, w.length_in * px, bar, _WASTE,
f"waste {_fmt_len(w.length_in)}")
for p in sp.placements:
self._add_piece(sp, p, p.x_in * px, y, p.len_in * px, bar,
f"{label(p.item_id)} · {_fmt_len(p.len_in)}")
y += bar + 24
for sp in plan.stock_pieces: # then plywood sheets
if not sp.is_sheet:
continue
m += 1
h = sp.width_in * px
self._label(0, y - 15, f"{sp.stock} sheet {m} "
f"({_fmt_len(sp.width_in)}×{_fmt_len(sp.length_in)})")
self._rect(0, y, sp.length_in * px, h, _WASTE, "")
self._rows.append((y, y + h, sp))
for p in sp.placements:
self._add_piece(sp, p, p.x_in * px, y + p.y_in * px,
p.len_in * px, p.wid_in * px, label(p.item_id))
y += h + 34
for warn in plan.warnings:
self._label(0, y, "" + warn)
y += 18
self.view.setSceneRect(self.scene.itemsBoundingRect())
def _add_piece(self, sp, p, x, y, w, h, text) -> None:
item = _Piece(self, p.id, sp.id, w, h, p.locked, text)
item.setPos(x, y)
self.scene.addItem(item)
# ----- drag / rotate / lock handlers -------------------------------
def _row_of(self, sp_id):
return next((y0 for y0, _y1, s in self._rows if s.id == sp_id), 0.0)
def _revert(self, plan, pid, home) -> None:
hsp, hx, hy = home
hrow = self._row_of(hsp)
home_sp = next(s for s in plan.stock_pieces if s.id == hsp)
relocate(plan, pid, hsp, hx / self._px,
(hy - hrow) / self._px if home_sp.is_sheet else 0.0)
def _drop_piece(self, item, home) -> None:
plan, px = self._plan, self._px
cy = item.sceneBoundingRect().center().y()
target = next((s for y0, y1, s in self._rows if y0 - 2 <= cy <= y1 + 2), None)
sp_cur, p = find_placement(plan, item.pid)
if target is None:
target = sp_cur
# Stock-type compatibility: a 2x4 can't go on a plywood sheet, etc.
item_stock = plan.item(p.item_id).stock
if item_stock != target.stock:
self._revert(plan, item.pid, home)
self._status.setText(f"{item_stock} can't go on {target.stock} — reverted")
recompute(plan); self._refresh_all()
return
row_y0 = self._row_of(target.id)
x_in = max(item.pos().x() / px, 0.0)
y_in = max((item.pos().y() - row_y0) / px, 0.0) if target.is_sheet else 0.0
relocate(plan, item.pid, target.id, x_in, y_in)
if not target.is_sheet:
p.x_in = snap_x(target, p, p.x_in, plan.settings.kerf_in)
if placement_fits(target, p, plan.settings.kerf_in):
self._status.setText("✓ placed")
else:
self._revert(plan, item.pid, home)
self._status.setText("✗ overlap / off-stock — move reverted")
recompute(plan) # refresh waste/score after the edit
self._refresh_all()
def _rotate_piece(self, pid) -> None:
plan = self._plan
sp, p = find_placement(plan, pid)
if not sp.is_sheet:
return
if not plan.settings.allow_plywood_rotation or plan.settings.grain_direction:
self._status.setText("✗ rotation isn't allowed (grain / settings)")
return
rotate_placement(plan, pid)
if placement_fits(sp, p, plan.settings.kerf_in):
self._status.setText("✓ rotated")
else:
rotate_placement(plan, pid) # rotate back
self._status.setText("✗ rotation doesn't fit")
recompute(plan)
self._refresh_all()
def _piece_menu(self, pid, screen_pos) -> None:
plan = self._plan
sp, p = find_placement(plan, pid)
menu = QMenu(self)
menu.addAction("Unlock" if p.locked else "Lock", lambda: self._toggle_lock(pid))
if sp.is_sheet and plan.settings.allow_plywood_rotation and not plan.settings.grain_direction:
menu.addAction("Rotate", lambda: self._rotate_piece(pid))
menu.exec(screen_pos)
def _toggle_lock(self, pid) -> None:
_sp, p = find_placement(self._plan, pid)
p.locked = not p.locked
self._refresh_all()
def _rect(self, x, y, w, h, color, text) -> None:
item = QGraphicsRectItem(x, y, w, h)
item.setBrush(QBrush(QColor(color)))
item.setPen(QPen(QColor("#111111")))
self.scene.addItem(item)
if text:
t = QGraphicsSimpleTextItem(text)
t.setBrush(QBrush(QColor("white")))
t.setPos(x + 3, y + 3)
self.scene.addItem(t)
def _label(self, x, y, text) -> None:
t = QGraphicsSimpleTextItem(text)
t.setBrush(QBrush(QColor("#cccccc")))
t.setPos(x, y)
self.scene.addItem(t)
def _print_layout(self) -> None:
printer = QPrinter()
if QPrintDialog(printer, self).exec():
from PySide6.QtGui import QPainter
painter = QPainter(printer)
self.scene.render(painter)
painter.end()

View File

@ -0,0 +1,118 @@
"""Command bar: type a command or push-to-talk, see the transcript, optionally
hear the reply. Slow work (LLM, dictate, TTS) runs off the UI thread."""
from __future__ import annotations
import subprocess
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtWidgets import (QCheckBox, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QTextEdit, QVBoxLayout, QWidget)
from .controller import Controller
from .workers import run_async
_WHO_COLOR = {"you": "#9cdcfe", "ws": "#c8965a", "sys": "#e06c75"}
class CommandBar(QWidget):
def __init__(self, controller: Controller, pool: QThreadPool, parent=None):
super().__init__(parent)
self.c = controller
self.pool = pool
root = QVBoxLayout(self)
self.transcript = QTextEdit(readOnly=True)
self.transcript.setMaximumHeight(150)
root.addWidget(self.transcript)
row = QHBoxLayout()
self.mic = QPushButton("🎤")
self.mic.setToolTip("Click and speak a command")
self.mic.setFixedWidth(40)
self.mic.clicked.connect(self._listen)
row.addWidget(self.mic)
self.input = QLineEdit()
self.input.setPlaceholderText("Type a command, e.g. 'build a coffee table' — Enter to send")
self.input.returnPressed.connect(self._send)
row.addWidget(self.input, 1)
send = QPushButton("Send")
send.clicked.connect(self._send)
row.addWidget(send)
root.addLayout(row)
bottom = QHBoxLayout()
self.speak = QCheckBox("Speak replies")
bottom.addWidget(self.speak)
bottom.addStretch()
self.status = QLabel("")
bottom.addWidget(self.status)
root.addLayout(bottom)
self.c.logged.connect(self._log)
# ----- logging -----------------------------------------------------
def _log(self, who: str, text: str) -> None:
if not text:
return
color = _WHO_COLOR.get(who, "#cccccc")
label = {"you": "you", "ws": "WoodShop", "sys": ""}.get(who, who)
self.transcript.append(f'<span style="color:{color}"><b>{label}:</b> '
f'{text.replace(chr(10), "<br>")}</span>')
self.transcript.verticalScrollBar().setValue(self.transcript.verticalScrollBar().maximum())
def _busy(self, on: bool, msg: str = "") -> None:
self.input.setEnabled(not on)
self.mic.setEnabled(not on)
self.status.setText(msg)
# ----- send typed/spoken command -----------------------------------
def _send(self) -> None:
text = self.input.text().strip()
if not text:
return
self.input.clear()
self._run(text)
def submit(self, text: str) -> None:
"""Run a command programmatically (e.g. from a Build-menu template)."""
self._run(text)
def _run(self, text: str) -> None:
self._log("you", text)
self._busy(True, "thinking…")
def work():
return self.c.run_command(text)
def done(summary):
self._busy(False)
if summary:
self._log("ws", summary)
if self.speak.isChecked():
run_async(self.pool, lambda: subprocess.run(
["read-aloud", "--strip-md", "true"], input=summary, text=True))
def failed(err):
self._busy(False)
self._log("sys", err)
run_async(self.pool, work, on_done=done, on_error=failed)
# ----- voice -------------------------------------------------------
def _listen(self) -> None:
self._busy(True, "listening…")
def work():
r = subprocess.run(["dictate", "--duration", "6"], capture_output=True, text=True)
return (r.stdout or "").strip()
def done(text):
self._busy(False)
if text:
self._run(text)
else:
self._log("sys", "Didn't catch that.")
run_async(self.pool, work, on_done=done, on_error=lambda e: (self._busy(False), self._log("sys", e)))

View File

@ -0,0 +1,431 @@
"""Controller: the single in-memory Scene and every way to mutate it.
Buttons/menus call the typed methods directly (instant); voice/typed commands
go through the Claude interpreter and are applied via `execute_call`, which
reuses the CLI's command functions so GUI and CLI behave identically. Every
mutation saves to disk (keeping the CLI/headless tools interoperable) and emits
`changed` so the views refresh.
"""
from __future__ import annotations
import copy
from pathlib import Path
from types import SimpleNamespace
from PySide6.QtCore import QObject, Signal
from .. import cli, driver
from ..scene import Scene, SceneError, default_scene_path
def _f(v):
"""Parse an optional float arg ('' / None -> None)."""
return float(v) if v not in (None, "") else None
def _opt(v):
"""Normalize an optional string arg ('' -> None)."""
return v if v not in (None, "") else None
# Map each wood-* tool to (cli command fn, namespace builder). Reusing the CLI
# command functions means voice and CLI share one implementation.
TOOL_CMD = {
"wood-place": lambda a: (cli.cmd_place, SimpleNamespace(
stock=a["stock"], length=a["length"], width=_opt(a.get("width")), unit="inch")),
"wood-join": lambda a: (cli.cmd_join, SimpleNamespace(
part_b=a["part_b"], part_a=_opt(a.get("to")), angle=float(a.get("angle") or 90),
offset=_opt(a.get("offset")), anchor=a.get("anchor") or "end_b", unit="inch")),
"wood-stand": lambda a: (cli.cmd_stand, SimpleNamespace(part=_opt(a.get("part")), tilt=float(a.get("tilt") or 90))),
"wood-lay": lambda a: (cli.cmd_lay, SimpleNamespace(part=_opt(a.get("part")))),
"wood-rotate": lambda a: (cli.cmd_rotate, SimpleNamespace(
part=_opt(a.get("part")), yaw=_f(a.get("yaw")), tilt=_f(a.get("tilt")), roll=_f(a.get("roll")))),
"wood-move": lambda a: (cli.cmd_move, SimpleNamespace(
part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")),
absolute=False, unit="inch")),
"wood-trim": lambda a: (cli.cmd_trim, SimpleNamespace(length=a["length"], part=_opt(a.get("part")), unit="inch")),
"wood-copy": lambda a: (cli.cmd_copy, SimpleNamespace(
part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")), unit="inch")),
"wood-rename": lambda a: (cli.cmd_rename, SimpleNamespace(name=a["name"], part=_opt(a.get("part")))),
"wood-feature": lambda a: (cli.cmd_feature, SimpleNamespace(
kind=a["kind"], part=_opt(a.get("part")), face=a.get("face") or "end_b",
along=_opt(a.get("along")), across=_opt(a.get("across")), width=_opt(a.get("width")),
height=_opt(a.get("height")), depth=_opt(a.get("depth")), diameter=_opt(a.get("diameter")),
rotation=_opt(a.get("rotation")))),
"wood-feature-delete": lambda a: (cli.cmd_feature_delete, SimpleNamespace(fid=a["fid"])),
"wood-connect": lambda a: (cli.cmd_connect, SimpleNamespace(anchor=a["anchor"], moving=a["moving"])),
"wood-explode": lambda a: (cli.cmd_explode, SimpleNamespace(distance=a["distance"])),
"wood-assemble": lambda a: (cli.cmd_assemble, SimpleNamespace()),
"wood-disconnect": lambda a: (cli.cmd_disconnect, SimpleNamespace(connection=a["connection"])),
"wood-sand": lambda a: (cli.cmd_sand, SimpleNamespace(part=_opt(a.get("part")))),
"wood-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))),
"wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])),
"wood-undo": lambda a: (cli.cmd_undo, SimpleNamespace()),
"wood-redo": lambda a: (cli.cmd_redo, SimpleNamespace()),
"wood-clear": lambda a: (cli.cmd_clear, SimpleNamespace()),
"wood-cutlist": lambda a: (cli.cmd_cutlist, SimpleNamespace()),
"wood-save": lambda a: (cli.cmd_save, SimpleNamespace(name=a["name"])),
"wood-open": lambda a: (cli.cmd_open, SimpleNamespace(name=a["name"])),
"wood-projects": lambda a: (cli.cmd_projects, SimpleNamespace()),
}
class Controller(QObject):
changed = Signal() # scene or selection changed -> refresh views
logged = Signal(str, str) # (who, text): who in {"you","ws","sys"}
preview_changed = Signal() # pending feature preview changed -> redraw red ghost
def __init__(self, scene_path: str | None = None):
super().__init__()
self.scene_path = Path(scene_path) if scene_path else default_scene_path()
self.scene = Scene.load(self.scene_path)
self._schemas: str | None = None
self.selected: list[str] = [self.scene.selection] if self.scene.selection else []
self.active_feature: str | None = None # feature currently being edited
self.preview = None # (Part, Feature) shown as an overlay, or None
self.preview_kind = "edit" # "edit" (red pending) | "highlight" (cyan)
# ----- persistence / notify ----------------------------------------
def save(self) -> None:
self.scene.save(self.scene_path)
def _commit(self, message: str | None = None) -> None:
self.save()
if message:
self.logged.emit("ws", message)
self.changed.emit()
# ----- selection (single + multi) ----------------------------------
def _valid(self, ids):
have = {p.id for p in self.scene.parts}
return [i for i in ids if i in have]
def set_selected(self, ids) -> None:
"""Replace the whole selection set. Primary = last in the list."""
self.selected = self._valid(list(dict.fromkeys(ids)))
self.scene.selection = self.selected[-1] if self.selected else None
self.changed.emit()
def select(self, ref: str | None) -> None:
"""Single-select by id/name (e.g. from a 3D click without Ctrl)."""
if not ref:
self.set_selected([])
return
try:
part = self.scene.get_part(ref)
except SceneError:
return
self.set_selected([part.id])
def toggle(self, ref: str | None) -> None:
"""Ctrl+click: add/remove a board from the selection."""
if not ref:
return
try:
pid = self.scene.get_part(ref).id
except SceneError:
return
ids = list(self.selected)
ids.remove(pid) if pid in ids else ids.append(pid)
self.set_selected(ids)
def target_ids(self) -> list[str]:
"""The boards an action applies to: the multi-selection, else the primary."""
if self.selected:
return list(self.selected)
return [self.scene.selection] if self.scene.selection else []
@property
def selected_id(self) -> str | None:
return self.scene.selection
# ----- direct operations (buttons / menus) -------------------------
def _do(self, fn) -> None:
try:
msg = fn()
except (SceneError, ValueError, KeyError) as exc:
self.logged.emit("sys", str(exc).strip('"'))
return
# A single op selects its result (e.g. placing a board selects it).
self.selected = [self.scene.selection] if self.scene.selection else []
self._commit(msg if isinstance(msg, str) else None)
def _do_group(self, op, verb: str) -> None:
"""Apply `op(part_id)` to every selected board as a single undo step."""
ids = self.target_ids()
if not ids:
self.logged.emit("sys", "Nothing selected.")
return
try:
with self.scene.batch():
for pid in ids:
op(pid)
except (SceneError, ValueError, KeyError) as exc:
self.logged.emit("sys", str(exc).strip('"'))
return
self.selected = self._valid(self.selected) # drop any deleted ids
if self.scene.selection not in {p.id for p in self.scene.parts}:
self.scene.selection = self.selected[-1] if self.selected else None
n = len(ids)
self._commit(f"{verb} {n} board{'s' if n > 1 else ''}.")
def place(self, stock: str, length_in: float, width_in: float | None = None):
self._do(lambda: f"Placed {self.scene.place(stock, length_in, width_in).id}.")
# group-aware (act on the whole selection)
def stand(self): self._do_group(lambda pid: self.scene.stand(pid), "Stood up")
def lay(self): self._do_group(lambda pid: self.scene.stand(pid, 0.0), "Laid flat")
def sand(self): self._do_group(lambda pid: self.scene.finish(pid), "Sanded")
def delete(self): self._do_group(lambda pid: self.scene.delete(pid), "Deleted")
def move_selected(self, dx=0.0, dy=0.0, dz=0.0):
self._do_group(lambda pid: self.scene.move(pid, dx, dy, dz), "Moved")
def rotate_selected(self, dyaw=0.0, dtilt=0.0):
def op(pid):
p = self.scene.get_part(pid)
self.scene.orient(pid, yaw=p.yaw_deg + dyaw, tilt=p.tilt_deg + dtilt)
self._do_group(op, "Rotated")
def rotate_90(self): self.rotate_selected(dyaw=90)
# single-part (act on the primary selection)
def undo(self): self._do(self.scene.undo)
def redo(self): self._do(self.scene.redo)
def clear(self): self._do(self.scene.clear)
def duplicate(self): self._do(lambda: f"Copied to {self.scene.copy(self.scene.selection).id}.")
def rename(self, ref, name): self._do(lambda: f"Named {self.scene.rename(ref, name).id}.")
def set_length(self, ref, length_in): self._do(lambda: f"Cut {self.scene.set_length(ref, length_in).id}.")
def rotate(self, ref=None, yaw=None, tilt=None, roll=None):
self._do(lambda: f"Oriented {self.scene.orient(ref, yaw=yaw, tilt=tilt, roll=roll).id}.")
# ----- joinery features --------------------------------------------
def _feature_defaults(self, kind: str, part) -> dict:
"""Sensible starting dimensions derived from the board's size."""
t, w = part.section_in
L = part.length_in
if kind == "tenon":
return dict(face="end_b", width_in=round(w / 2, 3), height_in=round(t / 2, 3), depth_in=1.0)
if kind == "mortise":
return dict(face="top", along_in=round(L / 2, 3), width_in=1.5,
height_in=round(w / 2, 3), depth_in=round(t / 2, 3))
if kind == "hole":
return dict(face="top", along_in=round(L / 2, 3), diameter_in=0.375, depth_in=0.0)
if kind == "slot":
return dict(face="top", along_in=round(L / 2, 3), width_in=2.0,
height_in=0.5, depth_in=round(t / 2, 3))
if kind == "chamfer":
return dict(face="end_b", width_in=round(min(t, w) / 3, 3), depth_in=0.0)
return dict(face="top")
def add_feature(self, kind: str) -> None:
if not self.scene.selection:
self.logged.emit("sys", "Select a board first.")
return
part = self.scene.get_part(self.scene.selection)
try:
feat = self.scene.add_feature(part.id, kind, **self._feature_defaults(kind, part))
except (SceneError, ValueError) as exc:
self.logged.emit("sys", str(exc).strip('"'))
return
self.active_feature = feat.id
self._commit(f"Added {kind} ({feat.id}) to {part.id}.")
def select_feature(self, fid: str | None) -> None:
self.active_feature = fid
self.changed.emit()
def edit_active_feature(self, **dims) -> None:
if not self.active_feature:
return
try:
self.scene.edit_feature(self.active_feature, **dims)
except (SceneError, ValueError) as exc:
self.logged.emit("sys", str(exc).strip('"'))
return
self._commit()
def delete_active_feature(self) -> None:
if not self.active_feature:
return
try:
msg = self.scene.delete_feature(self.active_feature)
except SceneError:
return
self.active_feature = None
self._commit(msg)
def active_feature_obj(self):
if not self.active_feature:
return None
try:
return self.scene.find_feature(self.active_feature)[1]
except SceneError:
return None
def features_of_kind(self, kind: str) -> list[tuple]:
"""All (part, feature) of a kind, excluding the one being edited."""
return [(p, f) for p in self.scene.parts for f in p.features
if f.kind == kind and f.id != self.active_feature]
def fit_feature(self, target_fid: str, clearance: float = 1 / 32) -> None:
"""Resize the active feature to mate with another (tenon<->mortise)."""
feat = self.active_feature_obj()
if not feat:
return
try:
_, target = self.scene.find_feature(target_fid)
except SceneError:
return
if feat.kind == "mortise" and target.kind == "tenon":
dims = dict(width_in=target.width_in + clearance,
height_in=target.height_in + clearance,
depth_in=target.depth_in + clearance) # pocket slightly deeper
elif feat.kind == "tenon" and target.kind == "mortise":
dims = dict(width_in=max(target.width_in - clearance, 0.05),
height_in=max(target.height_in - clearance, 0.05),
depth_in=target.depth_in) # tongue reaches the bottom
else:
self.logged.emit("sys", "Fit a mortise to a tenon (or a tenon to a mortise).")
return
self.scene.edit_feature(feat.id, **dims)
self._commit(f"Fitted {feat.id} to {target_fid}.")
def explode(self, distance: float = 3.0) -> None:
self._do(lambda: self.scene.explode(distance))
def assemble(self) -> None:
self._do(self.scene.assemble)
def break_connections(self, part_id: str | None = None) -> None:
self._do(lambda: self.scene.disconnect(part=part_id) if part_id
else self.scene.disconnect())
def feature_connection_ids(self, fid: str) -> list[str]:
return [c.id for c in self.scene.connections if fid in (c.anchor, c.moving)]
def break_feature_connection(self, fid: str) -> None:
"""Break the connection(s) that this specific feature is part of."""
cids = self.feature_connection_ids(fid)
if not cids:
return
def op():
with self.scene.batch():
for cid in cids:
self.scene.disconnect(cid=cid)
return f"Broke {len(cids)} connection(s) on {fid}."
self._do(op)
def groups(self) -> list[list[str]]:
return self.scene.groups()
def make_connection(self, target_fid: str, move_self: bool = False) -> None:
"""Seat the two features together. By default the target's board moves;
move_self moves the active feature's board instead. The moving board's
whole sub-assembly travels with it."""
feat = self.active_feature_obj()
if not feat:
return
anchor, moving = (target_fid, feat.id) if move_self else (feat.id, target_fid)
try:
msg = self.scene.connect(anchor, moving)
except SceneError as exc:
self.logged.emit("sys", str(exc).strip('"'))
return
self._commit(msg)
# ----- live preview of a pending feature edit ----------------------
def set_preview(self, **fields) -> None:
"""Stash a pending edit (does NOT change the model) and redraw the ghost."""
feat = self.active_feature_obj()
if not feat:
self.preview = None
else:
part = self.scene.find_feature(feat.id)[0]
pending = copy.copy(feat)
for k, val in fields.items():
if val is not None and hasattr(pending, k):
setattr(pending, k, val)
self.preview = (part, pending)
self.preview_kind = "edit"
self.preview_changed.emit()
def highlight_feature(self, fid: str | None) -> None:
"""Show a cyan highlight of a feature in the scene (no edit)."""
if not fid:
self.preview = None
else:
try:
part, feat = self.scene.find_feature(fid)
self.preview = (part, copy.copy(feat))
self.preview_kind = "highlight"
except SceneError:
self.preview = None
self.preview_changed.emit()
def clear_preview(self) -> None:
self.preview = None
self.preview_changed.emit()
def apply_preview(self) -> None:
"""Commit the pending edit to the real feature (then re-render geometry)."""
if not self.preview:
return
_, pending = self.preview
self.preview = None
self.scene.edit_feature(
pending.id, face=pending.face, along_in=pending.along_in,
across_in=pending.across_in, width_in=pending.width_in,
height_in=pending.height_in, depth_in=pending.depth_in,
diameter_in=pending.diameter_in, rotation_deg=pending.rotation_deg)
self._commit() # re-tessellates with the new geometry
self.preview_changed.emit() # clear the ghost
# ----- project / export --------------------------------------------
def open_project(self, name): self._do(lambda: cli.cmd_open(self.scene, SimpleNamespace(name=name)))
def save_project(self, name):
from ..scene import project_path
self.scene.save(project_path(name))
self.logged.emit("ws", f"Saved project '{name}'.")
def export(self, path: str):
from ..geometry import export
self.logged.emit("ws", f"Exported to {export(self.scene, path)}.")
def cutlist_text(self) -> str:
from ..cutlist import format_cutlist
return format_cutlist(self.scene)
# ----- voice / typed commands --------------------------------------
def schemas(self) -> str:
if self._schemas is None:
self._schemas = driver.load_schemas()
return self._schemas
def execute_call(self, tool: str, args: dict) -> str:
"""In-process executor for driver.dispatch (mutates the live scene)."""
entry = TOOL_CMD.get(tool)
if entry is None:
return f"(unknown tool {tool})"
func, ns = entry(args)
try:
return func(self.scene, ns)
except (SceneError, ValueError, KeyError) as exc:
return str(exc).strip('"')
def run_command(self, text: str) -> str:
"""Interpret a spoken/typed command and apply it. Returns a spoken summary.
(Slow call from a worker thread.)"""
from ..scene import spatial_summary
self.save() # ensure disk reflects current state
sel = ", ".join(self.selected) if self.selected else "none"
scene_text = (cli.cmd_status(self.scene, None)
+ f"\nCurrently selected ('these' / 'them' / 'the selected'): {sel}"
+ "\n" + spatial_summary(self.scene))
calls = driver.interpret(text, self.schemas(), scene_text=scene_text)
messages = driver.dispatch(calls, verbose=False, executor=self.execute_call)
self._commit()
return driver.summarize(calls, messages)

View File

@ -0,0 +1,225 @@
"""Joinery panel: add tenon/mortise/hole/slot/chamfer features to the selected
board, then tweak the active feature's fields. Add-with-default-then-edit:
clicking a kind drops a sensibly-sized feature you can immediately adjust."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QDoubleSpinBox, QFormLayout, QGridLayout, QHBoxLayout,
QLabel, QListWidget, QListWidgetItem, QMenu,
QMessageBox, QPushButton, QVBoxLayout, QWidget)
from ..scene import FACES
from .controller import Controller
_KINDS = ["tenon", "mortise", "hole", "slot", "chamfer"]
class FeaturePanel(QWidget):
def __init__(self, controller: Controller, parent=None):
super().__init__(parent)
self.c = controller
self._loading = False
root = QVBoxLayout(self)
root.addWidget(QLabel("<b>Joinery on the selected board</b>"))
add = QGridLayout()
for i, kind in enumerate(_KINDS):
b = QPushButton("+ " + kind.capitalize())
b.clicked.connect(lambda _=False, k=kind: self.c.add_feature(k))
add.addWidget(b, i // 2, i % 2)
root.addLayout(add)
self.list = QListWidget()
self.list.itemSelectionChanged.connect(self._on_row)
self.list.setContextMenuPolicy(Qt.CustomContextMenu)
self.list.customContextMenuRequested.connect(self._feat_menu)
root.addWidget(self.list, 1)
self.hint = QLabel("")
self.hint.setWordWrap(True)
self.hint.setStyleSheet("color:#aaaaaa; font-size:11px;")
root.addWidget(self.hint)
form = QFormLayout()
self.face = QComboBox(); self.face.addItems(FACES)
self.face.currentIndexChanged.connect(self._preview)
# (key, label, tooltip)
self._fields = [
("along_in", "Along board", "Position along the board's length (or 1st offset on an end)"),
("across_in", "Across", "Offset from the centre of the face"),
("width_in", "Width", "Feature size across the face (1st dimension)"),
("height_in", "Height", "Feature size across the face (2nd dimension)"),
("depth_in", "Depth", "How deep it cuts — or how far a tenon sticks out"),
("diameter_in", "Diameter", "Hole diameter (holes only)"),
("rotation_deg", "Rotate", "Spin the feature about its face normal to line up the cross-section"),
]
self._spins = {}
for key, label, tip in self._fields:
sp = QDoubleSpinBox()
if key == "rotation_deg":
sp.setRange(-180, 180); sp.setSingleStep(15); sp.setSuffix(" °")
else:
sp.setRange(-48, 96); sp.setSingleStep(0.25); sp.setSuffix(" in")
sp.setToolTip(tip)
sp.valueChanged.connect(self._preview) # live red ghost as you drag
self._spins[key] = sp
form.addRow(label, sp)
form.insertRow(0, "Face", self.face)
root.addLayout(form)
self.fit_btn = QPushButton("Fit to mate…")
self.fit_btn.setToolTip("Resize this tenon/mortise to fit a matching one on another board")
self.fit_btn.clicked.connect(self._fit)
root.addWidget(self.fit_btn)
btns = QHBoxLayout()
self.apply_btn = QPushButton("Apply")
self.apply_btn.setToolTip("Commit the previewed change (cuts/adds the real geometry)")
self.apply_btn.clicked.connect(self.c.apply_preview)
self.del_btn = QPushButton("Delete feature")
self.del_btn.clicked.connect(self.c.delete_active_feature)
btns.addWidget(self.apply_btn)
btns.addWidget(self.del_btn)
root.addLayout(btns)
self.c.changed.connect(self.refresh)
self.refresh()
def _part(self):
pid = self.c.selected_id
return next((p for p in self.c.scene.parts if p.id == pid), None) if pid else None
def refresh(self) -> None:
self._loading = True
part = self._part()
self.list.clear()
feats = part.features if part else []
# Map each connected feature -> the board(s) it mates with.
mates: dict[str, list[str]] = {}
for c in self.c.scene.connections:
if not self.c.scene._conn_valid(c):
continue
ap, mp = self.c.scene.feature_owner(c.anchor), self.c.scene.feature_owner(c.moving)
mates.setdefault(c.anchor, []).append(mp.name or mp.id)
mates.setdefault(c.moving, []).append(ap.name or ap.id)
# keep the active feature pointing at something on this board
ids = [f.id for f in feats]
if self.c.active_feature not in ids:
self.c.active_feature = ids[0] if ids else None
for f in feats:
mark = f" 🔗 → {', '.join(mates[f.id])}" if f.id in mates else ""
label = f"{f.id}: {f.kind} · {f.face}{mark}"
item = QListWidgetItem(label)
item.setData(Qt.UserRole, f.id)
self.list.addItem(item)
if f.id == self.c.active_feature:
item.setSelected(True)
feat = self.c.active_feature_obj()
editable = feat is not None
self.face.setEnabled(editable)
self.del_btn.setEnabled(editable)
self.apply_btn.setEnabled(editable)
for sp in self._spins.values():
sp.setEnabled(editable)
if feat:
self.face.setCurrentText(feat.face)
for key, sp in self._spins.items():
sp.setValue(getattr(feat, key))
self.hint.setText(_HINTS.get(feat.kind, "") if feat else
"Add a feature above, then adjust it here.")
mate = {"tenon": "mortise", "mortise": "tenon"}.get(feat.kind if feat else "")
self.fit_btn.setEnabled(bool(mate))
self.fit_btn.setText(f"Fit to {mate}" if mate else "Fit to mate…")
self._loading = False
# Highlight the selected feature in the scene (cyan), or clear if none.
self.c.highlight_feature(feat.id if feat else None)
def _on_row(self) -> None:
if self._loading:
return
items = self.list.selectedItems()
if items:
self.c.select_feature(items[0].data(Qt.UserRole))
def _feat_menu(self, pos) -> None:
item = self.list.itemAt(pos)
if not item:
return
fid = item.data(Qt.UserRole)
self.c.select_feature(fid)
menu = QMenu(self)
if self.c.feature_connection_ids(fid):
menu.addAction("Break this connection",
lambda: self.c.break_feature_connection(fid))
menu.addAction("Delete feature", self.c.delete_active_feature)
menu.exec(self.list.viewport().mapToGlobal(pos))
def _preview(self) -> None:
"""Live: show a red ghost of the pending values (no commit until Apply)."""
if self._loading or not self.c.active_feature:
return
dims = {key: sp.value() for key, sp in self._spins.items()}
self.c.set_preview(face=self.face.currentText(), **dims)
def _fit(self) -> None:
feat = self.c.active_feature_obj()
mate = {"tenon": "mortise", "mortise": "tenon"}.get(feat.kind if feat else "")
if not mate:
return
cands = self.c.features_of_kind(mate)
if not cands:
QMessageBox.information(self, "Fit", f"No {mate} on another board to fit to.")
return
dlg = QDialog(self)
dlg.setWindowTitle(f"Fit {feat.kind} to a {mate}")
lay = QVBoxLayout(dlg)
lay.addWidget(QLabel(f"Select the {mate} to mate with:"))
lst = QListWidget()
for part, f in cands:
who = f"{part.id} ({part.name})" if part.name else part.id
label = f"{who} · {f.id}: {f.kind} {f.width_in:g}×{f.height_in:g}×{f.depth_in:g}"
item = QListWidgetItem(label)
item.setData(Qt.UserRole, f.id)
lst.addItem(item)
# Highlight whichever candidate is selected so the user sees it in 3D.
lst.currentItemChanged.connect(
lambda cur, _prev: self.c.highlight_feature(cur.data(Qt.UserRole)) if cur else None)
lst.setCurrentRow(0)
lay.addWidget(lst)
connect_cb = QCheckBox("Make connection (seat the joint together)")
lay.addWidget(connect_cb)
which = QComboBox()
which.addItems(["Reposition the other board", "Reposition this board"])
which.setToolTip("Which board moves to seat the joint (its whole assembly moves with it)")
which.setEnabled(False)
connect_cb.toggled.connect(which.setEnabled)
lay.addWidget(which)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(dlg.accept)
bb.rejected.connect(dlg.reject)
lay.addWidget(bb)
accepted = dlg.exec()
if accepted and lst.currentItem():
target = lst.currentItem().data(Qt.UserRole)
self.c.fit_feature(target) # size to match
if connect_cb.isChecked():
self.c.make_connection(target, move_self=which.currentIndex() == 1)
else:
self.c.highlight_feature(feat.id) # restore highlight on cancel
_HINTS = {
"tenon": "Tenon — a tongue on the chosen end. Width × Height = its cross-section; "
"Depth = how far it sticks out; Across = shift it off-centre.",
"mortise": "Mortise — a pocket. Along = position down the board; Width × Height = "
"the opening on the face; Depth = how deep it goes.",
"hole": "Hole — Along = position down the board; Across = off-centre; "
"Diameter = size; Depth 0 = drilled all the way through.",
"slot": "Slot — a channel. Along = position; Width × Height = the opening; "
"Depth = how deep.",
"chamfer": "Chamfer — bevels the edges around the chosen Face; Width = bevel size "
"(the red preview highlights the face).",
}

View File

@ -0,0 +1,206 @@
"""The WoodShop studio main window: viewport + parts panel on top, command bar
below, with menus tying everything to the controller."""
from __future__ import annotations
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (QFileDialog, QInputDialog, QMainWindow, QMessageBox,
QSplitter, QTabWidget, QVBoxLayout, QWidget)
from ..cutlist import board_feet
from ..scene import list_projects
from .command_bar import CommandBar
from .controller import Controller
from .feature_panel import FeaturePanel
from .numpad import NumpadPanel
from .panels import PartsPanel
from .viewport import Viewport
class MainWindow(QMainWindow):
def __init__(self, scene_path: str | None = None):
super().__init__()
self.setWindowTitle("WoodShop")
self.resize(1280, 860)
self.pool = QThreadPool.globalInstance()
self.controller = Controller(scene_path)
self.viewport = Viewport()
self.parts = PartsPanel(self.controller)
self.features_panel = FeaturePanel(self.controller)
self.numpad = NumpadPanel(self.controller, self.viewport)
self.command = CommandBar(self.controller, self.pool)
tabs = QTabWidget()
tabs.addTab(self.parts, "Parts")
tabs.addTab(self.features_panel, "Joinery")
right = QWidget()
rlayout = QVBoxLayout(right)
rlayout.setContentsMargins(0, 0, 0, 0)
rlayout.addWidget(tabs, 1)
rlayout.addWidget(self.numpad)
top = QSplitter(Qt.Horizontal)
top.addWidget(self.viewport)
top.addWidget(right)
top.setStretchFactor(0, 3)
top.setStretchFactor(1, 1)
split = QSplitter(Qt.Vertical)
split.addWidget(top)
split.addWidget(self.command)
split.setStretchFactor(0, 4)
split.setStretchFactor(1, 1)
self.setCentralWidget(split)
self.viewport.picked.connect(self._on_pick)
self.controller.changed.connect(self._on_changed)
self.controller.preview_changed.connect(
lambda: self.viewport.set_preview(self.controller.preview,
self.controller.preview_kind))
self._build_menus()
self._on_changed() # initial render + status
def _on_pick(self, pid: str, additive: bool):
self.controller.toggle(pid) if additive else self.controller.select(pid)
def keyPressEvent(self, event):
# Physical numpad drives the move/rotate panel — but only when the user
# isn't typing in the command box.
if (event.modifiers() & Qt.KeypadModifier) and not self.command.input.hasFocus():
if self.numpad.trigger(event.key()):
event.accept()
return
super().keyPressEvent(event)
# ----- menus -------------------------------------------------------
def _act(self, menu, text, slot, shortcut=None):
a = QAction(text, self)
if shortcut:
a.setShortcut(QKeySequence(shortcut))
a.triggered.connect(slot)
menu.addAction(a)
return a
def _build_menus(self):
mb = self.menuBar()
f = mb.addMenu("&File")
self._act(f, "&New", self.controller.clear, "Ctrl+N")
self._act(f, "&Open Project…", self._open_project, "Ctrl+O")
self._act(f, "&Save Project…", self._save_project, "Ctrl+S")
f.addSeparator()
self._act(f, "&Export STL/STEP…", self._export)
self._act(f, "Save &Image…", self._render)
f.addSeparator()
self._act(f, "&Quit", self.close, "Ctrl+Q")
e = mb.addMenu("&Edit")
self._act(e, "&Undo", self.controller.undo, "Ctrl+Z")
self._act(e, "&Redo", self.controller.redo, "Ctrl+Y")
e.addSeparator()
self._act(e, "&Delete selected", self.controller.delete, "Del")
self._act(e, "&Clear scene", self.controller.clear)
v = mb.addMenu("&View")
self._act(v, "Top", lambda: self._camera(self.viewport.plotter.view_xy))
self._act(v, "Front", lambda: self._camera(self.viewport.plotter.view_xz))
self._act(v, "Side", lambda: self._camera(self.viewport.plotter.view_yz))
self._act(v, "Isometric", lambda: self._camera(self.viewport.plotter.view_isometric))
self._act(v, "Fit", lambda: self._camera(self.viewport.plotter.reset_camera))
b = mb.addMenu("&Build")
self._act(b, "Cut list / BOM…", self._show_cutlist)
self._act(b, "Table base…", lambda: self._template(
"build a table base: a {L} by {W} frame of 2x4s with four legs {H} tall standing at the corners",
[("Length", "48 in"), ("Width", "24 in"), ("Leg height", "29 in")]))
self._act(b, "Bookshelf side…", lambda: self._template(
"build a bookshelf side: two {H} 2x4 uprights {W} apart with {N} shelves of 1x8 between them",
[("Height", "48 in"), ("Width", "12 in"), ("Shelves", "3")]))
h = mb.addMenu("&Help")
self._act(h, "Commands…", self._show_help)
# ----- slots -------------------------------------------------------
def _on_changed(self):
scene = self.controller.scene
self.viewport.render_scene(scene, self.controller.selected)
# render_scene clears all actors — re-apply the feature overlay on top.
self.viewport.set_preview(self.controller.preview, self.controller.preview_kind)
bf = sum(board_feet(p.stock, p.length_in) for p in scene.parts)
sel = self.controller.selected
sel_txt = (f"{len(sel)} selected" if len(sel) > 1
else (sel[0] if sel else "none"))
self.statusBar().showMessage(
f"{len(scene.parts)} part(s) · {bf:.1f} board-feet · selection: {sel_txt}")
def _camera(self, fn):
fn()
self.viewport.plotter.render()
def _open_project(self):
names = list_projects()
if not names:
QMessageBox.information(self, "Open", "No saved projects yet.")
return
name, ok = QInputDialog.getItem(self, "Open Project", "Project:", names, 0, False)
if ok and name:
self.controller.open_project(name)
def _save_project(self):
name, ok = QInputDialog.getText(self, "Save Project", "Name:")
if ok and name.strip():
self.controller.save_project(name.strip())
def _export(self):
path, _ = QFileDialog.getSaveFileName(self, "Export", "model.step",
"CAD/Mesh (*.step *.stl)")
if path:
try:
self.controller.export(path)
except Exception as exc:
QMessageBox.warning(self, "Export failed", str(exc))
def _render(self):
path, _ = QFileDialog.getSaveFileName(self, "Save Image", "model.png", "PNG (*.png)")
if path:
self.viewport.plotter.screenshot(path)
self.controller.logged.emit("ws", f"Saved image to {path}.")
def _template(self, template, fields):
keys = {"Length": "L", "Width": "W", "Leg height": "H",
"Height": "H", "Shelves": "N"}
vals = {}
for label, default in fields:
text, ok = QInputDialog.getText(self, "Build", f"{label}:", text=default)
if not ok:
return
vals[keys[label]] = text
self.command.submit(template.format(**vals))
def _show_cutlist(self):
from .bom_window import BomWindow
self._bom = BomWindow(self.controller, self) # keep a ref so it isn't GC'd
self._bom.show()
def _show_help(self):
QMessageBox.information(self, "Commands", _HELP)
def closeEvent(self, event):
self.controller.save()
self.viewport.close_viewport()
super().closeEvent(event)
_HELP = """Speak or type, e.g.:
place a 6 foot 2x4
build a coffee table with four 18 inch legs
stand it up / lay it flat / rotate 90 degrees
move that 5 inches along x
select the front-left leg (or click a board)
sand it / delete that / undo / redo
what's my cut list? / save this as my table
Click a board (3D or list) to select it. Buttons act on the selection.
"""

View File

@ -0,0 +1,83 @@
"""Numberpad control panel: move/rotate the selection without talking, by
clicking buttons laid out like a numpad or by pressing the physical numpad
keys (MainWindow forwards them via `action_for`).
Layout (mirrors a keyboard numpad):
7 yaw 8 +Y 9 yaw
4 X 5 Fit 6 +X
1 tilt 2 Y 3 tilt
0 Front . Iso +Z / Z
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QDoubleSpinBox, QGridLayout, QGroupBox, QHBoxLayout,
QLabel, QPushButton, QVBoxLayout)
from .controller import Controller
class NumpadPanel(QGroupBox):
def __init__(self, controller: Controller, viewport, parent=None):
super().__init__("Move / Rotate", parent)
self.c = controller
self.vp = viewport
root = QVBoxLayout(self)
steps = QHBoxLayout()
self.step = QDoubleSpinBox(); self.step.setRange(0.05, 48); self.step.setValue(1.0)
self.step.setSuffix(" in"); self.step.setSingleStep(0.5)
self.angle = QDoubleSpinBox(); self.angle.setRange(1, 180); self.angle.setValue(15)
self.angle.setSuffix(" °")
steps.addWidget(QLabel("Step")); steps.addWidget(self.step)
steps.addWidget(QLabel("Angle")); steps.addWidget(self.angle)
root.addLayout(steps)
# key -> (button label, callable). Buttons and physical keys share this.
self._actions = {
Qt.Key_7: ("7\n⟲ yaw", lambda: self.c.rotate_selected(dyaw=-self._a())),
Qt.Key_8: ("8\n+Y ↑", lambda: self.c.move_selected(dy=self._s())),
Qt.Key_9: ("9\n⟳ yaw", lambda: self.c.rotate_selected(dyaw=self._a())),
Qt.Key_4: ("4\nX ←", lambda: self.c.move_selected(dx=-self._s())),
Qt.Key_5: ("5\nFit", self.vp.fit),
Qt.Key_6: ("6\n+X →", lambda: self.c.move_selected(dx=self._s())),
Qt.Key_1: ("1\n⤓ tilt", lambda: self.c.rotate_selected(dtilt=-self._a())),
Qt.Key_2: ("2\nY ↓", lambda: self.c.move_selected(dy=-self._s())),
Qt.Key_3: ("3\n⤒ tilt", lambda: self.c.rotate_selected(dtilt=self._a())),
Qt.Key_0: ("0\nFront", self.vp.set_front),
Qt.Key_Period: (".\nIso", self.vp.set_iso),
Qt.Key_Plus: ("+\nZ ↑", lambda: self.c.move_selected(dz=self._s())),
Qt.Key_Minus: ("\nZ ↓", lambda: self.c.move_selected(dz=-self._s())),
}
grid = QGridLayout()
positions = {
Qt.Key_7: (0, 0), Qt.Key_8: (0, 1), Qt.Key_9: (0, 2),
Qt.Key_4: (1, 0), Qt.Key_5: (1, 1), Qt.Key_6: (1, 2),
Qt.Key_1: (2, 0), Qt.Key_2: (2, 1), Qt.Key_3: (2, 2),
Qt.Key_0: (3, 0), Qt.Key_Period: (3, 1),
Qt.Key_Plus: (4, 0), Qt.Key_Minus: (4, 1),
}
for key, (label, _) in self._actions.items():
b = QPushButton(label)
b.setMinimumHeight(36)
b.clicked.connect(lambda _=False, k=key: self.trigger(k))
r, col = positions[key]
grid.addWidget(b, r, col)
root.addLayout(grid)
root.addWidget(QLabel("<i>Tip: use the keyboard numpad too.</i>"))
def _s(self) -> float:
return self.step.value()
def _a(self) -> float:
return self.angle.value()
def trigger(self, key) -> bool:
"""Run the action bound to a numpad key. Returns True if handled."""
entry = self._actions.get(key)
if entry:
entry[1]()
return True
return False

196
src/woodshop/gui/panels.py Normal file
View File

@ -0,0 +1,196 @@
"""Parts panel: the list of boards, the selected-part inspector with editable
fields, and quick-action buttons. Makes the current selection visible (the thing
that solves "delete that" ambiguity)."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QAbstractItemView, QComboBox, QDoubleSpinBox,
QFormLayout, QGridLayout, QGroupBox, QHBoxLayout,
QInputDialog, QLabel, QMenu, QPushButton, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget)
from ..lumber import NOMINAL_TO_ACTUAL, PLYWOOD_FRACTIONS, is_plywood
from .controller import Controller
class PartsPanel(QWidget):
def __init__(self, controller: Controller, parent=None):
super().__init__(parent)
self.c = controller
self._loading = False
root = QVBoxLayout(self)
root.addWidget(QLabel("<b>Parts</b> <span style='color:#888'>(connected boards group into assemblies)</span>"))
# Quick manual add — no AI needed.
add = QHBoxLayout()
self.stock = QComboBox()
self.stock.addItems(sorted(NOMINAL_TO_ACTUAL, key=lambda s: (s[0], len(s), s))
+ [f"ply-{f}" for f in PLYWOOD_FRACTIONS])
self.stock.setCurrentText("2x4")
self.stock.currentTextChanged.connect(self._stock_changed)
self.add_len = QDoubleSpinBox(); self.add_len.setRange(0.5, 480)
self.add_len.setValue(24); self.add_len.setSuffix(" in")
self.add_wid = QDoubleSpinBox(); self.add_wid.setRange(0.5, 96)
self.add_wid.setValue(24); self.add_wid.setPrefix("w "); self.add_wid.setSuffix(" in")
self.add_wid.setToolTip("Panel width (plywood)"); self.add_wid.setEnabled(False)
add_btn = QPushButton("+ Add")
add_btn.clicked.connect(self._add_board)
add.addWidget(self.stock); add.addWidget(self.add_len)
add.addWidget(self.add_wid); add.addWidget(add_btn)
root.addLayout(add)
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection) # Ctrl/Shift multi-select
self.tree.itemSelectionChanged.connect(self._on_row_selected)
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
self.tree.customContextMenuRequested.connect(self._context_menu)
root.addWidget(self.tree, 1)
box = QGroupBox("Selected")
bl = QVBoxLayout(box)
self.detail = QLabel("nothing selected")
self.detail.setWordWrap(True)
bl.addWidget(self.detail)
# quick actions
grid = QGridLayout()
actions = [
("Stand", lambda: self.c.stand()), ("Lay", lambda: self.c.lay()),
("Rotate 90°", lambda: self.c.rotate_90()), ("Sand", lambda: self.c.sand()),
("Duplicate", lambda: self.c.duplicate()), ("Rename", self._rename),
("Delete", lambda: self.c.delete()),
]
for i, (label, fn) in enumerate(actions):
b = QPushButton(label)
b.clicked.connect(lambda _=False, f=fn: f())
grid.addWidget(b, i // 2, i % 2)
bl.addLayout(grid)
# editable fields
form = QFormLayout()
self.len_spin = QDoubleSpinBox(); self.len_spin.setRange(0.1, 480); self.len_spin.setSuffix(" in")
self.yaw_spin = QDoubleSpinBox(); self.yaw_spin.setRange(-360, 360); self.yaw_spin.setSuffix(" °")
self.tilt_spin = QDoubleSpinBox(); self.tilt_spin.setRange(-180, 180); self.tilt_spin.setSuffix(" °")
self.len_spin.editingFinished.connect(self._apply_length)
self.yaw_spin.editingFinished.connect(self._apply_orientation)
self.tilt_spin.editingFinished.connect(self._apply_orientation)
form.addRow("Length", self.len_spin)
form.addRow("Yaw", self.yaw_spin)
form.addRow("Tilt", self.tilt_spin)
bl.addLayout(form)
root.addWidget(box)
self.c.changed.connect(self.refresh)
self.refresh()
def _part_label(self, p) -> str:
name = f" · {p.name}" if p.name else ""
extra = "" if p.features else ""
return f"{p.id} {p.stock} {p.length_in:g}\"{name}{extra}"
def _add_leaf(self, parent, pid, selected):
p = next((q for q in self.c.scene.parts if q.id == pid), None)
if not p:
return
item = QTreeWidgetItem(parent, [self._part_label(p)])
item.setData(0, Qt.UserRole, pid)
if pid in selected:
item.setSelected(True)
# ----- refresh from scene ------------------------------------------
def refresh(self) -> None:
self._loading = True
self.tree.clear()
selected = set(self.c.selected)
by_id = {p.id: p for p in self.c.scene.parts}
for group in self.c.groups():
if len(group) > 1: # an assembly -> parent node
names = [by_id[i].name or i for i in group if i in by_id]
node = QTreeWidgetItem(self.tree, [f"⛓ Assembly: {' + '.join(names)}"])
node.setData(0, Qt.UserRole, None)
node.setExpanded(True)
for pid in group:
self._add_leaf(node, pid, selected)
elif group:
self._add_leaf(self.tree, group[0], selected)
part = self._selected_part()
if part:
ori = "vertical" if part.is_vertical else f"yaw {part.yaw_deg:g}°, tilt {part.tilt_deg:g}°"
fin = f" · {', '.join(part.finishes)}" if part.finishes else ""
self.detail.setText(f"<b>{part.id}</b>{' · ' + part.name if part.name else ''}<br>"
f"{part.length_in:g}\" {part.stock} · {ori}{fin}")
self.len_spin.setValue(part.length_in)
self.yaw_spin.setValue(part.yaw_deg)
self.tilt_spin.setValue(part.tilt_deg)
else:
self.detail.setText("nothing selected")
self._loading = False
def _selected_part(self):
pid = self.c.selected_id
if not pid:
return None
return next((p for p in self.c.scene.parts if p.id == pid), None)
# ----- handlers ----------------------------------------------------
def _selected_ids(self) -> list[str]:
ids = []
for it in self.tree.selectedItems():
pid = it.data(0, Qt.UserRole)
if pid: # a part leaf
ids.append(pid)
else: # an assembly node -> its members
ids += [it.child(i).data(0, Qt.UserRole) for i in range(it.childCount())]
return list(dict.fromkeys(ids))
def _stock_changed(self, stock: str) -> None:
self.add_wid.setEnabled(is_plywood(stock)) # width only matters for plywood
def _add_board(self) -> None:
stock = self.stock.currentText()
width = self.add_wid.value() if is_plywood(stock) else None
self.c.place(stock, self.add_len.value(), width)
def _on_row_selected(self) -> None:
if self._loading:
return
self.c.set_selected(self._selected_ids())
def _context_menu(self, pos) -> None:
item = self.tree.itemAt(pos)
menu = QMenu(self)
if self.c.scene.connections:
menu.addAction("Back off connections", lambda: self.c.explode(3.0))
menu.addAction("Re-fit connections", self.c.assemble)
if item:
pid = item.data(0, Qt.UserRole)
if pid:
menu.addAction("Break this board's connections",
lambda: self.c.break_connections(pid))
menu.addAction("Break all connections", lambda: self.c.break_connections())
if menu.isEmpty():
menu.addAction("No connections yet").setEnabled(False)
menu.exec(self.tree.viewport().mapToGlobal(pos))
def _rename(self) -> None:
part = self._selected_part()
if not part:
return
name, ok = QInputDialog.getText(self, "Rename", "Name:", text=part.name)
if ok and name.strip():
self.c.rename(part.id, name.strip())
def _apply_length(self) -> None:
part = self._selected_part()
if part and not self._loading and abs(self.len_spin.value() - part.length_in) > 1e-6:
self.c.set_length(part.id, self.len_spin.value())
def _apply_orientation(self) -> None:
part = self._selected_part()
if part and not self._loading and (
abs(self.yaw_spin.value() - part.yaw_deg) > 1e-6
or abs(self.tilt_spin.value() - part.tilt_deg) > 1e-6):
self.c.rotate(part.id, yaw=self.yaw_spin.value(), tilt=self.tilt_spin.value())

View File

@ -0,0 +1,129 @@
"""Embedded 3D viewport (pyvistaqt). Renders the live Scene, highlights the
selection, and emits a signal when a board is clicked."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (QApplication, QHBoxLayout, QPushButton,
QVBoxLayout, QWidget)
from ..scene import Scene
from ..viewer import (_PALETTE, _add_feature_edges, _part_mesh, _quiet_vtk,
feature_preview_mesh)
class Viewport(QWidget):
picked = Signal(str, bool) # (part id, additive?) — additive when Ctrl held
def __init__(self, parent=None):
super().__init__(parent)
from pyvistaqt import QtInteractor
_quiet_vtk()
self.plotter = QtInteractor(self)
self.plotter.set_background("#2b2b2b")
self.plotter.enable_parallel_projection()
self._actor_to_pid: dict = {}
self._first = True
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.plotter.interactor)
bar = QHBoxLayout()
for label, fn in [("Top", self.plotter.view_xy), ("Front", self.plotter.view_xz),
("Side", self.plotter.view_yz), ("Iso", self.plotter.view_isometric),
("Fit", self.plotter.reset_camera)]:
b = QPushButton(label)
b.clicked.connect(lambda _=False, f=fn: (f(), self.plotter.render()))
bar.addWidget(b)
bar.addStretch()
layout.addLayout(bar)
self._enable_picking()
def _enable_picking(self) -> None:
try: # left-click picking; degrade gracefully if the API differs
self.plotter.enable_mesh_picking(
callback=self._on_pick, use_actor=True, show=False,
show_message=False, left_clicking=True,
)
except Exception:
pass
def _on_pick(self, actor) -> None:
pid = self._actor_to_pid.get(actor)
if pid:
additive = bool(QApplication.keyboardModifiers() & Qt.ControlModifier)
self.picked.emit(pid, additive)
def render_scene(self, scene: Scene, selected_ids=None) -> None:
selected_ids = set(selected_ids or ([scene.selection] if scene.selection else []))
cam = None if self._first else self.plotter.camera_position
self.plotter.clear()
self._actor_to_pid.clear()
labels, pts = [], []
for i, part in enumerate(scene.parts):
selected = part.id in selected_ids
mesh = _part_mesh(part)
actor = self.plotter.add_mesh(
mesh,
color="#f5d76e" if selected else _PALETTE[i % len(_PALETTE)],
show_edges=not part.features, line_width=3 if selected else 1,
edge_color="black", reset_camera=False, pickable=True,
)
self._actor_to_pid[actor] = part.id
if part.features:
_add_feature_edges(self.plotter, mesh, selected)
mid = [part.position_in[j] + part.axis_unit()[j] * part.length_in / 2 for j in range(3)]
labels.append(part.name or part.id)
pts.append(mid)
if pts:
self.plotter.add_point_labels(
pts, labels, font_size=12, text_color="white",
shape_color="#222222", shape_opacity=0.5, point_size=1,
name="labels", always_visible=True, reset_camera=False,
)
self.plotter.show_grid(color="#555555", xtitle="X (in)", ytitle="Y (in)", ztitle="Z (in)")
self.plotter.add_axes()
if self._first:
self.plotter.view_isometric()
self._first = False
elif cam is not None:
self.plotter.camera_position = cam # keep the user's viewpoint
self.plotter.render()
def set_preview(self, preview, kind="edit") -> None:
"""Draw (or clear) a feature overlay: red for a pending edit, cyan to
highlight the selected feature."""
for name in ("preview_face", "preview_edges"):
try:
self.plotter.remove_actor(name)
except Exception:
pass
if preview is not None:
part, feat = preview
color = "#33ccff" if kind == "highlight" else "red"
try:
mesh = feature_preview_mesh(part, feat)
self.plotter.add_mesh(mesh, color=color, opacity=0.4, pickable=False,
reset_camera=False, name="preview_face")
edges = mesh.extract_feature_edges()
if edges.n_points:
self.plotter.add_mesh(edges, color=color, line_width=3,
pickable=False, reset_camera=False, name="preview_edges")
except Exception:
pass
self.plotter.render()
def set_front(self): self.plotter.view_xz(); self.plotter.render()
def set_iso(self): self.plotter.view_isometric(); self.plotter.render()
def fit(self): self.plotter.reset_camera(); self.plotter.render()
def close_viewport(self) -> None:
try:
self.plotter.close()
except Exception:
pass

View File

@ -0,0 +1,52 @@
"""Run slow work (dictate, the LLM call, read-aloud) off the Qt event loop so
the UI never freezes.
Lifetime note: a QRunnable is auto-deleted by the pool the moment run() returns,
which can destroy its signals object before Qt delivers the queued result to the
UI thread (the "done" callback then never fires). We disable auto-delete and
keep a strong reference until the result is delivered, then drop it.
"""
from __future__ import annotations
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
class _Signals(QObject):
done = Signal(object)
error = Signal(str)
class _Task(QRunnable):
def __init__(self, fn):
super().__init__()
self.fn = fn
self.signals = _Signals()
self.setAutoDelete(False) # we manage lifetime (see module docstring)
def run(self):
try:
self.signals.done.emit(self.fn())
except Exception as exc: # surface, don't crash the pool
self.signals.error.emit(str(exc))
_active: set[_Task] = set()
def run_async(pool: QThreadPool, fn, on_done=None, on_error=None) -> None:
task = _Task(fn)
_active.add(task) # keep alive until a result is delivered on the UI thread
def finish_done(result):
_active.discard(task)
if on_done:
on_done(result)
def finish_error(message):
_active.discard(task)
if on_error:
on_error(message)
task.signals.done.connect(finish_done)
task.signals.error.connect(finish_error)
pool.start(task)

View File

@ -0,0 +1,82 @@
"""Build instructions: a DETERMINISTIC ordered step list from the CutPlan + scene.
Every number/part name comes from the model. The AI is only used (optionally) to
rephrase these steps into friendlier prose `polish_prompt()` builds a prompt
that explicitly forbids changing any measurement.
"""
from __future__ import annotations
from collections import Counter
from .cutlist import _fmt_len
from .cutplan import build_cut_plan
def build_steps(scene, plan=None) -> list:
"""Return [(title, [lines])] in build order. Deterministic."""
plan = plan or build_cut_plan(scene)
names = {p.id: (p.name or p.id) for p in scene.parts}
part_of = {it.id: it.part_id for it in plan.items}
sections = []
buy = []
for stock, n in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()):
unit = "sheet" if stock.startswith("ply-") else "8' stick"
buy.append(f"{n} × {stock} ({unit}{'s' if n != 1 else ''})")
if buy:
sections.append(("Gather stock", buy))
cuts = []
for sp in plan.stock_pieces:
kind = "sheet" if sp.is_sheet else "stick"
pieces = []
for p in sp.placements:
nm = names.get(part_of.get(p.item_id, ""), p.item_id)
dims = (f"{_fmt_len(p.wid_in)}×{_fmt_len(p.len_in)}" if sp.is_sheet
else _fmt_len(p.len_in))
pieces.append(f"{nm} ({dims})")
if pieces:
cuts.append(f"From a {sp.stock} {kind}: cut " + ", ".join(pieces))
if cuts:
sections.append(("Cut pieces to size (see the Cut Layout tab)", cuts))
joinery = []
for p in scene.parts:
for f in p.features:
dims = (f"{f.diameter_in:g}\"" if f.kind == "hole"
else f"{f.width_in:g}×{f.height_in:g}×{f.depth_in:g}\"")
joinery.append(f"On {names[p.id]}{f.kind} on the {f.face} face ({dims})")
if joinery:
sections.append(("Mark and cut the joinery", joinery))
sanded = [names[p.id] for p in scene.parts if "sanded" in p.finishes]
sections.append(("Sand", [f"Sand {', '.join(sanded)} smooth." if sanded
else "Sand all parts smooth."]))
asm = []
for c in scene.connections:
if not scene._conn_valid(c):
continue
ap, mp = scene.feature_owner(c.anchor), scene.feature_owner(c.moving)
asm.append(f"Join {names[mp.id]} to {names[ap.id]} (seat the joint).")
if asm:
sections.append(("Dry-fit, then glue and fasten", asm))
sections.append(("Finish", ["Apply your chosen finish."]))
return sections
def format_steps(sections) -> str:
out = []
for n, (title, lines) in enumerate(sections, 1):
out.append(f"{n}. {title}")
out += [f"{ln}" for ln in lines]
out.append("")
return "\n".join(out).rstrip() or "Nothing to build yet."
def polish_prompt(sections) -> str:
return ("Rewrite these woodworking build steps as clear, friendly, numbered shop "
"instructions a beginner could follow. KEEP every measurement, part name, "
"and count EXACTLY as given — do not invent or change any number. Plain text only.\n\n"
+ format_steps(sections))

93
src/woodshop/jigs.py Normal file
View File

@ -0,0 +1,93 @@
"""Jig suggestions: detect REPEATED operations (rule-based, deterministic) and
propose shop aids with computed dimensions. The AI only explains how to build/use
them it never sets a dimension.
Jigs are *shop aids*, kept separate from the project BOM (don't silently add jig
material to what the user buys).
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from .cutlist import _fmt_len, cut_length
from .lumber import is_plywood
@dataclass
class JigSuggestion:
kind: str
title: str
count: int # how many repeated operations it serves
detail: str # deterministic specifics (dimensions, what to set)
material: list = field(default_factory=list) # optional shop-aid stock
def suggest_jigs(scene, min_repeats: int = 3) -> list:
jigs = []
# Repeated identical crosscuts -> stop block.
lengths = Counter((p.stock, round(cut_length(p), 2))
for p in scene.parts if not is_plywood(p.stock))
for (stock, ln), n in sorted(lengths.items(), key=lambda kv: -kv[1]):
if n >= min_repeats:
jigs.append(JigSuggestion(
"stop-block", f"Stop block — {n}× {stock} @ {_fmt_len(ln)}", n,
f"Clamp a stop block {_fmt_len(ln)} from the blade (or marking edge) and cut all "
f"{n} pieces against it for identical length every time.",
[f"a ~3\" {stock} offcut (the stop)", "a straight fence/backer board"]))
# Repeated holes at the SAME registered position -> drilling template.
# (Grouping by position, not just diameter — a fixed template only locates
# holes that share a face + offsets.)
holes = Counter((p.stock, f.face, round(f.along_in, 2), round(f.across_in, 2), round(f.diameter_in, 3))
for p in scene.parts for f in p.features if f.kind == "hole")
for (stock, face, along, across, dia), n in sorted(holes.items()):
if n >= min_repeats:
jigs.append(JigSuggestion(
"drill-template", f"Drilling template — {n}×{dia:g}\" holes ({stock}, {face})", n,
f"Make a template with a ⌀{dia:g}\" guide hole and clamp it to register the hole "
f"at the same spot ({_fmt_len(along)} along, {across:g}\" off centre) on all {n} parts.",
["a scrap of ply/hardboard for the template"]))
# Repeated mortises at the same position/size -> positioning template.
mort = Counter((p.stock, f.face, round(f.along_in, 2), round(f.across_in, 2),
round(f.width_in, 2), round(f.height_in, 2), round(f.depth_in, 2), round(f.rotation_deg, 1))
for p in scene.parts for f in p.features if f.kind == "mortise")
for (stock, face, along, across, w, h, d, _rot), n in sorted(mort.items()):
if n >= min_repeats:
jigs.append(JigSuggestion(
"mortise-template", f"Mortise template — {n}× {w:g}×{h:g}\" ({stock}, {face})", n,
f"Build a routing template with a {w:g}×{h:g}\" opening; register it at the same spot "
f"({_fmt_len(along)} along) and rout all {n} mortises {_fmt_len(d)} deep with a guide bushing.",
["template stock (ply/MDF)", "guide bushing"]))
# Repeated panel widths -> set the rip fence once.
widths = Counter(round(p.section_in[1], 2) for p in scene.parts if is_plywood(p.stock))
for wd, n in sorted(widths.items()):
if n >= min_repeats:
jigs.append(JigSuggestion(
"rip-stop", f"Rip-fence setting — {n}× {_fmt_len(wd)}-wide panels", n,
f"Set the rip fence to {_fmt_len(wd)} once and rip all {n} panels without re-measuring."))
return jigs
def format_jigs(jigs) -> str:
if not jigs:
return "No repeated operations detected yet — no jigs suggested."
out = ["SHOP AIDS / JIGS (optional — not part of the project BOM)", ""]
for j in jigs:
out.append(f"{j.title} (saves repeating {j.count}×)")
out.append(f" {j.detail}")
if j.material:
out.append(" Build from: " + ", ".join(j.material))
out.append("")
return "\n".join(out).rstrip()
def explain_prompt(jigs) -> str:
listing = "\n".join(f"- {j.title}: {j.detail}" for j in jigs)
return ("Explain, in friendly beginner terms, how to build and use each of these "
"woodworking jigs. KEEP every dimension exactly as given; do not invent numbers. "
"Plain text.\n\n" + listing)

62
src/woodshop/layout.py Normal file
View File

@ -0,0 +1,62 @@
"""Backwards-compatible nesting helpers — thin wrappers over cutplan.build_cut_plan.
The real packing now lives in cutplan.py (the CutPlan artifact). These keep the
older dict-shaped APIs working for existing callers/tests.
"""
from __future__ import annotations
from collections import Counter
from .cutplan import build_cut_plan
STICK_LEN = 96.0
KERF = 0.125
def nest_lumber(scene, order="decreasing", **_) -> dict:
plan = build_cut_plan(scene, strategy=order)
out: dict[str, list] = {}
for sp in plan.stock_pieces:
if sp.is_sheet:
continue
pieces = [(plan.item(p.item_id).part_id, p.len_in) for p in sp.placements]
used = sum(p.len_in for p in sp.placements) + plan.settings.kerf_in * max(len(pieces) - 1, 0)
offcut = sp.waste[0].length_in if sp.waste else round(sp.length_in - used, 3)
out.setdefault(sp.stock, []).append(
{"pieces": pieces, "used": round(used, 3), "offcut": round(offcut, 3)})
return out
def nest_plywood(scene, order="decreasing", **_) -> dict:
plan = build_cut_plan(scene, strategy=order)
out: dict[str, list] = {}
for sp in plan.stock_pieces:
if not sp.is_sheet:
continue
placements = [(plan.item(p.item_id).part_id, p.x_in, p.y_in, p.len_in, p.wid_in)
for p in sp.placements]
out.setdefault(sp.stock, []).append(
{"placements": placements, "sheet": (sp.width_in, sp.length_in)})
return out
def stock_counts(scene, **_) -> dict:
return dict(Counter(sp.stock for sp in build_cut_plan(scene).stock_pieces))
def waste_summary(scene, **_) -> dict:
plan = build_cut_plan(scene)
out: dict[str, dict] = {}
for sp in plan.stock_pieces:
d = out.setdefault(sp.stock, {"bought": 0, "used": 0.0, "capacity": 0.0})
d["bought"] += 1
if sp.is_sheet:
d["used"] += sum(p.len_in * p.wid_in for p in sp.placements) / 144
d["capacity"] += sp.length_in * sp.width_in / 144
else:
d["used"] += sum(p.len_in for p in sp.placements)
d["capacity"] += sp.length_in
for d in out.values():
d["used"] = round(d["used"], 1)
d["capacity"] = round(d["capacity"], 1)
return out

71
src/woodshop/lumber.py Normal file
View File

@ -0,0 +1,71 @@
"""Nominal -> actual dimensional lumber sizing.
A "2x4" is nominally 2"x4" but actually 1.5"x3.5" once surfaced. Getting this
right is what makes the models buildable rather than decorative, so the table
lives in one place and is shared by both the operations and the viewport.
All values are in inches. Section is (thickness, width) of the board's
cross-section; length is supplied per-part by the user.
"""
from __future__ import annotations
# (thickness, width) in inches for surfaced softwood dimensional lumber.
NOMINAL_TO_ACTUAL: dict[str, tuple[float, float]] = {
"1x2": (0.75, 1.5),
"1x3": (0.75, 2.5),
"1x4": (0.75, 3.5),
"1x6": (0.75, 5.5),
"1x8": (0.75, 7.25),
"1x10": (0.75, 9.25),
"1x12": (0.75, 11.25),
"2x2": (1.5, 1.5),
"2x3": (1.5, 2.5),
"2x4": (1.5, 3.5),
"2x6": (1.5, 5.5),
"2x8": (1.5, 7.25),
"2x10": (1.5, 9.25),
"2x12": (1.5, 11.25),
"4x4": (3.5, 3.5),
"4x6": (3.5, 5.5),
"6x6": (5.5, 5.5),
}
# Plywood is sheet stock: a fixed thickness, cut to any width × length. Canonical
# stock name is "ply-<fraction>"; standard sheet is 4' × 8'.
PLYWOOD_FRACTIONS = ("1/8", "1/4", "3/8", "1/2", "5/8", "3/4")
SHEET_WIDTH_IN, SHEET_LENGTH_IN = 48.0, 96.0
def normalize_stock(stock: str) -> str:
"""Canonicalize a stock name: '2 x 4' -> '2x4'; '3/4 plywood' -> 'ply-3/4'."""
s = stock.strip().lower()
if "ply" in s:
for frac in PLYWOOD_FRACTIONS:
if frac in s:
return f"ply-{frac}"
return "ply-3/4" # bare "plywood" defaults to 3/4"
return s.replace(" ", "").replace("by", "x")
def is_plywood(stock: str) -> bool:
return normalize_stock(stock).startswith("ply-")
def plywood_thickness(stock: str) -> float:
num, den = normalize_stock(stock).split("-", 1)[1].split("/")
return float(num) / float(den)
def actual_section(stock: str) -> tuple[float, float]:
"""Return the (thickness, width) in inches for a nominal lumber stock name.
Raises KeyError with the list of known stock if unknown. (Plywood is handled
separately its width is per-panel, not fixed by the stock.)
"""
key = normalize_stock(stock)
if key not in NOMINAL_TO_ACTUAL:
known = ", ".join(sorted(NOMINAL_TO_ACTUAL)) + ", " + \
", ".join(f"ply-{f}" for f in PLYWOOD_FRACTIONS)
raise KeyError(f"Unknown stock {stock!r}. Known stock: {known}")
return NOMINAL_TO_ACTUAL[key]

806
src/woodshop/scene.py Normal file
View File

@ -0,0 +1,806 @@
"""The WoodShop scene: the single source of truth for a model.
A scene is a list of *parts* (boards) and *joints* (how they attach), plus a
*selection* (the last-touched part, so commands like "sand it" resolve) and an
*undo stack*. It is persisted as plain JSON so that:
* stateless CmdForge operation tools can read -> mutate -> write it, and
* the long-lived viewport process can watch the file and re-render.
Geometry convention (all inches): a part is a box of length x width x thickness.
Unplaced, it runs along +X starting at ``position``; ``width`` along Y,
``thickness`` along Z. ``rotation_deg`` is a rotation about the Z axis applied
about ``position``. Joints compute the attached part's position/rotation so the
viewport stays a dumb renderer (no constraint solver needed for the PoC).
"""
from __future__ import annotations
import copy
import json
import math
import os
from contextlib import contextmanager
from dataclasses import dataclass, field, fields, asdict
from pathlib import Path
from .lumber import actual_section, is_plywood, normalize_stock, plywood_thickness
SCENE_VERSION = 1
# --- small 3x3 rotation helpers (degrees) for board orientation -------------
def _matmul(a, b):
return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
def _dot(u, v):
return u[0] * v[0] + u[1] * v[1] + u[2] * v[2]
def _transpose(M):
return [[M[j][i] for j in range(3)] for i in range(3)]
def _mv(M, x): # 3x3 matrix · 3-vector
return tuple(sum(M[i][j] * x[j] for j in range(3)) for i in range(3))
def _rot_x(deg):
c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
return [[1, 0, 0], [0, c, -s], [0, s, c]]
def _rot_y(deg):
c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
return [[c, 0, s], [0, 1, 0], [-s, 0, c]]
def _rot_z(deg):
c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
return [[c, -s, 0], [s, c, 0], [0, 0, 1]]
def face_frame(face, L, w, t):
"""Local-frame (origin, outward normal, in-plane u, in-plane v) of a board
face. X in [0,L], Y in [-w/2,w/2], Z in [-t/2,t/2]."""
return {
"top": ((L / 2, 0, t / 2), (0, 0, 1), (1, 0, 0), (0, 1, 0)),
"bottom": ((L / 2, 0, -t / 2), (0, 0, -1), (1, 0, 0), (0, 1, 0)),
"right": ((L / 2, w / 2, 0), (0, 1, 0), (1, 0, 0), (0, 0, 1)),
"left": ((L / 2, -w / 2, 0), (0, -1, 0), (1, 0, 0), (0, 0, 1)),
"end_b": ((L, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)),
"end_a": ((0, 0, 0), (-1, 0, 0), (0, 1, 0), (0, 0, 1)),
}[face]
def _rodrigues(x, n, deg):
"""Rotate vector x about unit axis n by deg (Rodrigues' formula)."""
a = math.radians(deg)
c, s = math.cos(a), math.sin(a)
cross = (n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0])
d = n[0] * x[0] + n[1] * x[1] + n[2] * x[2]
return tuple(x[i] * c + cross[i] * s + n[i] * d * (1 - c) for i in range(3))
def matrix_to_ypr(R):
"""Decompose a 3x3 rotation into (yaw, tilt, roll) for our convention
R = Rz(yaw)·Ry(-tilt)·Rx(roll)."""
sy = math.hypot(R[0][0], R[1][0])
if sy > 1e-6:
yaw = math.degrees(math.atan2(R[1][0], R[0][0]))
d = math.degrees(math.atan2(-R[2][0], sy))
roll = math.degrees(math.atan2(R[2][1], R[2][2]))
else: # gimbal lock (pointing straight up/down)
yaw = math.degrees(math.atan2(-R[1][2], R[1][1]))
d = math.degrees(math.atan2(-R[2][0], sy))
roll = 0.0
return yaw, -d, roll
def _data_dir() -> Path:
return Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "woodshop"
def default_scene_path() -> Path:
"""Where the active scene lives (override with $WOODSHOP_SCENE)."""
env = os.environ.get("WOODSHOP_SCENE")
if env:
return Path(env).expanduser()
return _data_dir() / "scene.json"
def slugify(name: str) -> str:
return "-".join("".join(c if c.isalnum() else " " for c in name).split()).lower()
def projects_dir() -> Path:
return _data_dir() / "projects"
def project_path(name: str) -> Path:
slug = slugify(name)
if not slug:
raise ValueError("Please give the project a name.")
return projects_dir() / f"{slug}.json"
def list_projects() -> list[str]:
d = projects_dir()
return sorted(p.stem for p in d.glob("*.json")) if d.exists() else []
# Feature kinds: ADD fuses material (tenon), CUT subtracts a box/cylinder, EDGE
# operates on the board's edges (chamfer bevel).
ADD_KINDS = {"tenon"}
CUT_KINDS = {"mortise", "slot", "hole", "dado", "rabbet"}
EDGE_KINDS = {"chamfer"}
FEATURE_KINDS = ADD_KINDS | CUT_KINDS | EDGE_KINDS
FACES = ("end_a", "end_b", "top", "bottom", "left", "right")
@dataclass
class Feature:
"""A parametric joinery feature attached to a board — a boolean op (add for a
tenon, cut for mortise/slot/hole) on a chosen face, re-editable and movable.
Placement on the face uses two offsets: for face=top/bottom/left/right,
``along_in`` is the position along the board length (from end_a) and
``across_in`` is offset from the face centre; for face=end_a/end_b the two
offsets are the lateral (width) and vertical (thickness) positions from
centre. ``width_in`` × ``height_in`` is the feature's cross-section on the
face and ``depth_in`` is how deep it cuts (or how far a tenon protrudes).
``diameter_in`` is used for round holes instead of width/height.
"""
id: str
kind: str
face: str = "end_b"
along_in: float = 0.0
across_in: float = 0.0
width_in: float = 1.0
height_in: float = 1.0
depth_in: float = 1.0
diameter_in: float = 0.375
rotation_deg: float = 0.0 # rotation of the feature about its face normal
@property
def is_cut(self) -> bool:
return self.kind in CUT_KINDS
def _aabb_overlap(b1, b2, eps=0.05) -> bool:
(lo1, hi1), (lo2, hi2) = b1, b2
return all(min(hi1[i], hi2[i]) - max(lo1[i], lo2[i]) > eps for i in range(3))
def spatial_summary(scene) -> str:
"""A compact text description of where each board sits (bounding boxes) and
which boards interpenetrate fed to the AI so it can reason spatially."""
if not scene.parts:
return "empty"
boxes = {p.id: p.bbox() for p in scene.parts}
lines = ["Layout (inch bounding boxes, x=length-ish, z=up):"]
for p in scene.parts:
lo, hi = boxes[p.id]
label = f"{p.id}" + (f" ({p.name})" if p.name else "")
lines.append(f" {label}: x[{lo[0]:.1f},{hi[0]:.1f}] "
f"y[{lo[1]:.1f},{hi[1]:.1f}] z[{lo[2]:.1f},{hi[2]:.1f}]")
ids = list(boxes)
overlaps = [f"{ids[i]}&{ids[j]}" for i in range(len(ids)) for j in range(i + 1, len(ids))
if _aabb_overlap(boxes[ids[i]], boxes[ids[j]])]
if overlaps:
lines.append("Interpenetrating (usually unintended — boards should touch, not overlap): "
+ ", ".join(overlaps))
return "\n".join(lines)
@dataclass
class Part:
id: str
stock: str # canonical nominal name, e.g. "2x4"
length_in: float
section_in: tuple[float, float] # (thickness, width)
position_in: list[float] = field(default_factory=lambda: [0.0, 0.0, 0.0])
yaw_deg: float = 0.0 # heading about Z, in the XY plane
tilt_deg: float = 0.0 # elevation from horizontal toward +Z (90 = standing up)
roll_deg: float = 0.0 # rotation about the board's own length axis
name: str = "" # optional human alias, e.g. "front-left leg"
finishes: list[str] = field(default_factory=list)
features: list[Feature] = field(default_factory=list)
def local_frame(self) -> tuple[tuple, tuple, tuple]:
"""The board's (length, width, thickness) unit axes in world space.
Built by composing the same rotation geometry.py / viewer.py apply:
R = Rz(yaw) · Ry(-tilt) · Rx(roll), then taking R's columns (the images
of the local X=length, Y=width, Z=thickness axes).
"""
R = _matmul(_rot_z(self.yaw_deg),
_matmul(_rot_y(-self.tilt_deg), _rot_x(self.roll_deg)))
length = (R[0][0], R[1][0], R[2][0])
width = (R[0][1], R[1][1], R[2][1])
thick = (R[0][2], R[1][2], R[2][2])
return length, width, thick
def axis_unit(self) -> tuple[float, float, float]:
"""Unit vector along the board's length, from end_a toward end_b."""
return self.local_frame()[0]
def rotation_matrix(self) -> list[list[float]]:
"""3x3 world rotation (columns = length/width/thickness world axes)."""
cl, cw, ct = self.local_frame()
return [[cl[i], cw[i], ct[i]] for i in range(3)]
def bbox(self) -> tuple:
"""World axis-aligned bounding box (min_xyz, max_xyz) of the board."""
t, w = self.section_in
L = self.length_in
R = self.rotation_matrix()
corners = []
for x in (0.0, L):
for y in (-w / 2, w / 2):
for z in (-t / 2, t / 2):
loc = (x, y, z)
corners.append(tuple(self.position_in[i] + sum(R[i][j] * loc[j] for j in range(3))
for i in range(3)))
lo = tuple(min(c[i] for c in corners) for i in range(3))
hi = tuple(max(c[i] for c in corners) for i in range(3))
return lo, hi
def feature_world_frame(self, feat) -> tuple:
"""(contact point, outward normal, u, v) of a feature, in world space,
with the feature's own rotation about its normal applied to u/v."""
R = self.rotation_matrix()
t, w = self.section_in
o, n, u, v = face_frame(feat.face, self.length_in, w, t)
off_u = feat.along_in - (self.length_in / 2 if u == (1, 0, 0) else 0.0)
fp = tuple(o[i] + off_u * u[i] + feat.across_in * v[i] for i in range(3))
ur, vr = _rodrigues(u, n, feat.rotation_deg), _rodrigues(v, n, feat.rotation_deg)
def to_world(x):
return tuple(sum(R[i][j] * x[j] for j in range(3)) for i in range(3))
point = tuple(self.position_in[i] + to_world(fp)[i] for i in range(3))
return point, to_world(n), to_world(ur), to_world(vr)
@property
def is_vertical(self) -> bool:
return abs(self.tilt_deg) > 45
def end_point(self) -> list[float]:
"""The far end (end_b) of the board in world space."""
ux, uy, uz = self.axis_unit()
return [
self.position_in[0] + ux * self.length_in,
self.position_in[1] + uy * self.length_in,
self.position_in[2] + uz * self.length_in,
]
@dataclass
class Joint:
id: str
part_a: str
part_b: str
angle_deg: float = 90.0
offset_in: float = 0.0
anchor: str = "end_a" # measure offset from "end_a" (start) or "end_b" (far end)
@dataclass
class Connection:
"""A recorded mate between two features (anchor stays, moving board seats into
it). Tracking it lets us group, explode (back off), break, and re-fit."""
id: str
anchor: str # anchor feature id
moving: str # moving feature id (its board was repositioned)
backed_off_in: float = 0.0 # current explode offset along the joint axis
class SceneError(Exception):
"""Raised for invalid operations (bad references, unknown stock, ...)."""
@dataclass
class Scene:
version: int = SCENE_VERSION
units: str = "inch"
parts: list[Part] = field(default_factory=list)
joints: list[Joint] = field(default_factory=list)
connections: list[Connection] = field(default_factory=list)
selection: str | None = None
_next_part: int = 1
_next_joint: int = 1
_next_feat: int = 1
_next_conn: int = 1
_undo: list[str] = field(default_factory=list, repr=False)
_redo: list[str] = field(default_factory=list, repr=False)
# ----- lookup -------------------------------------------------------
def get_part(self, ref: str) -> Part:
for p in self.parts:
if p.id == ref or (p.name and p.name.lower() == ref.lower()):
return p
labels = [p.id + (f" ({p.name})" if p.name else "") for p in self.parts]
raise SceneError(f"No part {ref!r}. Parts: {labels or 'none'}")
def resolve(self, ref: str | None) -> Part:
"""Resolve a part reference (id, name, or 'it'), defaulting to selection."""
if ref in (None, "", "it", "selection", "current", "that", "this"):
if not self.selection:
raise SceneError("No part is selected; say which board.")
return self.get_part(self.selection)
return self.get_part(ref)
# ----- undo / redo --------------------------------------------------
def _checkpoint(self) -> None:
if getattr(self, "_suppress", False): # inside a batch — one undo covers it
return
self._undo.append(json.dumps(self._raw(), sort_keys=True))
del self._undo[:-50] # keep the last 50 steps
self._redo.clear() # a new action invalidates the redo history
@contextmanager
def batch(self):
"""Group several operations into a single undo step (e.g. moving a
multi-selection)."""
self._checkpoint()
self._suppress = True
try:
yield
finally:
self._suppress = False
def _restore(self, snapshot: dict) -> None:
restored = Scene.from_dict(snapshot)
restored._undo = self._undo
restored._redo = self._redo
self.__dict__.update(restored.__dict__)
def undo(self) -> str:
if not self._undo:
raise SceneError("Nothing to undo.")
self._redo.append(json.dumps(self._raw(), sort_keys=True))
self._restore(json.loads(self._undo.pop()))
return "Undid last operation."
def redo(self) -> str:
if not self._redo:
raise SceneError("Nothing to redo.")
self._undo.append(json.dumps(self._raw(), sort_keys=True))
self._restore(json.loads(self._redo.pop()))
return "Redid last operation."
def select(self, ref: str) -> Part:
"""Set the current selection (by id or name). Not undoable."""
part = self.get_part(ref)
self.selection = part.id
return part
# ----- operations ---------------------------------------------------
def place(self, stock: str, length_in: float, width_in: float | None = None) -> Part:
self._checkpoint()
stock = normalize_stock(stock)
if is_plywood(stock):
if not width_in:
raise SceneError("Plywood is sheet stock — give it a width too "
"(e.g. a 24 inch wide panel).")
section = (plywood_thickness(stock), float(width_in))
else:
section = actual_section(stock)
pid = f"p{self._next_part}"
self._next_part += 1
part = Part(id=pid, stock=stock, length_in=float(length_in), section_in=section)
self.parts.append(part)
self.selection = pid
return part
def finish(self, ref: str | None, kind: str = "sanded") -> Part:
self._checkpoint()
part = self.resolve(ref)
if kind not in part.finishes:
part.finishes.append(kind)
self.selection = part.id
return part
def join(
self,
part_a: str | None,
part_b: str,
angle_deg: float = 90.0,
offset_in: float = 0.0,
anchor: str = "end_a",
) -> Joint:
self._checkpoint()
a = self.resolve(part_a)
b = self.get_part(part_b)
# Attach point: distance measured along A's axis from the chosen end.
along = offset_in if anchor == "end_a" else max(a.length_in - offset_in, 0.0)
a_len, a_width, a_thick = a.local_frame()
anchor_pt = [a.position_in[i] + a_len[i] * along for i in range(3)]
# B inherits A's heading plus the requested angle, but keeps its own
# tilt/roll (so a board you stood up stays standing when attached).
b.yaw_deg = a.yaw_deg + angle_deg
b_len = b.axis_unit() # direction B extends, away from the joint
# Real butt joint: B's end sits flush on A's SURFACE, not on A's
# centerline. Push out from the centerline to A's face along B's
# direction, by A's cross-section half-extent in that direction
# (width/thickness only — B butts the side, not the end).
a_half_w, a_half_t = a.section_in[1] / 2, a.section_in[0] / 2
surface = (a_half_w * abs(_dot(b_len, a_width))
+ a_half_t * abs(_dot(b_len, a_thick)))
b.position_in = [anchor_pt[i] + b_len[i] * surface for i in range(3)]
# Flush-by-default: align B to A's reference corner — its faces line up
# with A's top and one side, rather than B floating centered on A. For
# each of A's cross-section axes (skipping the one B extends along, so
# the butt contact is preserved) snap B's +face onto A's +face.
b_l, b_w, b_t = b.local_frame()
def b_half_extent(axis):
return (b.length_in / 2 * abs(_dot(b_l, axis))
+ b.section_in[1] / 2 * abs(_dot(b_w, axis))
+ b.section_in[0] / 2 * abs(_dot(b_t, axis)))
for axis, a_half in ((a_thick, a_half_t), (a_width, a_half_w)):
if abs(_dot(b_len, axis)) > 0.9: # B runs along this axis — leave it
continue
a_face = _dot(a.position_in, axis) + a_half # A's far face
b_face = _dot(b.position_in, axis) + b_half_extent(axis)
delta = a_face - b_face
b.position_in = [b.position_in[i] + delta * axis[i] for i in range(3)]
jid = f"j{self._next_joint}"
self._next_joint += 1
joint = Joint(id=jid, part_a=a.id, part_b=b.id,
angle_deg=float(angle_deg), offset_in=float(offset_in), anchor=anchor)
self.joints.append(joint)
self.selection = b.id
return joint
def stand(self, ref: str | None, tilt_deg: float = 90.0) -> Part:
"""Tilt a board up toward vertical (90 = standing straight up)."""
self._checkpoint()
part = self.resolve(ref)
part.tilt_deg = float(tilt_deg)
self.selection = part.id
return part
def orient(self, ref: str | None, yaw: float | None = None,
tilt: float | None = None, roll: float | None = None) -> Part:
"""Set any of the board's orientation angles (degrees)."""
self._checkpoint()
part = self.resolve(ref)
if yaw is not None:
part.yaw_deg = float(yaw)
if tilt is not None:
part.tilt_deg = float(tilt)
if roll is not None:
part.roll_deg = float(roll)
self.selection = part.id
return part
def move(self, ref: str | None, dx: float = 0.0, dy: float = 0.0, dz: float = 0.0,
absolute: bool = False) -> Part:
"""Translate a board by (dx, dy, dz), or set its position if absolute."""
self._checkpoint()
part = self.resolve(ref)
if absolute:
part.position_in = [float(dx), float(dy), float(dz)]
else:
part.position_in = [part.position_in[0] + dx,
part.position_in[1] + dy,
part.position_in[2] + dz]
self.selection = part.id
return part
def set_length(self, ref: str | None, length_in: float) -> Part:
"""Cut a board to a new length."""
self._checkpoint()
part = self.resolve(ref)
part.length_in = float(length_in)
self.selection = part.id
return part
def copy(self, ref: str | None, dx: float = 0.0, dy: float = 0.0, dz: float = 0.0) -> Part:
"""Duplicate a board, offset by (dx, dy, dz)."""
self._checkpoint()
src = self.resolve(ref)
pid = f"p{self._next_part}"
self._next_part += 1
clone = Part(id=pid, stock=src.stock, length_in=src.length_in,
section_in=src.section_in,
position_in=[src.position_in[0] + dx, src.position_in[1] + dy,
src.position_in[2] + dz],
yaw_deg=src.yaw_deg, tilt_deg=src.tilt_deg, roll_deg=src.roll_deg,
finishes=list(src.finishes))
self.parts.append(clone)
self.selection = pid
return clone
def rename(self, ref: str | None, name: str) -> Part:
"""Give a board a human-friendly alias, e.g. 'front-left leg'."""
self._checkpoint()
part = self.resolve(ref)
part.name = name.strip()
self.selection = part.id
return part
def clear(self) -> str:
self._checkpoint()
self.parts = []
self.joints = []
self.connections = []
self.selection = None
self._next_part = 1
self._next_joint = 1
self._next_feat = 1
self._next_conn = 1
return "Cleared the scene."
# ----- joinery features --------------------------------------------
def add_feature(self, ref: str | None, kind: str, face: str = "end_b",
**dims) -> Feature:
kind = kind.lower().strip()
if kind not in FEATURE_KINDS:
raise SceneError(f"Unknown feature {kind!r}. Known: {', '.join(sorted(FEATURE_KINDS))}")
if face not in FACES:
raise SceneError(f"Unknown face {face!r}. Faces: {', '.join(FACES)}")
self._checkpoint()
part = self.resolve(ref)
fid = f"f{self._next_feat}"
self._next_feat += 1
allowed = {"along_in", "across_in", "width_in", "height_in", "depth_in",
"diameter_in", "rotation_deg"}
feat = Feature(id=fid, kind=kind, face=face,
**{k: float(v) for k, v in dims.items() if k in allowed and v is not None})
part.features.append(feat)
self.selection = part.id
return feat
def find_feature(self, fid: str) -> tuple[Part, Feature]:
for p in self.parts:
for f in p.features:
if f.id == fid:
return p, f
raise SceneError(f"No feature {fid!r}.")
def edit_feature(self, fid: str, **dims) -> Feature:
self._checkpoint()
part, feat = self.find_feature(fid)
for k, v in dims.items():
if v is None:
continue
if k == "face":
feat.face = v
elif hasattr(feat, k):
setattr(feat, k, float(v))
self.selection = part.id
return feat
def delete_feature(self, fid: str) -> str:
self._checkpoint()
part, feat = self.find_feature(fid)
part.features.remove(feat)
self.selection = part.id
return f"Deleted feature {fid} from {part.id}."
def feature_owner(self, fid: str) -> Part:
return self.find_feature(fid)[0]
def _seat(self, anchor_fid: str, moving_fid: str):
"""Position/orient the moving board so its feature mates with the anchor.
Returns (anchor_part, moving_part, anchor_normal). No checkpoint/record."""
anchor_part, anchor_feat = self.find_feature(anchor_fid)
moving_part, moving_feat = self.find_feature(moving_fid)
if anchor_part is moving_part:
raise SceneError("Connect features on two different boards.")
pa, na, ua, va = anchor_part.feature_world_frame(anchor_feat)
# moving feature's LOCAL frame (in its own board), with its rotation.
t, w = moving_part.section_in
o, n, u, v = face_frame(moving_feat.face, moving_part.length_in, w, t)
off_u = moving_feat.along_in - (moving_part.length_in / 2 if u == (1, 0, 0) else 0.0)
fp_l = tuple(o[i] + off_u * u[i] + moving_feat.across_in * v[i] for i in range(3))
n_l = n
u_l, v_l = _rodrigues(u, n, moving_feat.rotation_deg), _rodrigues(v, n, moving_feat.rotation_deg)
# Desired world axes for the moving feature: insertion opposite the
# anchor's outward normal; cross-axes aligned (v flips to stay right-handed).
dN = tuple(-x for x in na)
dU, dV = ua, tuple(-x for x in va)
# R such that R·n_l = dN, R·u_l = dU, R·v_l = dV (R = [dN|dU|dV]·[n_l|u_l|v_l]^T)
cols_d, cols_f = (dN, dU, dV), (n_l, u_l, v_l)
R = [[sum(cols_d[k][i] * cols_f[k][j] for k in range(3)) for j in range(3)]
for i in range(3)]
moving_part.yaw_deg, moving_part.tilt_deg, moving_part.roll_deg = matrix_to_ypr(R)
moved_fp = tuple(sum(R[i][j] * fp_l[j] for j in range(3)) for i in range(3))
moving_part.position_in = [pa[i] - moved_fp[i] for i in range(3)]
return anchor_part, moving_part, na
def _group_of(self, pid: str) -> set:
for g in self.groups():
if pid in g:
return set(g)
return {pid}
def _drag_group(self, lead: Part, old_R, old_p, member_ids) -> None:
"""Apply the rigid move that `lead` just underwent to the other members of
its assembly, so connected boards travel together."""
new_R, new_p = lead.rotation_matrix(), lead.position_in
Rd = _matmul(new_R, _transpose(old_R)) # delta rotation
for pid in member_ids:
if pid == lead.id:
continue
q = self.get_part(pid)
q.yaw_deg, q.tilt_deg, q.roll_deg = matrix_to_ypr(_matmul(Rd, q.rotation_matrix()))
rel = [q.position_in[i] - old_p[i] for i in range(3)]
moved = _mv(Rd, rel)
q.position_in = [new_p[i] + moved[i] for i in range(3)]
def connect(self, anchor_fid: str, moving_fid: str) -> str:
"""Seat the moving board into the anchor and record the connection. If the
moving board is already part of an assembly, that whole sub-assembly moves
with it (rigidly)."""
self._checkpoint()
moving_part = self.feature_owner(moving_fid)
anchor_part = self.feature_owner(anchor_fid)
# boards rigidly attached to the moving one (minus the anchor's group)
move_with = self._group_of(moving_part.id) - self._group_of(anchor_part.id)
old_R, old_p = moving_part.rotation_matrix(), list(moving_part.position_in)
anchor_part, moving_part, _ = self._seat(anchor_fid, moving_fid)
self._drag_group(moving_part, old_R, old_p, move_with)
existing = next((c for c in self.connections
if c.anchor == anchor_fid and c.moving == moving_fid), None)
if existing:
existing.backed_off_in = 0.0
else:
self.connections.append(Connection(id=f"c{self._next_conn}",
anchor=anchor_fid, moving=moving_fid))
self._next_conn += 1
self.selection = moving_part.id
return f"Connected {moving_part.id} to {anchor_part.id}."
def _conn_valid(self, c: Connection) -> bool:
try:
self.find_feature(c.anchor)
self.find_feature(c.moving)
return True
except SceneError:
return False
def assemble(self) -> str:
"""Re-fit every connection (seat the moving boards back into place)."""
self._checkpoint()
n = 0
for c in self.connections:
if self._conn_valid(c):
self._seat(c.anchor, c.moving)
c.backed_off_in = 0.0
n += 1
return f"Re-fitted {n} connection(s)."
def explode(self, distance: float) -> str:
"""Back off each moving board along its joint axis (exploded view)."""
self._checkpoint()
n = 0
for c in self.connections:
if not self._conn_valid(c):
continue
_, mp, na = self._seat(c.anchor, c.moving)
mp.position_in = [mp.position_in[i] + na[i] * distance for i in range(3)]
c.backed_off_in = distance
n += 1
return f"Backed off {n} connection(s) by {distance:g} in."
def disconnect(self, cid: str | None = None, part: str | None = None) -> str:
"""Break connection(s): pieces stay in place but become independent."""
self._checkpoint()
before = len(self.connections)
if cid:
self.connections = [c for c in self.connections if c.id != cid]
elif part is not None:
pid = self.resolve(part).id
self.connections = [c for c in self.connections
if not (self._conn_valid(c)
and pid in (self.feature_owner(c.anchor).id,
self.feature_owner(c.moving).id))]
else:
self.connections = []
return f"Broke {before - len(self.connections)} connection(s)."
def groups(self) -> list[list[str]]:
"""Connected-component part groups (assemblies) via the connection graph."""
parent = {p.id: p.id for p in self.parts}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for c in self.connections:
if self._conn_valid(c):
parent[find(self.feature_owner(c.anchor).id)] = find(self.feature_owner(c.moving).id)
groups: dict[str, list[str]] = {}
for p in self.parts:
groups.setdefault(find(p.id), []).append(p.id)
return list(groups.values())
def delete(self, ref: str | None) -> str:
self._checkpoint()
part = self.resolve(ref)
dead_features = {f.id for f in part.features}
self.parts = [p for p in self.parts if p.id != part.id]
self.joints = [j for j in self.joints
if part.id not in (j.part_a, j.part_b)]
self.connections = [c for c in self.connections
if not (dead_features & {c.anchor, c.moving})]
if self.selection == part.id:
self.selection = self.parts[-1].id if self.parts else None
return f"Deleted {part.id}."
# ----- persistence --------------------------------------------------
def _raw(self) -> dict:
d = asdict(self)
d.pop("_undo", None)
d.pop("_redo", None)
return d
def to_dict(self) -> dict:
d = asdict(self)
return d
@classmethod
def from_dict(cls, data: dict) -> "Scene":
parts = []
valid = {f.name for f in fields(Part)}
feat_fields = {f.name for f in fields(Feature)}
for p in data.get("parts", []):
p = dict(p)
if "rotation_deg" in p and "yaw_deg" not in p: # migrate old scenes
p["yaw_deg"] = p.pop("rotation_deg")
p["section_in"] = tuple(p["section_in"])
p["features"] = [Feature(**{k: v for k, v in f.items() if k in feat_fields})
for f in p.get("features", [])]
parts.append(Part(**{k: v for k, v in p.items() if k in valid}))
joints = [Joint(**j) for j in data.get("joints", [])]
conn_fields = {f.name for f in fields(Connection)}
connections = [Connection(**{k: v for k, v in c.items() if k in conn_fields})
for c in data.get("connections", [])]
return cls(
version=data.get("version", SCENE_VERSION),
units=data.get("units", "inch"),
parts=parts,
joints=joints,
connections=connections,
selection=data.get("selection"),
_next_part=data.get("_next_part", len(parts) + 1),
_next_joint=data.get("_next_joint", len(joints) + 1),
_next_feat=data.get("_next_feat", 1),
_next_conn=data.get("_next_conn", len(connections) + 1),
_undo=data.get("_undo", []),
_redo=data.get("_redo", []),
)
def save(self, path: Path | None = None) -> Path:
path = Path(path) if path else default_scene_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(self.to_dict(), indent=2))
tmp.replace(path) # atomic so the viewport never reads a half-written file
return path
@classmethod
def load(cls, path: Path | None = None) -> "Scene":
path = Path(path) if path else default_scene_path()
if not path.exists():
return cls()
return cls.from_dict(json.loads(path.read_text()))

44
src/woodshop/units.py Normal file
View File

@ -0,0 +1,44 @@
"""Parse spoken/typed lengths into inches.
The AI interpreter is expected to pass reasonably structured values, but people
say "6 foot", "2'", '10 inches', '3 ft 6 in', or bare numbers. Everything is
normalized to inches (the scene's internal unit).
"""
from __future__ import annotations
import re
_FEET = r"(?:feet|foot|ft|')"
_INCH = r"(?:inches|inch|in|\")"
# e.g. "3 ft 6 in", "6 foot", "10 inches", "2'", "72", "-5"
_COMBINED = re.compile(
rf"^\s*(?P<sign>-)?\s*(?:(?P<ft>[\d.]+)\s*{_FEET})?\s*(?:(?P<inch>[\d.]+)\s*{_INCH})?\s*$",
re.IGNORECASE,
)
_BARE = re.compile(r"^\s*(?P<n>-?[\d.]+)\s*$")
def to_inches(value: str | float | int, default_unit: str = "inch") -> float:
"""Convert a length expression to inches.
Bare numbers use ``default_unit`` ('inch' or 'foot'). Raises ValueError on
anything unparseable.
"""
if isinstance(value, (int, float)):
return float(value) * (12.0 if default_unit.startswith("f") else 1.0)
text = str(value).strip()
bare = _BARE.match(text)
if bare:
n = float(bare.group("n"))
return n * (12.0 if default_unit.startswith("f") else 1.0)
m = _COMBINED.match(text)
if m and (m.group("ft") or m.group("inch")):
ft = float(m.group("ft") or 0)
inch = float(m.group("inch") or 0)
total = ft * 12.0 + inch
return -total if m.group("sign") else total
raise ValueError(f"Could not parse length: {value!r}")

240
src/woodshop/viewer.py Normal file
View File

@ -0,0 +1,240 @@
"""Live 3D viewport: watches scene.json and re-renders on every change.
Run it alongside the voice driver:
woodshop-view # or: python -m woodshop.viewer
It polls the scene file's mtime and rebuilds the view whenever an operation
tool writes a change, so saying "place a 6 foot 2x4" makes a board appear. Uses
lightweight pyvista boxes for instant updates (build123d/geometry.py is used for
accurate export, not for the live view).
"""
from __future__ import annotations
import argparse
import time
from pathlib import Path
from .scene import Part, Scene, default_scene_path
# Distinct colors so adjacent boards read as separate pieces.
_PALETTE = ["#c8965a", "#a9744f", "#d6b27c", "#8d5524", "#e0c097", "#b5651d"]
def _featured_mesh(part: Part):
"""Tessellate the true build123d solid (with joinery booleans) for display."""
import pyvista as pv
from .geometry import part_solid
verts, tris = part_solid(part).tessellate(0.02)
points = [(v.X, v.Y, v.Z) for v in verts]
faces = []
for tri in tris:
faces += [3, tri[0], tri[1], tri[2]]
return pv.PolyData(points, faces)
def _part_mesh(part: Part):
import pyvista as pv
if part.features: # show real joinery (slower; only featured boards)
try:
return _featured_mesh(part)
except Exception:
pass # fall back to the plain box if booleans fail
length = part.length_in
thickness, width = part.section_in
cube = pv.Cube(center=(length / 2, 0, 0),
x_length=length, y_length=width, z_length=thickness)
cube.rotate_x(part.roll_deg, point=(0, 0, 0), inplace=True)
cube.rotate_y(-part.tilt_deg, point=(0, 0, 0), inplace=True)
cube.rotate_z(part.yaw_deg, point=(0, 0, 0), inplace=True)
cube.translate(part.position_in, inplace=True)
return cube
def _axis_extent(axis, L, w, t):
if axis == (1, 0, 0):
return L
if axis in ((0, 1, 0), (0, -1, 0)):
return w
return t
def feature_preview_mesh(part, feat):
"""A cheap pyvista box/cylinder showing a feature's footprint (no build123d),
for the live red preview while adjusting fields."""
import pyvista as pv
from .geometry import _face_frame
L = part.length_in
t, w = part.section_in
o, n, u, v = _face_frame(feat.face, L, w, t)
off_u = feat.along_in - (L / 2 if u == (1, 0, 0) else 0.0)
fp = tuple(o[i] + off_u * u[i] + feat.across_in * v[i] for i in range(3))
if feat.kind == "hole":
thru = abs(n[0]) * L + abs(n[1]) * w + abs(n[2]) * t + 0.1
h = feat.depth_in if feat.depth_in > 0 else thru
c = tuple(fp[i] - n[i] * h / 2 for i in range(3))
mesh = pv.Cylinder(center=c, direction=n, radius=feat.diameter_in / 2, height=h)
elif feat.kind == "chamfer": # can't cheaply preview the bevel — highlight the face
ue, ve, thin = _axis_extent(u, L, w, t), _axis_extent(v, L, w, t), 0.08
dims = tuple(ue * abs(u[i]) + ve * abs(v[i]) + thin * abs(n[i]) for i in range(3))
c = fp
mesh = pv.Box(bounds=(c[0] - dims[0] / 2, c[0] + dims[0] / 2,
c[1] - dims[1] / 2, c[1] + dims[1] / 2,
c[2] - dims[2] / 2, c[2] + dims[2] / 2))
else: # tenon (out) / mortise/slot/dado/rabbet (in)
d = feat.depth_in
dims = tuple(feat.width_in * abs(u[i]) + feat.height_in * abs(v[i]) + d * abs(n[i])
for i in range(3))
sign = 1 if feat.kind == "tenon" else -1
c = tuple(fp[i] + sign * n[i] * d / 2 for i in range(3))
mesh = pv.Box(bounds=(c[0] - dims[0] / 2, c[0] + dims[0] / 2,
c[1] - dims[1] / 2, c[1] + dims[1] / 2,
c[2] - dims[2] / 2, c[2] + dims[2] / 2))
if feat.rotation_deg and feat.kind not in ("hole", "chamfer"):
mesh.rotate_vector(n, feat.rotation_deg, point=fp, inplace=True)
mesh.rotate_x(part.roll_deg, point=(0, 0, 0), inplace=True)
mesh.rotate_y(-part.tilt_deg, point=(0, 0, 0), inplace=True)
mesh.rotate_z(part.yaw_deg, point=(0, 0, 0), inplace=True)
mesh.translate(part.position_in, inplace=True)
return mesh
def _add_feature_edges(plotter, mesh, selected: bool) -> None:
"""Overlay a tessellated solid's real edges (corners/holes/chamfers) so it
reads as crisply as a plain board, without the triangle-mesh noise."""
try:
edges = mesh.extract_feature_edges(
feature_angle=20, boundary_edges=True, feature_edges=True,
manifold_edges=False, non_manifold_edges=False)
if edges.n_points:
plotter.add_mesh(edges, color="yellow" if selected else "black",
line_width=3 if selected else 2, reset_camera=False)
except Exception:
pass
def _quiet_vtk() -> None:
"""Stop VTK from spamming warnings (esp. headless) through Python logging."""
try:
import vtk
vtk.vtkObject.GlobalWarningDisplayOff()
except Exception:
pass
def _render(plotter, scene: Scene) -> None:
import pyvista as pv
plotter.clear()
plotter.clear_actors()
labels, label_pts = [], []
for i, part in enumerate(scene.parts):
edge = part.id == scene.selection
mesh = _part_mesh(part)
plotter.add_mesh(
mesh,
color="#f5d76e" if edge else _PALETTE[i % len(_PALETTE)],
show_edges=not part.features, # plain boxes: real quad edges
line_width=3 if edge else 1,
edge_color="black",
smooth_shading=False,
)
if part.features: # tessellated: overlay only the true edges
_add_feature_edges(plotter, mesh, edge)
mid = [part.position_in[j] + part.axis_unit()[j] * part.length_in / 2 for j in range(3)]
labels.append(part.name or part.id)
label_pts.append(mid)
n = len(scene.parts)
if label_pts:
plotter.add_point_labels(
label_pts, labels, font_size=12, text_color="white",
shape_color="#222222", shape_opacity=0.5, point_size=1,
name="labels", always_visible=True,
)
plotter.show_grid(color="#555555", xtitle="X (in)", ytitle="Y (in)", ztitle="Z (in)")
plotter.add_text(f"WoodShop — {n} part(s) | selection: {scene.selection or '-'}",
font_size=11, color="white", name="hud")
plotter.add_axes()
def render_to_file(scene: Scene, path, window_size=(1100, 800)) -> str:
"""Render the scene to a PNG (off-screen) — works headless / over SSH."""
import pyvista as pv
_quiet_vtk()
pv.OFF_SCREEN = True
plotter = pv.Plotter(off_screen=True, window_size=window_size)
plotter.set_background("#2b2b2b")
plotter.enable_parallel_projection()
_render(plotter, scene)
plotter.view_isometric()
plotter.screenshot(str(path))
plotter.close()
return str(path)
def run(scene_path: Path | None = None, poll_s: float = 0.3) -> None:
import pyvista as pv
_quiet_vtk()
scene_path = Path(scene_path) if scene_path else default_scene_path()
plotter = pv.Plotter(title="WoodShop")
plotter.set_background("#2b2b2b")
plotter.enable_parallel_projection()
# Let closing the window (X button) or pressing q/Escape end the loop.
closed = {"flag": False}
def _on_close():
closed["flag"] = True
for key in ("q", "Escape"):
try:
plotter.add_key_event(key, _on_close)
except Exception:
pass
last_mtime = -1.0
scene = Scene.load(scene_path) if scene_path.exists() else Scene()
_render(plotter, scene)
plotter.view_isometric()
plotter.show(interactive_update=True, auto_close=False)
while True:
# Stop if the render window has been closed by any means.
if closed["flag"] or getattr(plotter, "_closed", False) or plotter.render_window is None:
break
try:
mtime = scene_path.stat().st_mtime if scene_path.exists() else 0.0
if mtime != last_mtime:
last_mtime = mtime
scene = Scene.load(scene_path) if scene_path.exists() else Scene()
_render(plotter, scene)
plotter.update()
time.sleep(poll_s)
except KeyboardInterrupt:
break
except Exception: # window destroyed mid-update
break
try:
plotter.close()
except Exception:
pass
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="woodshop-view", description="Live WoodShop 3D viewport.")
ap.add_argument("--scene", help="Path to scene.json")
args = ap.parse_args(argv)
run(args.scene)
return 0
if __name__ == "__main__":
raise SystemExit(main())

70
tests/test_bom_window.py Normal file
View File

@ -0,0 +1,70 @@
"""Offscreen tests for the BOM window's drag/drop path (no display needed —
QGraphicsScene is pure Qt). Guards the placement-id vs cut-item-id crash."""
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6")
from PySide6.QtWidgets import QApplication # noqa: E402
from woodshop.cutplan import find_placement # noqa: E402
from woodshop.gui.bom_window import BomWindow, _Piece # noqa: E402
from woodshop.gui.controller import Controller # noqa: E402
_app = QApplication.instance() or QApplication([])
def _pieces(w):
return sorted((it for it in w.scene.items() if isinstance(it, _Piece)),
key=lambda it: it.pos().x())
def test_drop_overlap_reverts_without_crashing(tmp_path):
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 30)
c.place("2x4", 30) # one stick, two pieces
w = BomWindow(c)
first, second = _pieces(w)[:2]
home = (second.sp_id, second.pos().x(), second.pos().y())
second.setPos(0, second.pos().y()) # drop on top of the first -> overlap
w._drop_piece(second, home) # must not raise (was StopIteration)
assert "revert" in w._status.text().lower()
def test_drop_onto_incompatible_stock_reverts(tmp_path):
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 24)
c.place("ply-3/4", 24, width_in=24)
w = BomWindow(c)
lumber = next(it for it in _pieces(w)
if not find_placement(w._plan, it.pid)[0].is_sheet)
sheet_y = next(y0 for y0, _y1, sp in w._rows if sp.is_sheet)
home = (lumber.sp_id, lumber.pos().x(), lumber.pos().y())
lumber.setPos(10, sheet_y + 5) # drag the 2x4 onto the plywood sheet
w._drop_piece(lumber, home) # must not raise
assert "can't go" in w._status.text()
def test_best_of_n_button_keeps_valid_plan(tmp_path):
from woodshop.cutplan import validate_cut_plan
c = Controller(str(tmp_path / "s.json"))
for ln in (50, 46, 40, 30):
c.place("2x4", ln)
w = BomWindow(c)
w._best_of_n() # no locks -> best of 100
assert validate_cut_plan(w._plan) == []
assert "best" in w._status.text().lower()
def test_valid_move_commits(tmp_path):
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 20)
c.place("2x4", 20)
w = BomWindow(c)
second = _pieces(w)[1]
home = (second.sp_id, second.pos().x(), second.pos().y())
second.setPos(50 * w._px, second.pos().y()) # slide it right, still clear
w._drop_piece(second, home)
assert "placed" in w._status.text().lower()

68
tests/test_cutlist.py Normal file
View File

@ -0,0 +1,68 @@
"""Tests for the cut list / board-feet / shopping estimate."""
import pytest
from woodshop.cutlist import board_feet, cut_rows, nominal_dims, shopping
from woodshop.scene import Scene
def test_nominal_dims():
assert nominal_dims("2x4") == (2.0, 4.0)
assert nominal_dims("4x4") == (4.0, 4.0)
def test_board_feet_uses_nominal():
# 2x4 at 96in = (2*4*96)/144 = 5.333 bd-ft
assert board_feet("2x4", 96) == pytest.approx(5.3333, abs=1e-3)
def test_cut_rows_groups_and_counts():
s = Scene()
s.place("2x4", 48)
s.place("2x4", 48)
s.place("2x4", 29)
rows = cut_rows(s)
by_len = {r["length_in"]: r for r in rows}
assert by_len[48.0]["count"] == 2
assert by_len[29.0]["count"] == 1
assert by_len[48.0]["board_feet"] == pytest.approx(board_feet("2x4", 48) * 2)
def test_shopping_rounds_up_with_waste():
s = Scene()
s.place("2x4", 48)
s.place("2x4", 48) # 96in total -> with 10% waste = 105.6 -> 2 sticks of 96in
assert shopping(s) == {"2x4": 2}
def test_empty_shopping():
assert shopping(Scene()) == {}
def test_end_tenon_extends_cut_length():
from woodshop.cutlist import cut_length
s = Scene()
s.place("2x4", 24)
assert cut_length(s.get_part("p1")) == 24
s.add_feature("p1", "tenon", face="end_b", depth_in=1.5) # protrudes 1.5"
assert cut_length(s.get_part("p1")) == 25.5
# the cut row and board-feet reflect the longer piece
assert cut_rows(s)[0]["length_in"] == 25.5
assert cut_rows(s)[0]["board_feet"] == pytest.approx(board_feet("2x4", 25.5))
def test_plywood_uses_sqft_and_sheets():
from woodshop.cutlist import cut_rows, shopping, format_cutlist
s = Scene()
s.place("ply-3/4", 48, width_in=24) # 48 × 24 = 8 sq ft
row = cut_rows(s)[0]
assert row["plywood"] and row["sq_ft"] == pytest.approx(8.0)
assert shopping(s)["ply-3/4"] == 1 # well under a 32 sq-ft sheet
assert "sq ft" in format_cutlist(s) and "sheet" in format_cutlist(s)
def test_cut_feature_does_not_change_cut_length():
from woodshop.cutlist import cut_length
s = Scene()
s.place("2x4", 24)
s.add_feature("p1", "mortise", face="top", width_in=1, height_in=1, depth_in=0.5)
assert cut_length(s.get_part("p1")) == 24 # cuts don't reduce stock you buy

267
tests/test_cutplan.py Normal file
View File

@ -0,0 +1,267 @@
"""Phase 0 tests for the CutPlan model."""
import json
from woodshop.cutplan import CutPlan, ShopSettings, build_cut_plan, validate_cut_plan
from woodshop.scene import Scene
def test_lumber_plan_packs_and_validates():
s = Scene()
for _ in range(3):
s.place("2x4", 40)
plan = build_cut_plan(s)
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
assert len(sticks) == 2
assert sum(len(sp.placements) for sp in sticks) == 3
assert plan.score["stock_count"] == 2
assert validate_cut_plan(plan) == []
def test_kerf_prevents_two_48_in_one_stick():
s = Scene()
s.place("2x4", 48)
s.place("2x4", 48)
assert build_cut_plan(s).score["stock_count"] == 2
def test_tenon_extends_cut_item_length():
s = Scene()
s.place("2x4", 24)
s.add_feature("p1", "tenon", face="end_b", depth_in=2)
item = build_cut_plan(s).items[0]
assert item.length_in == 26 and "tenon" in item.note
def test_plywood_plan_and_validate():
s = Scene()
s.place("ply-3/4", 40, width_in=20)
s.place("ply-3/4", 40, width_in=20)
plan = build_cut_plan(s)
sheets = [sp for sp in plan.stock_pieces if sp.is_sheet]
assert len(sheets) == 1 and len(sheets[0].placements) == 2
assert validate_cut_plan(plan) == []
def test_oversize_lumber_warns_and_is_unplaced():
s = Scene()
s.place("2x4", 120) # longer than a 96" stick
plan = build_cut_plan(s)
assert plan.unplaced and plan.warnings
assert validate_cut_plan(plan) == [] # flagged, so still valid
def test_stable_ids_present():
s = Scene()
s.place("2x4", 40)
plan = build_cut_plan(s)
assert all(it.id for it in plan.items)
assert all(sp.id for sp in plan.stock_pieces)
assert all(p.id for sp in plan.stock_pieces for p in sp.placements)
def test_json_roundtrip():
s = Scene()
s.place("2x4", 40)
s.place("ply-3/4", 40, width_in=20)
plan = build_cut_plan(s)
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
assert plan2.settings.kerf_in == plan.settings.kerf_in
assert [sp.id for sp in plan2.stock_pieces] == [sp.id for sp in plan.stock_pieces]
assert plan2.score["stock_count"] == plan.score["stock_count"]
assert validate_cut_plan(plan2) == []
def test_plywood_rotation_fits_panel():
s = Scene()
s.place("ply-3/4", 30, width_in=60) # 60" wide > 48" sheet — needs rotating
plan = build_cut_plan(s) # rotation allowed by default
sheets = [sp for sp in plan.stock_pieces if sp.is_sheet]
assert len(sheets) == 1
p = sheets[0].placements[0]
assert p.rotated and p.len_in == 60 and p.wid_in == 30
assert validate_cut_plan(plan) == []
def test_rotation_disabled_flags_unfit():
s = Scene()
s.place("ply-3/4", 30, width_in=60)
plan = build_cut_plan(s, settings=ShopSettings(allow_plywood_rotation=False))
assert plan.unplaced and plan.warnings
def test_best_cut_plan_is_no_worse():
from woodshop.cutplan import _plan_key, best_cut_plan
s = Scene()
for ln in (50, 46, 30, 30, 20):
s.place("2x4", ln)
best = best_cut_plan(s)
base = build_cut_plan(s, strategy="decreasing")
assert _plan_key(best) <= _plan_key(base)
assert best.strategy == "optimized"
assert validate_cut_plan(best) == []
def test_snap_and_fits():
from woodshop.cutplan import placement_fits, snap_x
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30) # both fit one stick
plan = build_cut_plan(s)
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
p1, p2 = stick.placements[0], stick.placements[1]
k = plan.settings.kerf_in
assert abs(snap_x(stick, p2, 31.0, k) - (p1.x_in + p1.len_in + k)) < 1e-6
p2.x_in = 0.0
assert not placement_fits(stick, p2, k) # now overlaps p1
p2.x_in = p1.len_in + k
assert placement_fits(stick, p2, k) # butted clear
def test_relocate_between_sticks():
from woodshop.cutplan import relocate
s = Scene()
for _ in range(3):
s.place("2x4", 60) # each needs its own stick
plan = build_cut_plan(s)
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
assert len(sticks) == 3
pid = sticks[2].placements[0].id
relocate(plan, pid, sticks[0].id, 0.0)
assert any(p.id == pid for p in sticks[0].placements)
assert all(p.id != pid for p in sticks[2].placements)
def test_rotate_placement_swaps_footprint():
from woodshop.cutplan import rotate_placement
s = Scene()
s.place("ply-3/4", 40, width_in=20)
plan = build_cut_plan(s)
p = next(sp for sp in plan.stock_pieces if sp.is_sheet).placements[0]
L, W, rot = p.len_in, p.wid_in, p.rotated
rotate_placement(plan, p.id)
assert p.len_in == W and p.wid_in == L and p.rotated != rot
def test_kerf_gap_required_not_just_overlap():
from woodshop.cutplan import placement_fits
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30)
plan = build_cut_plan(s)
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
p1, p2 = stick.placements
k = plan.settings.kerf_in
p2.x_in = p1.len_in + 0.01 # closer than a kerf
assert not placement_fits(stick, p2, k)
p2.x_in = p1.len_in + k # exactly a kerf apart
assert placement_fits(stick, p2, k)
def test_validate_flags_wrong_stock_and_illegal_rotation():
from woodshop.cutplan import relocate, rotate_placement
s = Scene()
s.place("2x4", 24)
s.place("ply-3/4", 24, width_in=24)
plan = build_cut_plan(s)
lumber = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
sheet = next(sp for sp in plan.stock_pieces if sp.is_sheet)
relocate(plan, lumber.placements[0].id, sheet.id, 0.0, 0.0)
assert any("stock piece" in p for p in validate_cut_plan(plan))
plan2 = build_cut_plan(s, settings=ShopSettings(allow_plywood_rotation=False))
sh2 = next(sp for sp in plan2.stock_pieces if sp.is_sheet)
rotate_placement(plan2, sh2.placements[0].id)
assert any("rotation" in p for p in validate_cut_plan(plan2))
def test_recompute_updates_waste_after_move():
from woodshop.cutplan import recompute
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30)
plan = build_cut_plan(s)
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
stick.placements[1].x_in = 60.0 # leave a gap after p1
recompute(plan)
assert any(abs(w.x_in - 30) < 1.0 for w in stick.waste) # gap at ~30 now waste
def test_stable_hash_is_deterministic():
from woodshop.cutplan import _stable_hash
assert _stable_hash("ci1x") == _stable_hash("ci1x")
def test_reoptimize_preserves_locked_placement():
from woodshop.cutplan import reoptimize
s = Scene()
for ln in (40, 40, 40):
s.place("2x4", ln)
plan = build_cut_plan(s)
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
locked = sticks[-1].placements[0]
locked.locked = True
lx, lid = locked.x_in, locked.id
re = reoptimize(s, plan, "decreasing")
kept = [p for sp in re.stock_pieces for p in sp.placements if p.id == lid]
assert kept and kept[0].locked and abs(kept[0].x_in - lx) < 1e-6
placed = {p.item_id for sp in re.stock_pieces for p in sp.placements}
assert {it.id for it in re.items} <= placed | set(re.unplaced) # nothing lost
assert validate_cut_plan(re) == []
def test_exact_no_worse_than_ffd():
s = Scene()
for ln in (50, 46, 40, 30, 30, 20):
s.place("2x4", ln)
ex = build_cut_plan(s, strategy="exact")
ffd = build_cut_plan(s, strategy="decreasing")
assert ex.score["stock_count"] <= ffd.score["stock_count"]
placed = {p.item_id for sp in ex.stock_pieces for p in sp.placements}
assert {it.id for it in ex.items} <= placed | set(ex.unplaced)
assert validate_cut_plan(ex) == []
def test_exact_handles_oversize():
s = Scene()
s.place("2x4", 40)
s.place("2x4", 120) # bigger than a stick
plan = build_cut_plan(s, strategy="exact")
assert plan.unplaced and plan.warnings
assert validate_cut_plan(plan) == []
def test_guillotine_packs_and_validates():
s = Scene()
for _ in range(4):
s.place("ply-3/4", 30, width_in=20)
g = build_cut_plan(s, strategy="guillotine")
sheets = [sp for sp in g.stock_pieces if sp.is_sheet]
assert sheets and sum(len(sp.placements) for sp in sheets) == 4
assert validate_cut_plan(g) == []
def test_guillotine_oversize_panel_unplaced():
s = Scene()
s.place("ply-3/4", 200, width_in=200) # bigger than a whole sheet
g = build_cut_plan(s, strategy="guillotine")
assert g.unplaced and g.warnings
assert validate_cut_plan(g) == []
def test_best_of_n_no_worse():
from woodshop.cutplan import _plan_key, best_cut_plan
s = Scene()
for ln in (50, 46, 40, 30, 30, 20):
s.place("2x4", ln)
best = best_cut_plan(s, attempts=50)
base = build_cut_plan(s, strategy="decreasing")
assert _plan_key(best) <= _plan_key(base)
assert validate_cut_plan(best) == []
def test_custom_settings_kerf():
s = Scene()
s.place("2x4", 48)
s.place("2x4", 48)
# zero kerf -> both 48" fit in one 96" stick
assert build_cut_plan(s, settings=ShopSettings(kerf_in=0.0)).score["stock_count"] == 1

100
tests/test_driver.py Normal file
View File

@ -0,0 +1,100 @@
"""Tests for the driver's orchestration logic (external tools are mocked)."""
import json
from woodshop import driver
from woodshop.cli import normalize_anchor
def test_anchor_aliases():
assert normalize_anchor("end") == "end_b"
assert normalize_anchor("the end") == "end_b" # falls through to default end_b
assert normalize_anchor("start") == "end_a"
assert normalize_anchor("NEAR") == "end_a"
assert normalize_anchor("") == "end_b"
def test_dispatch_resolves_dollar_symbols(monkeypatch):
"""$1/$2 in a multi-op turn resolve to the ids of boards placed this turn."""
seen = []
def fake_run(cmd, stdin=""):
if cmd[0] != "pa-execute-tool":
return ""
name, args = cmd[2], json.loads(cmd[4])
seen.append((name, args))
if name == "wood-place":
n = sum(1 for c in seen if c[0] == "wood-place")
return json.dumps({"success": True, "output": f"Placed p{n}: a board.", "error": ""})
return json.dumps({"success": True, "output": f"did {name}", "error": ""})
monkeypatch.setattr(driver, "_run", fake_run)
calls = [
{"tool": "wood-place", "args": {"stock": "2x4", "length": "2 ft"}},
{"tool": "wood-place", "args": {"stock": "2x4", "length": "2 ft"}},
{"tool": "wood-join", "args": {"part_b": "$2", "to": "$1", "angle": "90"}},
]
driver.dispatch(calls, verbose=False)
join_args = next(a for n, a in seen if n == "wood-join")
assert join_args["part_b"] == "p2"
assert join_args["to"] == "p1"
def test_say_pseudo_tool_does_not_dispatch(monkeypatch):
calls_made = []
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": calls_made.append(cmd) or "")
msgs = driver.dispatch([{"tool": "say", "args": {"text": "which end?"}}], verbose=False)
assert msgs == ["which end?"]
assert calls_made == [] # nothing executed
def test_interpret_tolerates_fenced_json(monkeypatch):
monkeypatch.setattr(
driver, "_run",
lambda cmd, stdin="": '```json\n[{"tool": "wood-undo", "args": {}}]\n```'
if cmd[:2] != ["pa-load-tools", "--tools"] else "[]",
)
calls = driver.interpret("undo that", schemas="[]")
assert calls == [{"tool": "wood-undo", "args": {}}]
def test_summarize_rolls_up_many_ops():
calls = ([{"tool": "wood-place", "args": {}}] * 8
+ [{"tool": "wood-join", "args": {}}] * 2
+ [{"tool": "wood-stand", "args": {}}] * 4)
summary = driver.summarize(calls, [""] * len(calls))
assert "placed 8" in summary
assert "joined 2" in summary
assert "stood up 4" in summary
assert len(summary) < 80 # short enough to speak
def test_summarize_speaks_queries_verbatim():
calls = [{"tool": "wood-cutlist", "args": {}}]
messages = ["CUT LIST\n 2 x 2x4 ..."]
assert driver.summarize(calls, messages).startswith("CUT LIST")
def test_summarize_speaks_clarification():
calls = [{"tool": "say", "args": {"text": "which end?"}}]
assert driver.summarize(calls, ["which end?"]) == "which end?"
def test_interpret_handles_garbage(monkeypatch):
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": "I'm not sure what you mean")
calls = driver.interpret("blah", schemas="[]")
assert calls[0]["tool"] == "say"
def test_extract_calls_ignores_trailing_brackets():
"""A greedy [.*] would swallow the trailing '[note]' and fail to parse."""
raw = '[{"tool": "wood-undo", "args": {}}]\n\nLet me know [if that helps].'
assert driver._extract_calls(raw) == [{"tool": "wood-undo", "args": {}}]
def test_extract_calls_strips_fences_and_handles_object():
assert driver._extract_calls('```json\n{"tool": "wood-clear", "args": {}}\n```') == \
[{"tool": "wood-clear", "args": {}}]
def test_extract_calls_returns_none_on_garbage():
assert driver._extract_calls("no json here") is None

64
tests/test_geometry.py Normal file
View File

@ -0,0 +1,64 @@
"""Geometry tests that exercise the build123d boolean features."""
import pytest
pytest.importorskip("build123d")
from woodshop.geometry import part_solid # noqa: E402
from woodshop.scene import Scene # noqa: E402
def test_hole_reduces_volume():
s = Scene()
s.place("2x4", 12)
base = part_solid(s.get_part("p1")).volume
s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=1.0, depth_in=0) # through
assert part_solid(s.get_part("p1")).volume < base
def test_mortise_reduces_volume():
s = Scene()
s.place("2x4", 12)
base = part_solid(s.get_part("p1")).volume
s.add_feature("p1", "mortise", face="top", along_in=6, width_in=1, height_in=2, depth_in=0.75)
assert part_solid(s.get_part("p1")).volume < base
def test_tenon_adds_volume():
s = Scene()
s.place("2x4", 12)
base = part_solid(s.get_part("p1")).volume
s.add_feature("p1", "tenon", face="end_b", width_in=1.5, height_in=0.75, depth_in=1.5)
assert part_solid(s.get_part("p1")).volume > base
def test_chamfer_reduces_volume():
s = Scene()
s.place("2x4", 12)
base = part_solid(s.get_part("p1")).volume
s.add_feature("p1", "chamfer", face="end_b", width_in=0.5)
assert part_solid(s.get_part("p1")).volume < base
def test_oversized_chamfer_falls_back():
s = Scene()
s.place("2x4", 12)
# absurd chamfer size: clamped/caught, board stays valid (no crash)
s.add_feature("p1", "chamfer", face="end_b", width_in=99)
assert part_solid(s.get_part("p1")).volume > 0
def test_rotated_box_feature_still_cuts():
s = Scene()
s.place("2x4", 12)
base = part_solid(s.get_part("p1")).volume
s.add_feature("p1", "mortise", face="top", width_in=2, height_in=0.5,
depth_in=0.5, rotation_deg=90)
assert part_solid(s.get_part("p1")).volume < base
def test_featured_part_tessellates():
s = Scene()
s.place("2x4", 12)
s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=0.5, depth_in=0)
verts, tris = part_solid(s.get_part("p1")).tessellate(0.05)
assert len(verts) > 8 and len(tris) > 12

View File

@ -0,0 +1,140 @@
"""Tests for the GUI controller's in-process command execution (no display)."""
import pytest
pytest.importorskip("PySide6")
from PySide6.QtCore import QCoreApplication # noqa: E402
from woodshop import driver # noqa: E402
from woodshop.gui.controller import Controller # noqa: E402
_app = QCoreApplication.instance() or QCoreApplication([])
def _controller(tmp_path):
return Controller(str(tmp_path / "scene.json"))
def test_execute_calls_with_symbols(tmp_path):
"""The controller's executor applies wood-* calls in-process, with $N."""
c = _controller(tmp_path)
calls = [
{"tool": "wood-place", "args": {"stock": "2x4", "length": "4 ft"}},
{"tool": "wood-place", "args": {"stock": "2x4", "length": "2 ft"}},
{"tool": "wood-stand", "args": {"part": "$2"}},
{"tool": "wood-join", "args": {"part_b": "$2", "to": "$1", "angle": "0"}},
]
driver.dispatch(calls, verbose=False, executor=c.execute_call)
assert [p.id for p in c.scene.parts] == ["p1", "p2"]
assert c.scene.get_part("p2").is_vertical
assert len(c.scene.joints) == 1
def test_button_ops_and_persistence(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 48)
c.stand() # acts on selection (p1)
assert c.scene.get_part("p1").is_vertical
c.duplicate() # p2
assert len(c.scene.parts) == 2
c.delete() # deletes selection p2
assert [p.id for p in c.scene.parts] == ["p1"]
# changes are persisted to disk
from woodshop.scene import Scene
assert len(Scene.load(c.scene_path).parts) == 1
def test_select_and_undo_redo(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 24)
c.place("2x4", 36)
c.select("p1")
assert c.selected_id == "p1"
c.undo() # removes p2
assert len(c.scene.parts) == 1
c.redo()
assert len(c.scene.parts) == 2
def test_toggle_multiselect(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 24)
c.place("2x4", 24)
c.select("p1")
c.toggle("p2")
assert set(c.selected) == {"p1", "p2"}
c.toggle("p1") # ctrl-click again removes it
assert c.selected == ["p2"]
def test_group_move_is_single_undo(tmp_path):
c = _controller(tmp_path)
for _ in range(3):
c.place("2x4", 24)
c.set_selected(["p1", "p2", "p3"])
c.move_selected(dy=4) # "move these 4 inches in +y"
assert all(p.position_in[1] == 4 for p in c.scene.parts)
c.undo() # one undo reverts the whole group
assert all(p.position_in[1] == 0 for p in c.scene.parts)
def test_feature_preview_then_apply(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 12)
c.add_feature("mortise") # active feature with defaults
orig = c.active_feature_obj().depth_in
c.set_preview(depth_in=orig + 0.5) # preview only — model unchanged
assert c.preview is not None
assert c.active_feature_obj().depth_in == orig
c.apply_preview() # commit
assert c.preview is None
assert c.active_feature_obj().depth_in == orig + 0.5
def test_feature_preview_mesh_builds():
pytest.importorskip("pyvista")
from woodshop.scene import Scene
from woodshop.viewer import feature_preview_mesh
s = Scene(); s.place("2x4", 12)
feat = s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=0.5)
assert feature_preview_mesh(s.get_part("p1"), feat).n_points > 0
def test_fit_mortise_to_tenon(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 24)
c.add_feature("tenon") # f1 on p1, active
c.scene.edit_feature("f1", width_in=1.0, height_in=0.75, depth_in=1.5)
c.place("2x4", 24)
c.add_feature("mortise") # f2 on p2, now active
c.fit_feature("f1") # fit the mortise to the tenon
_, m = c.scene.find_feature("f2")
assert m.width_in == 1.0 + 1 / 32 # pocket = tongue + clearance
assert m.height_in == 0.75 + 1 / 32
assert m.depth_in == 1.5 + 1 / 32
def test_highlight_feature(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 12)
c.add_feature("mortise") # f1
c.highlight_feature("f1")
assert c.preview is not None and c.preview_kind == "highlight"
assert c.preview[1].id == "f1"
c.highlight_feature(None)
assert c.preview is None
def test_break_feature_connection(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 24); c.add_feature("mortise") # f1 on p1
c.place("2x4", 12); c.add_feature("tenon") # f2 on p2
c.scene.connect("f1", "f2")
assert c.feature_connection_ids("f1") == ["c1"]
c.break_feature_connection("f1")
assert c.scene.connections == []
assert c.feature_connection_ids("f1") == []
def test_unknown_tool_is_safe(tmp_path):
c = _controller(tmp_path)
assert "unknown" in c.execute_call("wood-bogus", {}).lower()

View File

@ -0,0 +1,41 @@
"""Tests for deterministic build-step generation."""
from woodshop.instructions import build_steps, format_steps, polish_prompt
from woodshop.scene import Scene
def _scene():
s = Scene()
s.place("2x4", 24)
s.rename("p1", "leg")
s.add_feature("p1", "tenon", face="end_b", depth_in=1) # f1
s.place("2x4", 48)
s.add_feature("p2", "mortise", face="top", along_in=24,
width_in=1.5, height_in=1, depth_in=1) # f2
s.connect("f2", "f1") # seat the joint
return s
def test_steps_cover_the_build_phases():
sections = build_steps(_scene())
titles = [t for t, _ in sections]
assert "Gather stock" in titles
assert any("Cut pieces" in t for t in titles)
assert any("joinery" in t.lower() for t in titles)
assert any("glue" in t.lower() for t in titles) # assembly step (has connections)
assert titles[-1] == "Finish"
def test_steps_carry_real_data():
text = format_steps(build_steps(_scene()))
assert "leg" in text # named part appears
assert "tenon" in text and "mortise" in text # joinery listed
def test_polish_prompt_guards_numbers():
p = polish_prompt(build_steps(_scene()))
assert "do not invent" in p.lower()
assert "Gather stock" in p
def test_empty_scene_still_formats():
assert format_steps(build_steps(Scene()))

55
tests/test_jigs.py Normal file
View File

@ -0,0 +1,55 @@
"""Tests for rule-based jig suggestions."""
from woodshop.jigs import format_jigs, suggest_jigs
from woodshop.scene import Scene
def test_repeated_crosscuts_suggest_stop_block():
s = Scene()
for _ in range(10): # the canonical example: 10 identical cuts
s.place("2x4", 6.5)
sb = [j for j in suggest_jigs(s) if j.kind == "stop-block"]
assert sb and sb[0].count == 10
assert "6.5" in sb[0].title
def test_below_threshold_suggests_nothing():
s = Scene()
s.place("2x4", 6.5)
s.place("2x4", 6.5) # only 2 < default min_repeats=3
assert suggest_jigs(s) == []
def test_repeated_holes_suggest_drill_template():
s = Scene()
for _ in range(4):
p = s.place("2x4", 24)
s.add_feature(p.id, "hole", face="top", diameter_in=0.375)
assert any(j.kind == "drill-template" and j.count == 4 for j in suggest_jigs(s))
def test_repeated_mortises_suggest_template():
s = Scene()
for _ in range(3):
p = s.place("2x4", 24)
s.add_feature(p.id, "mortise", face="top", width_in=1.5, height_in=1, depth_in=1)
assert any(j.kind == "mortise-template" for j in suggest_jigs(s))
def test_holes_at_same_position_suggest_template():
s = Scene()
for _ in range(3):
p = s.place("2x4", 24)
s.add_feature(p.id, "hole", face="top", along_in=3, diameter_in=0.375)
assert any(j.kind == "drill-template" for j in suggest_jigs(s))
def test_holes_at_different_positions_no_template():
s = Scene()
for along in (3, 9, 15): # same diameter, different spots
p = s.place("2x4", 24)
s.add_feature(p.id, "hole", face="top", along_in=along, diameter_in=0.375)
assert not any(j.kind == "drill-template" for j in suggest_jigs(s))
def test_format_empty():
assert "No repeated" in format_jigs([])

45
tests/test_layout.py Normal file
View File

@ -0,0 +1,45 @@
"""Tests for the cutting-stock nesting (lumber 1D, plywood 2D)."""
from woodshop.layout import nest_lumber, nest_plywood, stock_counts
from woodshop.scene import Scene
def test_lumber_packs_into_sticks():
s = Scene()
for _ in range(3):
s.place("2x4", 40) # three 40" pieces -> two fit in a 96" stick (80+kerf)
sticks = nest_lumber(s)["2x4"]
assert len(sticks) == 2 # 2 in the first stick, 1 in the second
assert sum(len(st["pieces"]) for st in sticks) == 3
assert all(st["used"] <= 96 + 1e-6 for st in sticks)
def test_lumber_offcut_reported():
s = Scene()
s.place("2x4", 90)
stick = nest_lumber(s)["2x4"][0]
assert stick["offcut"] == 6.0 # 96 - 90
def test_plywood_packs_panels_on_sheet():
s = Scene()
s.place("ply-3/4", 40, width_in=20) # two panels easily fit on one 48x96 sheet
s.place("ply-3/4", 40, width_in=20)
sheets = nest_plywood(s)["ply-3/4"]
assert len(sheets) == 1
assert len(sheets[0]["placements"]) == 2
def test_oversize_panel_gets_its_own_sheet():
s = Scene()
s.place("ply-3/4", 96, width_in=48) # a full sheet
s.place("ply-3/4", 96, width_in=48) # another full sheet
assert len(nest_plywood(s)["ply-3/4"]) == 2
def test_stock_counts_mixes_lumber_and_plywood():
s = Scene()
s.place("2x4", 40)
s.place("ply-1/2", 24, width_in=24)
counts = stock_counts(s)
assert counts["2x4"] == 1
assert counts["ply-1/2"] == 1

435
tests/test_scene.py Normal file
View File

@ -0,0 +1,435 @@
"""Tests for the scene model and operations (no heavy 3D deps required)."""
import math
import pytest
from woodshop.lumber import actual_section, normalize_stock
from woodshop.scene import Scene, SceneError
from woodshop.units import to_inches
# ----- lumber ----------------------------------------------------------
def test_nominal_to_actual():
assert actual_section("2x4") == (1.5, 3.5)
assert actual_section("4x4") == (3.5, 3.5)
@pytest.mark.parametrize("raw,expected", [("2 x 4", "2x4"), ("2X4", "2x4"), ("2by4", "2x4")])
def test_normalize_stock(raw, expected):
assert normalize_stock(raw) == expected
def test_unknown_stock_lists_options():
with pytest.raises(KeyError, match="Known stock"):
actual_section("9x9")
# ----- units -----------------------------------------------------------
@pytest.mark.parametrize("value,unit,inches", [
("6 ft", "inch", 72), ("6 foot", "inch", 72), ("10 inches", "inch", 10),
("3 ft 6 in", "inch", 42), ("2'", "inch", 24), ("72", "inch", 72),
("6", "foot", 72), (6, "foot", 72),
])
def test_to_inches(value, unit, inches):
assert to_inches(value, default_unit=unit) == inches
def test_to_inches_bad():
with pytest.raises(ValueError):
to_inches("a bunch")
# ----- operations ------------------------------------------------------
def test_place_sets_section_and_selection():
s = Scene()
p = s.place("2x4", 72)
assert p.id == "p1"
assert p.section_in == (1.5, 3.5)
assert s.selection == "p1"
def test_the_example_sentence():
"""'place a 6 foot 2x4, sand it, attach a 2 foot 2x4 at 90 deg, 10 in from end.'"""
s = Scene()
s.place("2x4", to_inches("6 ft")) # p1
s.finish("it") # sand the selection
s.place("2x4", to_inches("2 ft")) # p2 (now selected)
s.join("p1", "p2", angle_deg=90, offset_in=10, anchor="end_b")
p1, p2 = s.get_part("p1"), s.get_part("p2")
assert "sanded" in p1.finishes
# attach point is 10in back from p1's far end (72 - 10 = 62 along +X)
assert p2.position_in[0] == pytest.approx(62.0)
# butt joint: p2's end sits flush on p1's side face (a 2x4 is 3.5" wide ->
# 1.75" from centerline), in the same horizontal plane (z = 0).
assert p2.position_in[1] == pytest.approx(1.75)
assert p2.position_in[2] == pytest.approx(0.0, abs=1e-9)
assert p2.yaw_deg == pytest.approx(90.0)
# p2 now runs along +Y
ux, uy, uz = p2.axis_unit()
assert ux == pytest.approx(0.0, abs=1e-9)
assert uy == pytest.approx(1.0)
assert uz == pytest.approx(0.0, abs=1e-9)
assert len(s.joints) == 1
def test_stand_makes_board_vertical():
s = Scene()
s.place("2x4", 30)
s.stand("it")
p = s.get_part("p1")
assert p.is_vertical
ux, uy, uz = p.axis_unit()
assert uz == pytest.approx(1.0) # length axis points straight up
assert (ux, uy) == pytest.approx((0.0, 0.0), abs=1e-9)
assert p.end_point()[2] == pytest.approx(30.0) # top is 30in up
def test_butt_joint_meets_surface_not_centerline():
"""B's end should sit on A's face, with no interpenetration past A's centerline."""
s = Scene()
s.place("2x4", 48) # p1 along +X, flat
s.place("2x4", 12) # p2
s.join("p1", "p2", angle_deg=90, offset_in=24, anchor="end_a")
p2 = s.get_part("p2")
# p2 runs along +Y starting at p1's +Y face (1.75 from centerline), not at
# p1's centerline (which would be y=0).
assert p2.position_in[1] == pytest.approx(1.75)
# its far end is 1.75 + 12 out, fully clear of p1's body.
assert p2.end_point()[1] == pytest.approx(13.75)
def test_flush_aligns_tops_for_different_thicknesses():
"""A thinner board joined to a thicker one should sit with TOPS level, not
centers level (flush-by-default)."""
s = Scene()
s.place("2x4", 48) # p1: thickness 1.5 -> top face at z=0.75
s.place("1x8", 12) # p2: thickness 0.75
s.join("p1", "p2", angle_deg=90, offset_in=24, anchor="end_a")
p2 = s.get_part("p2")
# p2's top (z + 0.375) is flush with p1's top (0.75) -> p2 center at 0.375,
# NOT centered on p1 (which would leave p2 at z=0).
assert p2.position_in[2] == pytest.approx(0.375)
def test_join_preserves_vertical_tilt():
"""A stood-up leg stays vertical when attached to a horizontal apron."""
s = Scene()
s.place("2x4", 48) # p1 apron
s.place("2x4", 29) # p2 leg
s.stand("p2")
s.join("p1", "p2", angle_deg=0, offset_in=0, anchor="end_a")
leg = s.get_part("p2")
assert leg.is_vertical
# base sits on the apron's top face (z = t_a/2 = 0.75) since the leg is vertical
assert leg.position_in[2] == pytest.approx(0.75)
def test_move_relative_and_absolute():
s = Scene()
s.place("2x4", 24)
s.move("it", dx=5, dy=2, dz=1)
assert s.get_part("p1").position_in == [5.0, 2.0, 1.0]
s.move("it", dx=10, dy=0, dz=0, absolute=True)
assert s.get_part("p1").position_in == [10.0, 0.0, 0.0]
def test_copy_and_set_length_and_rename():
s = Scene()
s.place("2x4", 24)
s.rename("p1", "front rail")
assert s.get_part("front rail").id == "p1" # resolvable by alias
clone = s.copy("p1", dy=10)
assert clone.id == "p2"
assert clone.position_in[1] == 10.0
s.set_length("p2", 36)
assert s.get_part("p2").length_in == 36.0
def test_select_by_id_and_name():
s = Scene()
s.place("2x4", 24)
s.place("2x4", 24)
s.rename("p1", "front rail")
assert s.select("front rail").id == "p1"
assert s.selection == "p1"
assert s.select("p2").id == "p2"
def test_redo_after_undo():
s = Scene()
s.place("2x4", 24)
s.place("2x4", 36)
assert len(s.parts) == 2
s.undo()
assert len(s.parts) == 1
s.redo()
assert len(s.parts) == 2
assert s.get_part("p2").length_in == 36
def test_new_action_clears_redo():
s = Scene()
s.place("2x4", 24)
s.place("2x4", 36)
s.undo() # redo now has the p2 placement
s.place("2x6", 12) # a new action should invalidate redo
import pytest as _pt
with _pt.raises(SceneError, match="Nothing to redo"):
s.redo()
def test_batch_is_one_undo():
s = Scene()
s.place("2x4", 24)
s.place("2x4", 24)
with s.batch():
s.move("p1", dx=5)
s.move("p2", dx=5)
assert s.get_part("p1").position_in[0] == 5
s.undo() # single undo reverts both moves
assert s.get_part("p1").position_in[0] == 0
assert s.get_part("p2").position_in[0] == 0
def test_add_edit_delete_feature():
s = Scene()
s.place("2x4", 12)
f = s.add_feature("p1", "mortise", face="top", width_in=1, height_in=1, depth_in=0.5)
assert f.id == "f1" and f.is_cut
assert s.get_part("p1").features[0].kind == "mortise"
s.edit_feature("f1", depth_in=0.75)
assert s.find_feature("f1")[1].depth_in == 0.75
s.delete_feature("f1")
assert s.get_part("p1").features == []
def test_tenon_is_additive():
s = Scene()
s.place("2x4", 12)
assert not s.add_feature("p1", "tenon", face="end_b", depth_in=1).is_cut
def test_unknown_feature_kind_errors():
s = Scene()
s.place("2x4", 12)
with pytest.raises(SceneError, match="Unknown feature"):
s.add_feature("p1", "dovetailzzz")
def test_feature_roundtrip(tmp_path):
s = Scene()
s.place("2x4", 12)
s.add_feature("p1", "hole", face="top", along_in=3, diameter_in=0.5)
loaded = Scene.load(s.save(tmp_path / "s.json"))
feat = loaded.get_part("p1").features[0]
assert feat.kind == "hole" and feat.diameter_in == 0.5
@pytest.mark.parametrize("ypr", [(30, 0, 0), (0, 40, 0), (0, 0, 55), (35, 20, -15), (120, 30, -45)])
def test_matrix_to_ypr_roundtrip(ypr):
from woodshop.scene import matrix_to_ypr
s = Scene()
p = s.place("2x4", 12)
p.yaw_deg, p.tilt_deg, p.roll_deg = ypr
assert matrix_to_ypr(p.rotation_matrix()) == pytest.approx(ypr, abs=1e-6)
def test_connect_seats_tenon_in_mortise():
s = Scene()
s.place("2x4", 24)
s.add_feature("p1", "mortise", face="top", along_in=12, width_in=1.5, height_in=1, depth_in=1)
s.place("2x4", 12)
s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1)
s.connect("f1", "f2") # move p2 so its tenon seats into p1's mortise
pa, na, _, _ = s.get_part("p1").feature_world_frame(s.find_feature("f1")[1])
pb, nb, _, _ = s.get_part("p2").feature_world_frame(s.find_feature("f2")[1])
assert pb == pytest.approx(pa, abs=1e-6) # faces meet
assert nb == pytest.approx(tuple(-x for x in na), abs=1e-6) # tenon points into mortise
def _two_connected():
s = Scene()
s.place("2x4", 24)
s.add_feature("p1", "mortise", face="top", along_in=12, width_in=1.5, height_in=1, depth_in=1)
s.place("2x4", 12)
s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1)
s.connect("f1", "f2")
return s
def test_connect_records_and_groups():
s = _two_connected()
assert len(s.connections) == 1
groups = [g for g in s.groups() if len(g) > 1]
assert groups and set(groups[0]) == {"p1", "p2"}
def test_explode_then_assemble_roundtrip():
s = _two_connected()
seated = list(s.get_part("p2").position_in)
s.explode(5)
assert s.get_part("p2").position_in != seated
assert s.connections[0].backed_off_in == 5
s.assemble()
assert s.get_part("p2").position_in == pytest.approx(seated)
assert s.connections[0].backed_off_in == 0
def test_disconnect_keeps_position_and_ungroups():
s = _two_connected()
pos = list(s.get_part("p2").position_in)
s.disconnect(cid="c1")
assert s.connections == []
assert s.get_part("p2").position_in == pos # pieces stay put
assert all(len(g) == 1 for g in s.groups()) # no longer one assembly
def test_delete_drops_connections():
s = _two_connected()
s.delete("p2")
assert s.connections == []
def test_connecting_drags_connected_subassembly():
s = Scene()
s.place("2x4", 24)
s.add_feature("p1", "mortise", face="top", along_in=12, width_in=1.5, height_in=1, depth_in=1) # f1 (A)
s.place("2x4", 12)
s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1) # f2 (B end)
s.add_feature("p2", "mortise", face="top", along_in=6, width_in=1.5, height_in=1, depth_in=1) # f3 (B top)
s.place("2x4", 8)
s.add_feature("p3", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1) # f4 (C)
s.connect("f3", "f4") # C seats into B; B stays, C moves
def dist(a, b):
return math.dist(s.get_part(a).position_in, s.get_part(b).position_in)
d_bc = dist("p2", "p3")
c_before = list(s.get_part("p3").position_in)
s.connect("f1", "f2") # B seats into A — C should ride along
assert dist("p2", "p3") == pytest.approx(d_bc, abs=1e-6) # BC kept rigid
assert s.get_part("p3").position_in != c_before # C actually moved
def test_connect_needs_two_boards():
s = Scene()
s.place("2x4", 24)
s.add_feature("p1", "tenon", face="end_a")
s.add_feature("p1", "mortise", face="top")
with pytest.raises(SceneError, match="two different boards"):
s.connect("f1", "f2")
def test_bbox_axis_aligned():
s = Scene()
p = s.place("2x4", 24) # 1.5 x 3.5 section
lo, hi = p.bbox()
assert lo == pytest.approx((0, -1.75, -0.75))
assert hi == pytest.approx((24, 1.75, 0.75))
def test_spatial_summary_flags_overlap():
from woodshop.scene import spatial_summary
s = Scene()
s.place("2x4", 24) # p1 at origin
s.place("2x4", 24) # p2 at origin -> overlaps p1
summ = spatial_summary(s)
assert "p1" in summ and "p2" in summ
assert "p1&p2" in summ # interpenetration flagged
s.move("p2", dy=10) # slide clear
assert "p1&p2" not in spatial_summary(s)
def test_plywood_normalize_and_place():
from woodshop.lumber import normalize_stock, is_plywood, plywood_thickness
assert normalize_stock("3/4 plywood") == "ply-3/4"
assert normalize_stock("plywood") == "ply-3/4"
assert is_plywood("ply-1/2") and plywood_thickness("ply-1/2") == 0.5
s = Scene()
p = s.place("3/4 plywood", 48, width_in=24)
assert p.stock == "ply-3/4"
assert p.section_in == (0.75, 24.0)
def test_plywood_requires_width():
s = Scene()
with pytest.raises(SceneError, match="width"):
s.place("ply-1/2", 48)
def test_clear():
s = Scene()
s.place("2x4", 24)
s.place("2x4", 24)
s.clear()
assert s.parts == [] and s.selection is None
def test_migrate_old_rotation_field(tmp_path):
"""Scenes saved with the old rotation_deg field still load."""
import json
old = {"version": 1, "parts": [{"id": "p1", "stock": "2x4", "length_in": 24,
"section_in": [1.5, 3.5], "position_in": [0, 0, 0], "rotation_deg": 45}]}
path = tmp_path / "old.json"
path.write_text(json.dumps(old))
s = Scene.load(path)
assert s.get_part("p1").yaw_deg == 45
def test_slugify():
from woodshop.scene import slugify
assert slugify("Coffee Table!") == "coffee-table"
assert slugify(" My Bench ") == "my-bench"
def test_project_save_open_list(tmp_path, monkeypatch):
import woodshop.scene as scene_mod
monkeypatch.setattr(scene_mod, "_data_dir", lambda: tmp_path)
s = Scene()
s.place("2x4", 48)
s.place("2x4", 24)
s.save(scene_mod.project_path("coffee table"))
assert scene_mod.list_projects() == ["coffee-table"]
reopened = Scene.load(scene_mod.project_path("Coffee Table")) # name normalizes
assert len(reopened.parts) == 2
def test_resolve_it_without_selection_errors():
s = Scene()
with pytest.raises(SceneError, match="selected"):
s.finish("it")
def test_undo_restores_previous_state():
s = Scene()
s.place("2x4", 72)
s.place("2x4", 24)
assert len(s.parts) == 2
s.undo()
assert len(s.parts) == 1
assert s.selection == "p1"
def test_delete_reassigns_selection_and_drops_joints():
s = Scene()
s.place("2x4", 72)
s.place("2x4", 24)
s.join("p1", "p2")
s.delete("p2")
assert [p.id for p in s.parts] == ["p1"]
assert s.joints == []
assert s.selection == "p1"
def test_roundtrip_serialization(tmp_path):
s = Scene()
s.place("2x4", 72)
s.place("2x4", 24)
s.join("p1", "p2", angle_deg=90, offset_in=10)
path = s.save(tmp_path / "scene.json")
loaded = Scene.load(path)
assert [p.id for p in loaded.parts] == ["p1", "p2"]
assert loaded.parts[0].section_in == (1.5, 3.5)
assert loaded.joints[0].angle_deg == 90
assert loaded.selection == "p2"