Commit Graph

41 Commits

Author SHA1 Message Date
rob 2b76317a3f Phase 6: inventory workflows (purchase / record build / use offcuts)
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>
2026-05-30 19:23:24 -03:00
rob 30a10adabc Phase 5: event-sourced inventory ledger model
inventory.py — shop-wide, append-only event log; current state derived by
folding (Codex's recommendation). Lean and plumbing-only.

- Events: purchase / consume / create_offcut / discard / adjustment /
  build_recorded. Ledger.load/save to $XDG_DATA_HOME/woodshop/inventory.json.
- Primitives: purchase, adjust, consume_offcut, discard_offcut, record_build
  (deducts consumed stock, keeps/discards each produced offcut).
- Derived: on_hand(), offcut_bin(), available_stock() (one Piece shape for full
  stock + offcuts — the AvailableStock interface), builds(), stats() (spent,
  units built, offcuts kept/burned/trashed, per-project units).
- plan_consumption(plan): derive consumed stock + reusable offcuts from a CutPlan.
- tests: purchase/consume, adjustment, keep/discard offcuts, consume removes
  from bin, plan_consumption, available_stock, save/load, cross-project stats.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:16:28 -03:00
rob 59fff1cb6d Phase 4: batch builds (quantity N)
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>
2026-05-30 19:14:23 -03:00
rob 7adb7e27fc Phase 3: manufacturing allowance in CutPlan (rough vs final)
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>
2026-05-30 19:08:05 -03:00
rob 882b0ec959 Phase 2: finish costs by kind in the estimate
- 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>
2026-05-30 19:04:44 -03:00
rob c36ed3407e Phase 1: material + finish fields + color resolver
Parts now carry material/finish/finish_color instead of a finishes list.

- Part: material, finish (raw|sanded|clear|stain|paint), finish_color (hex).
  from_dict migrates the old finishes list; serialization additive.
- scene.set_material / set_finish / paint (finish() kept as alias); colors
  normalized via new colors.py (name->hex, lighten/darken/blend).
- part_color() resolves stock->material->finish->visual; viewer applies it with
  a subtle deterministic per-part tint (same-material boards stay distinct);
  sanded reads lighter, painted shows its color, stain/clear tint.
- lumber.MATERIAL_COLORS + default_material(stock).
- CLI: paint / finish / material subcommands; controller TOOL_CMD wood-paint/
  finish/material + group methods; Parts panel Paint…/Material… buttons +
  inspector shows material/finish.
- Updated instructions (sand + finish coats), cli status, estimate to new model.
- tests: set/paint/migrate/json-roundtrip, color priority + fallback, helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:01:28 -03:00
rob 30bfb3a9e0 Add project estimate: consumables, labor, and suggested selling price
The Cost tab now produces a full quote, not just material cost.

- estimate.py: project_estimate() = materials (incl HST) + consumables
  (screws per butt joint, glue per M&T connection / dado / rabbet, finish
  $/sq ft of finished surface) + labor (editable minutes per operation —
  setup, cut, butt joint, assembly, sanding, and per-feature tenon/mortise/
  hole/slot/dado/rabbet/chamfer — × counts from the scene/plan, × shop rate).
- Selling price = MARGIN on total cost: price = total_cost / (1 - margin),
  labor counted as cost. A target price overrides margin and back-solves the
  implied margin. EstimateRates persisted to estimate.json.
- Cost tab: live margin % spinbox + target $ field, "Edit rates…"
  (RatesEditDialog), existing "Edit prices…" / "Refresh from Kent…", Print.
- All counts are deterministic (count_ops off scene.joints / connections /
  features / finishes); nothing guessed.
- tests: op counts, screws/glue, labor scaling, margin formula, target
  back-solve, div-zero guard, rates roundtrip, format, and GUI cost-tab +
  margin/target controls. 163 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:40:15 -03:00
rob 067ec0ea46 Add cost estimate (Cost tab) with editable Kent NB price book
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>
2026-05-30 16:23:59 -03:00
rob 9d80be4e7f Fix lock-aware plywood reopt + honest Best-of-100 when locked
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>
2026-05-30 15:46:39 -03:00
rob 60957ae4af Carry conversation history so "yes" / "do that" resolve
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>
2026-05-30 15:42:33 -03:00
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 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 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 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 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 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 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 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 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