Extends "build something like this" beyond photos:
- driver.resolve_reference(source) routes any path/URL: image/PDF → a path
claude -p reads directly; STL/STEP/OBJ → render_mesh() renders an isometric
PNG (pyvista; STEP via build123d→STL) and reports the bounding box; a normal
web URL → fetch_web_text() pulls the page's visible text.
- interpret(reference_text=) injects guide/render-dims text alongside any image
directive; handle() + controller.run_command() + woodshop-talk --ref pass it.
- command bar: picker/drag-drop accept images + .pdf + 3D files; any pasted URL
is resolved; resolution (download/render/fetch) runs off the UI thread.
- find_image_url→find_reference_url (any URL); fetch_image→fetch_url (generic).
- tests: URL detect, image+reference-text directives, fetch_url, web-text strip,
resolve_reference routing per kind, real STL render (skips without GL). 220 pass.
3D render gives the model EXACT proportions (+ bbox) instead of a 2D guess.
Honest limit: render needs the viewer stack + working off-screen GL on your box;
the live model round-trip still wants your eyes to confirm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Attach a photo (📎 button, drag-drop, paste, or an image URL) and the driver
hands it to claude -p, which reads the image (its Read tool sees images) and
emits the usual tool-call JSON to build a simplified, buildable interpretation
in dimensional lumber — no API keys, same claude -p pipe.
- driver: interpret(image_path=) prepends a reference-photo directive with the
image's absolute path; find_image_url() + fetch_image() download a linked
image to a temp file; woodshop-talk --image (path or URL) for CLI/voice.
- controller.run_command(image_path=) passthrough.
- command bar: 📎 attach (file picker), drag-drop image, Ctrl+V paste image,
and image-URL-in-text detection; downloads run off the UI thread; an image
chip shows/clears the attachment.
- tests: URL detection, image directive in prompt, fetch_image temp write,
controller passthrough, command-bar attach + default-text smoke. 216 pass.
Honest limit: the live image round-trip needs a real display/model call to
verify — wired + unit-tested, please confirm it sees the photo on your machine.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
format_cutlist now renders from build_cut_plan(scene) via a new
format_plan_cutlist(plan), instead of its own board-feet/shopping math. The CLI,
voice, and BOM window all read the same CutPlan now, so they can't disagree —
and kerf, sanding allowance, species, offcut reuse, and unplaced warnings all
surface in the text cut list automatically.
- cut_rows/shopping/board_feet kept (still used elsewhere) but no longer back
the text renderer.
- Output shape preserved (CUT LIST / Total / SHOPPING) plus species labels,
rough→final lines, and a WON'T FIT section.
- tests: CLI shopping counts == BOM CutPlan counts (kerf case), species +
sanding surfaced, unplaced flagged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Housekeeping over features, per Codex: consistency and portability now matter
more than another feature.
- driver.scene_summary no longer hardcodes ~/PycharmProjects/.venv/bin/woodshop;
new driver.woodshop_cmd() resolves the CLI portably (PATH, else `python -m
woodshop`). Used by the voice/GUI status path.
- scripts/gen_wood_tools.py: CMDFORGE_PY overridable via env; generated tool
bodies resolve `woodshop` at RUNTIME (shutil.which → python -m woodshop), no
baked-in local path; file-writing moved under main()/__main__ (was running at
import); PyYAML declared under dev deps.
- cutlist.py: drop the misleading "+10% waste" label — shopping already uses the
kerf-aware CutPlan nesting.
- Docs refreshed (README + CLAUDE): real test count, parametric joinery is
modeled, new cutplan/prices/estimate/inventory/colors modules + GUI windows,
portable tool regeneration.
- tests: driver path discovery (PATH + module fallback), generated tool bodies
compile and contain no hardcoded paths. 207 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 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>