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>
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>
- 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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>