1. Offcut reuse was lost on optimize: Find better layout / Best of 100 / Try
alternative now pass available=self._available(); reoptimize seeds preserve
owned/source so a locked offcut stays owned (not silently bought).
2. Inventory is now species-aware end to end: purchase/consume/adjust/on_hand/
available_stock and record_build key by (stock, material); plan_consumption
and Mark-purchased group by species; PurchaseDialog shows species and prices
at the species rate; price-book save backs out the multiplier to the SPF base.
A spruce on-hand no longer satisfies an oak cut.
3. Cross-species placement is now invalid: validate_cut_plan and the GUI drop
path reject an oak cut on a spruce piece.
4. Yield is bought-only and consistent: _score divides bought-used by bought-area
(owned offcuts excluded); the Shopping tab's yield matches.
tests: locked-reopt keeps owned offcut, species-aware on-hand, cross-species
validate, yield excludes owned, optimize preserves the offcut toggle. 203 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves the v1 simplification where every species cost the same.
- CutItem/StockPiece gain `material`; build_cut_plan and reoptimize group by
(stock, material) so each bought stick/sheet is a single species; pieces are
stamped with their material (offcut seeds keep theirs).
- prices.estimate groups by (stock, species) and applies a per-species
multiplier (DEFAULT_MATERIAL_MULTIPLIERS: SPF 1.0, oak 3.0, walnut 5.5, …),
persisted to material_multipliers.json. CostLine shows "oak 1x4".
- PriceEditDialog gains a species-multiplier table; BOM Buy list shows species.
- Offcuts carry material so offcut reuse matches species.
- tests: multiplier scales price, default species at base, mixed species on the
same stock priced separately, multiplier save/load.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- gui/inventory_window.py: read-only management view over the event ledger —
On-hand / Offcut bin / Build history / Stats tabs (shop-wide), with Refresh.
- Wired into the main window: new Shop ▸ Inventory… menu.
- Plan doc marked complete (all 7 phases; offcut reuse opt-in toggle, purchase
price-book update opt-in).
- tests: window renders a populated ledger and an empty one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the ledger into the BOM window via three workflows (Codex's
workflow-first UX), plus an offcut-consuming planner.
- StockPiece gains owned/source; build_cut_plan(available=) seeds the packer
from owned offcuts (reusing the seeded-packing) so they're consumed before
buying. Score reports stock_count = pieces to BUY (offcuts free) + owned_count;
prices.estimate and the Buy list exclude owned pieces.
- BOM header: "Build units", an opt-in "Use shop offcuts" toggle, and
"Mark purchased…" / "Record build…" buttons.
- PurchaseDialog: confirm qty + price each, opt-in "save prices to price book".
- RecordBuildDialog: shows consumed stock + each offcut with Keep/Burn/Trash/
Ignore before committing (the moment to correct reality).
- Ledger.record_build takes per-offcut dispositions; used offcuts are consumed
from the bin; build cost snapshot logged.
- tests: offcut toggle drops buy-count to 0, record-build writes the ledger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Estimate N identical units, nesting all units together so offcuts carry across
units → realistic per-unit cost.
- build_cut_plan(quantity=N) / best_cut_plan(quantity=N) replicate CutItems
(not Parts) with a `unit` field; reoptimize infers the batch size from the
base plan and preserves it.
- project_estimate(quantity=N): materials from the N-unit plan; setup labor once
per batch, per-op time + consumables ×N; reports per-unit cost & price.
- BOM window: "Build units" spinner in the header drives the active plan; layout
labels pieces by unit ("U2 left leg"); cost tab shows total + per-unit.
- tests: replication + units, offcut sharing across units, per-unit estimate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sanding never shrinks the design model; instead the cut plan distinguishes the
rough size you cut from the final size you sand to.
- CutItem gains final_length_in/final_width_in (+ final_len/final_wid/
has_allowance helpers); length_in/width_in are the ROUGH cut size.
- _cut_items(scene, settings): a finished (finish != raw) board is cut oversize
by sanding_allowance_in on dimensions actually CUT — length always, width only
for sheet goods. Dimensional lumber's section width is the stock as delivered,
not padded (Rob's point). note gains "sand to final".
- ShopSettings.sanding_allowance_in default 1/32"; serialization additive.
- BOM cut list shows "Cut … → final …" and a sanding-allowance footnote.
- tests: raw = no allowance, sanded lumber pads length only, plywood pads width
too, allowance roundtrips in plan JSON.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- EstimateRates.finish_cost_per_sqft and min_per_finish are now dicts keyed by
finish kind (sanded/clear/stain/paint) — paint costs more and takes longer
than a clear coat; all editable.
- project_estimate prices the finish line per part by its finish kind × surface
area; finishing labor sums per-part by kind; raw parts cost nothing.
- load_rates generically merges any dict-valued rate field; RatesEditDialog
rewritten to render scalars + dict sub-sections automatically.
- tests: finish cost varies by kind, raw = no finish line, dict roundtrip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New shop-packet output: a printable cost estimate driven by the active
CutPlan's buy-counts × a curated, editable price book (HST 15%).
- prices.py: DEFAULT_PRICES seeded with real Kent (New Brunswick) shelf
prices per buy-unit (lumber = 8' stick, plywood = 4x8 sheet); persisted to
$XDG_CONFIG_HOME/woodshop/prices.json (defaults + saved overrides).
estimate() -> CostEstimate (lines/subtotal/tax/total/missing); lumber price
scales with stick length; unknown stock is flagged, never invented.
- BOM window: Cost tab with "Edit prices…" (PriceEditDialog), "Refresh from
Kent…", and Print.
- fetch_kent_prices() + scripts/fetch_kent_prices.py: best-effort refresh.
Kent renders prices client-side (not in HTML), so it tries a static parse
then Playwright if installed — honest that it may need updating.
- tests: estimate math, per-sheet plywood, stick-length scaling, missing-price
flagging, save/load roundtrip, corrupt-file fallback, JSON-LD parse, cost
tab render + price edit persistence. 153 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex findings:
1. reoptimize sent unlocked plywood to fresh sheets whenever any sheet
placement was locked, instead of packing into free space on the locked
sheet — so locking one of two panels that share a sheet split them onto
two sheets. Added _free_rects_sheet (guillotine subtraction carving free
rectangles around locked panels) + _pack_plywood_seeded, and refactored
_pack_plywood_guillotine onto a shared _guillotine_pack core that accepts
seeded sheets. reoptimize now uses it for the plywood branch.
2. "Best of 100" only tried the ~6 STRATEGIES when locks existed. The locked
path now runs strategies + shuffle restarts up to 100 attempts via
reoptimize, matching the label.
Tests: plywood lock keeps both panels on one sheet; locked Best-of-100 stays valid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The driver interpreted each utterance in isolation (schemas + scene +
utterance only), so when WoodShop asked a clarifying question and the user
replied "yes", the next turn had no record of what was proposed and fell
back to "not sure what you'd like me to do".
- driver.interpret/handle now accept a rolling (utterance, reply) history;
SYSTEM prompt gains a "Recent conversation" section instructing the model
to execute the previously-proposed calls on affirmation.
- CLI main() keeps a history list across the loop.
- GUI Controller keeps a bounded self._history and threads it through
run_command, appending each turn.
- tests: history render/window, prompt inclusion, handle + controller append.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
_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>
- 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>
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 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>
- 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>
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>
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>
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>
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>
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>
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>