Compare commits
No commits in common. "274e87e2392099268da60e3a8ae4b1b60921746e" and "70591ad6fea948822ab62c79a1d7bd5807314702" have entirely different histories.
274e87e239
...
70591ad6fe
147
CLAUDE.md
147
CLAUDE.md
|
|
@ -4,152 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## Project Overview
|
||||
|
||||
**WoodShop** - Voice-driven conversational 3D woodworking & furniture modeler.
|
||||
Speak (or type) commands like *"place a 6 foot 2x4, sand it, attach a 2 foot 2x4
|
||||
at 90 degrees 10 inches from the end"* and watch the model build in a live 3D
|
||||
viewport — Holodeck-style.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Design principle:** reuse existing CmdForge tools for everything that isn't
|
||||
woodshop-specific; don't reinvent voice/AI plumbing.
|
||||
|
||||
```
|
||||
woodshop-talk (driver.py) ── the conversational loop
|
||||
│ dictate ............... speech→text (CmdForge tool, reused)
|
||||
│ pa-load-tools ......... wood-* → Claude schemas (reused)
|
||||
│ claude -p ............. interpret utterance → JSON tool calls (reused provider)
|
||||
│ pa-execute-tool ....... dispatch each wood-* tool (reused)
|
||||
│ read-aloud ........... speak confirmation (reused)
|
||||
▼
|
||||
scene.json ← single source of truth (parts, joints, selection, undo stack)
|
||||
▲ │ writes
|
||||
│ reads/mutates ▼
|
||||
wood-* CmdForge tools woodshop-view (viewer.py)
|
||||
(place/join/sand/delete/undo) watches scene.json → live pyvista 3D
|
||||
thin wrappers over `woodshop` CLI
|
||||
```
|
||||
|
||||
Only woodshop-specific code lives in this repo: the scene model
|
||||
(`scene.py`), nominal→actual lumber table (`lumber.py`), length parsing
|
||||
(`units.py`), the `woodshop` CLI (`cli.py`), build123d geometry + STL/STEP
|
||||
export (`geometry.py`), the pyvista viewport (`viewer.py`), and the driver
|
||||
(`driver.py`). The driver uses Claude (not `pa-tool-loop`, which hard-wires a
|
||||
small local model) for reliable structured tool-calling.
|
||||
|
||||
### Entry points
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `woodshop` (no args) / `woodshop-gui` | **The unified desktop studio** (viewport + parts panel + command bar) |
|
||||
| `woodshop <op>` | CLI ops: place, join, stand, lay, rotate, move, trim, copy, rename, sand, delete, select, undo, redo, clear, status, cutlist, export, render, save, open, projects |
|
||||
| `woodshop-view` | Standalone live 3D viewport (watches `scene.json`; labels, grid, isometric) |
|
||||
| `woodshop-talk` | Standalone conversational driver (`--voice` for mic, `--once "..."` for one command) |
|
||||
|
||||
The studio (`src/woodshop/gui/`) is a thin PySide6 shell over the same Scene +
|
||||
operations + interpreter:
|
||||
- `controller.py` — one in-memory `Scene`; buttons/menus call typed methods,
|
||||
voice/typed commands go through `driver.interpret` and are applied via
|
||||
`execute_call`, which **reuses the CLI command functions** (no behavioral
|
||||
drift). Every mutation saves to disk and emits `changed`.
|
||||
- `viewport.py` — embedded `pyvistaqt.QtInteractor`; click a board to select.
|
||||
- `panels.py` — parts list (ExtendedSelection: Ctrl/Shift multi-select) +
|
||||
selected-part inspector (editable length/yaw/tilt) + quick-action buttons.
|
||||
`command_bar.py` — text + push-to-talk + transcript, with slow work
|
||||
(LLM/dictate/TTS) on a `QThreadPool` (`workers.py`).
|
||||
- `numpad.py` — a numberpad control panel (2/4/6/8 move, 1/3/7/9 rotate, +/−
|
||||
raise/lower, 0/. front/iso, 5 fit) that also responds to the **physical
|
||||
numpad keys** (MainWindow.keyPressEvent forwards them when not typing).
|
||||
- **Spatial feedback**: `scene.spatial_summary()` (each board's world bounding
|
||||
box via `Part.bbox()` + flagged interpenetrations) is fed into the interpreter
|
||||
prompt, so the AI can reason about where boards are — e.g. "position the left
|
||||
side flush against p2" becomes a computed `wood-move`, and it can fix overlaps.
|
||||
- **Manual add**: the Parts tab has a stock dropdown + length + "Add board" to
|
||||
place a board instantly without the AI (`controller.place`).
|
||||
- **Multi-selection**: `controller.selected` is a list driven by 3D Ctrl+click
|
||||
(`viewport.picked` carries an additive flag) and list multi-select. Group ops
|
||||
(`move_selected`/`rotate_selected`/stand/lay/sand/delete) apply to all selected
|
||||
in one undo step via `scene.batch()`. Voice "move these" works because the
|
||||
selected ids are fed into the interpreter prompt.
|
||||
|
||||
Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`.
|
||||
Named projects: `~/.local/share/woodshop/projects/<slug>.json`.
|
||||
|
||||
Stock is dimensional lumber (`lumber.NOMINAL_TO_ACTUAL`, fixed cross-section) or
|
||||
**plywood** sheet stock (`ply-3/4`, `ply-1/2`, …): fixed thickness, but a width
|
||||
given per-panel — `place(stock, length, width_in=)` requires a width for plywood.
|
||||
The cut list reports plywood in sq-ft and buys it in 4×8 sheets (lumber stays
|
||||
board-feet / 8' sticks).
|
||||
|
||||
Parts have full 3D orientation (`yaw_deg`/`tilt_deg`/`roll_deg`) so legs and
|
||||
uprights stand vertically. Parts can be referred to by id (`p1`) or by a name
|
||||
set with `rename`. The cut list (`cutlist.py`) reports board-feet and an 8'-stick
|
||||
shopping estimate.
|
||||
|
||||
### CmdForge tools (the documented command vocabulary)
|
||||
|
||||
`wood-place`, `wood-join`, `wood-sand`, `wood-delete`, `wood-undo` live in
|
||||
`~/.cmdforge/<name>/` and wrap the `woodshop` CLI. Regenerate them with
|
||||
`/tmp/gen_wood_tools.py` (kept in the repo plan) if their schemas change. The
|
||||
arg descriptions ARE the LLM's documentation, so keep them clear.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[viewer,dev]" # viewer extra pulls build123d + pyvista
|
||||
pytest # 25 tests
|
||||
```
|
||||
|
||||
### Known limitations / next steps
|
||||
|
||||
1. **Joins are flush butt joints**: B's end sits flush against A's surface, and
|
||||
B is aligned to A's reference corner (top faces level + one side flush) rather
|
||||
than centered. The flush corner is fixed (A's +width/+thick side; no per-join
|
||||
choice of which corner / centered).
|
||||
2. **Joinery features** (`Feature` on each `Part`) are parametric ops applied in
|
||||
`geometry.part_solid`: `tenon` fuses a protruding tongue; `mortise`/`hole`/
|
||||
`slot`/`dado`/`rabbet` cut a box/cylinder into a face; `chamfer` bevels the
|
||||
edges around a face via build123d `chamfer()` (handled specially —
|
||||
`_apply_chamfer`, with a try/except fallback for over-sized bevels). The
|
||||
viewport tessellates featured boards (plain boards stay fast pyvista boxes).
|
||||
Edit paths: CLI `feature/feature-edit/feature-delete/features`; voice
|
||||
`wood-feature`/`wood-feature-delete`; **GUI Joinery tab** (`feature_panel.py`)
|
||||
— add-with-default, then dragging a field shows a **live red preview ghost**
|
||||
(`viewer.feature_preview_mesh` — a cheap box/cylinder, no re-tessellation) of
|
||||
the pending change over the committed feature; **Apply** commits it
|
||||
(`controller.set_preview`/`apply_preview`, `preview_changed` signal →
|
||||
`viewport.set_preview`). Per-kind hints + field tooltips explain the
|
||||
parameters. `controller.active_feature` is the one being edited. A **Fit to
|
||||
mate…** button (`controller.fit_feature`) resizes a mortise to a chosen tenon
|
||||
(or vice versa) — pocket = tongue + clearance (1/32"), pocket slightly deeper;
|
||||
a dialog lists the complementary features (`features_of_kind`). The dialog's
|
||||
**Make connection** checkbox calls `controller.make_connection` →
|
||||
`scene.connect(anchor, moving)`, which moves/orients the moving feature's
|
||||
board so the tenon seats into the mortise (faces meet, axes aligned). Connect
|
||||
builds the target world rotation from the feature frames and decomposes it to
|
||||
the board's yaw/tilt/roll via `matrix_to_ypr` (inverse of `Part.local_frame`'s
|
||||
Rz·Ry(-tilt)·Rx(roll)); `Part.feature_world_frame` gives each feature's world
|
||||
point/normal/u/v. Features also have `rotation_deg` (spin about the face
|
||||
normal) to line up cross-sections. CLI `connect`; voice `wood-connect`.
|
||||
3. **Connections / assemblies**: `connect` RECORDS a `Connection`; connected
|
||||
boards form an assembly (`scene.groups()`, connection-graph union-find). They
|
||||
stay separate boards (not fused — cut list & disassembly keep working). Ops:
|
||||
`scene.explode(d)` backs each moving board off along its joint axis,
|
||||
`assemble()` re-seats (reverse), `disconnect(cid/part)` breaks (pieces stay
|
||||
put). `_seat()` is the shared mate math; `connect()` then `_drag_group()`
|
||||
applies the same rigid transform to the moving board's existing sub-assembly
|
||||
(minus the anchor's group) so connected boards travel together. The GUI Fit
|
||||
dialog lets you pick which board repositions (`make_connection(move_self=)`). CLI `connections/disconnect/explode/
|
||||
assemble`; voice `wood-explode/assemble/disconnect`. The GUI Parts tab is a
|
||||
QTreeWidget grouping connected boards under an assembly node, with a
|
||||
right-click menu (back off / re-fit / break). Not yet: countersinks,
|
||||
click-a-face-to-place, per-assembly naming/rename.
|
||||
2. **Latency** ~7–13s per utterance (one `claude -p` call).
|
||||
3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against
|
||||
failures but the mic path isn't exercised in the unit tests.
|
||||
4. Auto-placement of parts in a multi-step "build a table" request depends on
|
||||
the LLM choosing good offsets; geometry is correct but corners may need nudging.
|
||||
**WoodShop** - Voice-driven conversational 3D woodworking & furniture modeler
|
||||
|
||||
## ⚠️ CRITICAL: Updating Todos, Milestones, and Goals
|
||||
|
||||
|
|
|
|||
124
README.md
124
README.md
|
|
@ -1,125 +1,39 @@
|
|||
# WoodShop
|
||||
|
||||
**Voice-driven conversational 3D woodworking & furniture modeler.**
|
||||
|
||||
Talk to it like the Star Trek holodeck and watch furniture build itself:
|
||||
|
||||
> *"Place a 6 foot 2x4, sand it, then attach a 2 foot 2x4 at 90 degrees, 10 inches from the end."*
|
||||
|
||||
> *"Build a coffee table: a four foot by two foot frame from 2x4s, with four legs 18 inches tall standing at the corners."*
|
||||
|
||||
Each board is real dimensional lumber (a 2x4 is modeled at its true 1.5″ × 3.5″),
|
||||
so the result is buildable — export to **STEP** (CAD/CNC) or **STL** (3D print),
|
||||
and get a **cut list with board-feet and a shopping estimate**.
|
||||
|
||||
## How it works
|
||||
|
||||
WoodShop reuses the existing [CmdForge](https://gitea.brrd.tech/rob/cmdforge)
|
||||
tool ecosystem for everything that isn't woodworking-specific, so no wheels are
|
||||
reinvented:
|
||||
|
||||
```
|
||||
woodshop-talk ── the conversational loop
|
||||
│ dictate ............. speech → text (CmdForge tool)
|
||||
│ pa-load-tools ....... wood-* → Claude schemas (CmdForge tool)
|
||||
│ claude -p ........... interpret → tool calls (provider)
|
||||
│ pa-execute-tool ..... dispatch each wood-* (CmdForge tool)
|
||||
│ read-aloud .......... speak confirmation (CmdForge tool)
|
||||
▼
|
||||
scene.json ← single source of truth (parts, joints, selection, undo)
|
||||
▲ │ writes
|
||||
│ reads/mutates ▼
|
||||
wood-* CmdForge tools woodshop-view
|
||||
(place/join/stand/move/...) live pyvista 3D, watches scene.json
|
||||
```
|
||||
|
||||
The `wood-*` tools are thin wrappers over the `woodshop` CLI, so the modeling
|
||||
logic lives in one place and the tools double as the LLM's documented command
|
||||
vocabulary.
|
||||
Voice-driven conversational 3D woodworking & furniture modeler
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[gui,dev]" # 'gui' pulls build123d + pyvista + PySide6 + pyvistaqt
|
||||
python scripts/gen_wood_tools.py # register the wood-* CmdForge tools
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### The studio (recommended)
|
||||
*TODO: Add usage instructions*
|
||||
|
||||
```bash
|
||||
woodshop # launches the unified desktop app
|
||||
```
|
||||
## Documentation
|
||||
|
||||
One window with the **3D viewport** (click a board to select it; Ctrl+click to
|
||||
select several), a **parts panel** (list + selected-part inspector +
|
||||
quick-action buttons), a **numberpad control panel** (move/rotate the selection
|
||||
by clicking or with your keyboard's numpad — 2/4/6/8 move, 1/3/7/9 rotate, +/−
|
||||
raise/lower, 0/. front/iso, 5 fit), and a **command bar** where you type or
|
||||
push-to-talk (🎤). Mouse, keyboard, and voice all drive the same scene and the
|
||||
same visible selection — so "move these 4 inches", the numpad 8 key, and the
|
||||
move button are interchangeable, and act on every selected board at once (one
|
||||
undo). Menus cover New/Open/Save projects, Export STL/STEP, Save Image,
|
||||
Undo/Redo, camera views, and Build templates.
|
||||
|
||||
### Standalone tools (headless / scripting)
|
||||
|
||||
```bash
|
||||
woodshop-view & # just the live 3D window (watches the scene)
|
||||
woodshop-talk # just the voice/text loop; --voice to speak
|
||||
woodshop-talk --once "build a workbench top from five 2x6 boards 6 feet long"
|
||||
```
|
||||
|
||||
Or drive it directly from the CLI:
|
||||
|
||||
```bash
|
||||
woodshop place 2x4 "6 ft" # place a board
|
||||
woodshop stand # stand it up (a leg)
|
||||
woodshop join p2 --to p1 --angle 90 --offset "10 in"
|
||||
woodshop rename "front-left leg"
|
||||
woodshop cutlist # bill of materials
|
||||
woodshop export table.step # STEP / STL export
|
||||
woodshop save "coffee table" # named projects
|
||||
woodshop open "coffee table"
|
||||
```
|
||||
|
||||
Run `woodshop --help` for the full command list (place, join, stand, lay,
|
||||
rotate, move, trim, copy, rename, sand, delete, undo, clear, status, cutlist,
|
||||
export, save, open, projects).
|
||||
|
||||
The active scene lives at `$WOODSHOP_SCENE` or
|
||||
`~/.local/share/woodshop/scene.json`; named projects in
|
||||
`~/.local/share/woodshop/projects/`.
|
||||
Full documentation is available at: https://pages.brrd.tech/rob/woodshop/
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pytest # 41 tests
|
||||
# Clone the repository
|
||||
git clone https://gitea.brrd.tech/rob/woodshop.git
|
||||
cd woodshop
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install for development
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
```
|
||||
|
||||
Key modules:
|
||||
|
||||
| Module | Role |
|
||||
|--------|------|
|
||||
| `scene.py` | Part/Joint/Scene model, operations, undo, persistence |
|
||||
| `lumber.py` | nominal → actual dimensional lumber table |
|
||||
| `units.py` | parse "6 ft" / "3 ft 6 in" / "-2 ft" → inches |
|
||||
| `cli.py` | the `woodshop` command |
|
||||
| `geometry.py` | build123d solids + STL/STEP export |
|
||||
| `cutlist.py` | cut list, board-feet, shopping estimate |
|
||||
| `viewer.py` | live pyvista 3D viewport (`woodshop-view`) |
|
||||
| `driver.py` | conversational loop (`woodshop-talk`) |
|
||||
| `scripts/gen_wood_tools.py` | (re)generate the `wood-*` CmdForge tools |
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Joins are flush butt joints: B's end sits against A's face and B aligns to
|
||||
A's reference corner (tops level + one side flush), so mixed-size boards line
|
||||
up. Joinery *cuts* (mortise/tenon, lap, pocket holes) aren't modeled yet.
|
||||
- Command interpretation latency is ~7–13s per utterance (one `claude -p` call).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
*TODO: Add license*
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
# Shop Packet Plan
|
||||
|
||||
A living plan for turning the BOM into a **shop-packet generator**. Adjust as we go.
|
||||
|
||||
**Status:** Phases 0–4 implemented (cutplan.py model; multi-strategy auto-layout;
|
||||
deterministic instructions + AI polish; rule-based jig suggestions; constrained
|
||||
drag-edit layout). Logic is unit-tested; the drag/print GUI needs a real display to
|
||||
verify interactively.
|
||||
|
||||
Review fixes applied: one active CutPlan rendered by every tab; unplaced parts
|
||||
surfaced in Shopping; process-stable shuffle (hashlib); kerf-gap validation; drop
|
||||
stock-type compatibility; waste/score recompute after manual edits; rotation legality
|
||||
(settings/grain); position-aware jig grouping.
|
||||
|
||||
**Phase 1 now complete:** bounded branch-and-bound exact lumber packing
|
||||
(`_min_bins`/`_pack_lumber_exact`, ≤12 pieces, FFD-seeded with a count bound),
|
||||
guillotine free-rectangle plywood packing (`_pack_plywood_guillotine`, best-area-fit +
|
||||
rotation), a real "Best of 100" control in the Cut Layout tab, and richer scoring that
|
||||
prefers more & longer reusable offcuts (`reusable_in` tie-break). Lock-aware
|
||||
re-optimization also landed (locked pieces preserved through "Find better layout"/"Best of N").
|
||||
|
||||
Remaining follow-ups: grain-direction in auto-layout, on-hand offcut inventory,
|
||||
opt-in jig material in the BOM.
|
||||
|
||||
## Guiding principle
|
||||
The **math layer is deterministic and inspectable**; AI is used **only for narrative**
|
||||
(instruction wording, jig explanations). Cut lengths, kerf, counts, layouts, jig
|
||||
dimensions, validation, and warnings all come from code — the AI never invents a number.
|
||||
|
||||
UI language: say **"Optimize" / "Find better layout"**, never "optimal" (woodworking
|
||||
wants explainable good layouts, not slow provably-perfect ones).
|
||||
|
||||
## Data flow
|
||||
```
|
||||
Scene → CutItems → StockInventory → CutPlan → ShopPacket(view)
|
||||
```
|
||||
|
||||
## The keystone: `CutPlan` (cutplan.py)
|
||||
Dataclasses, JSON-friendly, **stable IDs everywhere** (`CutItem.id`, `StockPiece.id`,
|
||||
`Placement.id`) — never rely on list position. Serializable from day one
|
||||
(`to_dict`/`from_dict`) so we can save manual layouts, compare strategies, export, debug.
|
||||
|
||||
- `ShopSettings` — kerf, stick/sheet sizes, offcut-usable thresholds, plywood rotation
|
||||
allowed, grain direction (future), tolerances (mortise/tenon clearance, sanding
|
||||
allowance, reveal). Defaults present from day one even before they're in the UI.
|
||||
- `CutItem` — a required piece (part id, stock, length, width, is_sheet, note e.g. "incl. tenon").
|
||||
- `StockPiece` — a physical stick/sheet with its `placements` and `waste` regions.
|
||||
- `Placement` — a cut item on a stock piece: position (x[,y]), rotated?, locked?.
|
||||
- `WasteRegion` — leftover, with a `reusable` flag (≥ threshold).
|
||||
- `CutPlan` — settings, items, stock_pieces, unplaced, strategy, **score**, warnings.
|
||||
- `score = {stock_count, waste_area, reusable_offcuts, warnings, strategy_name}` —
|
||||
detailed, so the UI can explain *why* one layout beats another.
|
||||
- `build_cut_plan(scene, settings=None, strategy="decreasing") -> CutPlan`.
|
||||
- `validate_cut_plan(plan) -> [problems]` — no piece outside stock, no overlaps, kerf
|
||||
respected, every item placed-or-warned, stock dims respected, rotations legal.
|
||||
|
||||
`ShopPacket` stays thin (a view/composition over cut rows + shopping rows + cut plan +
|
||||
warnings) until `CutPlan` is solid.
|
||||
|
||||
## Phases (commit after each)
|
||||
|
||||
**Phase 0 — CutPlan + ShopSettings (keystone).**
|
||||
New `cutplan.py` with the model + `build_cut_plan` + `validate_cut_plan`. Port the current
|
||||
FFD (lumber) / shelf (plywood) packers behind it. **Keep old APIs** (`layout.nest_lumber/
|
||||
nest_plywood/stock_counts/waste_summary`, `cutlist.shopping/waste_summary`) as thin wrappers
|
||||
over `build_cut_plan` so existing tests/UI keep working. BOM window renders from `CutPlan`.
|
||||
Tests: lumber, plywood, kerf, tenon extra length, unplaced/oversize warnings, JSON roundtrip.
|
||||
|
||||
**Phase 1 — smart auto-layout.** Strategies behind the buttons: FFD, BFD, bounded exact
|
||||
(small jobs, capped), random restarts / best-of-N for big jobs; objective "minimize stock,
|
||||
then maximize useful offcuts (bonus for common 12/24/36″)". Plywood: per-panel rotation;
|
||||
shelf/guillotine/maxrects; score by sheet count, waste area, reusable-offcut size. Buttons:
|
||||
**Optimize · Try Alternative · Best of N**; surface warnings.
|
||||
|
||||
**Phase 2 — structured instructions.** Deterministic ordered steps from CutPlan + scene
|
||||
(buy → cut per plan → mark joinery → repeated cuts/jigs → cut/drill features → dry-fit →
|
||||
assemble → finish); **then** AI polishes wording (numbers stay from code). Instructions tab.
|
||||
|
||||
**Phase 3 — jig suggestions (rule-based → AI explanation).** Detect patterns (identical
|
||||
crosscuts, repeated end-offsets, repeated mortises/holes, mirrored L/R, repeated angles,
|
||||
repeated panel widths) → candidates with **computed dims** (stop block, spacer, drill
|
||||
template, story stick, mortise template, angle sled). AI explains build/use. Jigs are
|
||||
**shop aids** kept separate from project parts — optional, opt-in before any jig material
|
||||
enters the BOM. Jigs tab.
|
||||
|
||||
**Phase 4 — constrained manual layout editing.** Drag in the layout view as a *constrained
|
||||
planner*: snap to stock edges / kerf / neighbors; invalid = red; move pieces between
|
||||
sticks/sheets; rotate plywood (if grain allows); **lock** a piece so re-optimization works
|
||||
around it; live "valid / invalid / saves a stick / wastes more" feedback. Builds on
|
||||
`CutPlan.locked` + `validate_cut_plan`.
|
||||
|
||||
## Deterministic vs AI
|
||||
| Code (deterministic) | AI (narrative only) |
|
||||
|---|---|
|
||||
| lengths, kerf, counts, layouts, scores, jig dims, validation, warnings | instruction wording, jig build/use explanations, summaries |
|
||||
|
|
@ -10,25 +10,7 @@ readme = "README.md"
|
|||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
woodshop = "woodshop.cli:main"
|
||||
woodshop-gui = "woodshop.gui.app:main"
|
||||
woodshop-view = "woodshop.viewer:main"
|
||||
woodshop-talk = "woodshop.driver:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Heavy 3D stack (OpenCASCADE etc.) — only needed to run the live viewport.
|
||||
viewer = [
|
||||
"build123d>=0.6",
|
||||
"pyvista>=0.43",
|
||||
]
|
||||
# The unified desktop studio (embeds the viewport in a Qt window).
|
||||
gui = [
|
||||
"build123d>=0.6",
|
||||
"pyvista>=0.43",
|
||||
"PySide6>=6.6",
|
||||
"pyvistaqt>=0.11",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
"""Generate the wood-* CmdForge tools: the documented woodworking command
|
||||
vocabulary. Each is a thin wrapper over the `woodshop` CLI so the logic lives in
|
||||
one place; pa-load-tools turns these into Claude function schemas.
|
||||
|
||||
The arg descriptions ARE the LLM's documentation — keep them clear and example-rich.
|
||||
Run this after changing the woodshop CLI to refresh the tools:
|
||||
python scripts/gen_wood_tools.py
|
||||
"""
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
CMDFORGE_PY = "/home/rob/.local/share/pipx/venvs/cmdforge/bin/python"
|
||||
CMDFORGE_DIR = Path.home() / ".cmdforge"
|
||||
BIN_DIR = Path.home() / ".local" / "bin"
|
||||
WS = 'ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")'
|
||||
|
||||
|
||||
def code(body: str) -> str:
|
||||
"""Wrap a command-building body that sets `cmd`, then runs it."""
|
||||
return (f"import subprocess, os\n{WS}\n{body}\n"
|
||||
"r = subprocess.run(cmd, capture_output=True, text=True)\n"
|
||||
"out = (r.stdout + r.stderr).strip()\n")
|
||||
|
||||
|
||||
TOOLS = {
|
||||
"wood-place": {
|
||||
"description": "Place a new board of dimensional lumber, or a plywood panel. Use for 'place', 'add', 'put', 'grab', 'cut me a' board/panel.",
|
||||
"arguments": [
|
||||
{"flag": "--stock", "variable": "stock", "description": "Lumber size e.g. 2x4, 2x6, 1x4, 4x4; or plywood e.g. ply-3/4, ply-1/2, ply-1/4"},
|
||||
{"flag": "--length", "variable": "length", "description": "Length with units, e.g. '6 ft', '72 in', '3 ft 6 in'"},
|
||||
{"flag": "--width", "variable": "width", "default": "", "description": "Panel width (REQUIRED for plywood, ignored for lumber), e.g. '24 in'"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "place", stock, length]\n'
|
||||
'if width != "": cmd += ["--width", str(width)]'
|
||||
),
|
||||
},
|
||||
"wood-join": {
|
||||
"description": "Attach one board to another at an angle, optionally offset along the target. Use for 'attach', 'join', 'connect', 'fasten'.",
|
||||
"arguments": [
|
||||
{"flag": "--part-b", "variable": "part_b", "description": "Id or name of the board being attached, e.g. p2"},
|
||||
{"flag": "--to", "variable": "to", "default": "", "description": "Board to attach to, e.g. p1 or 'front rail'. Omit for the most recent board."},
|
||||
{"flag": "--angle", "variable": "angle", "default": "90", "description": "Angle in degrees between the boards (default 90)"},
|
||||
{"flag": "--offset", "variable": "offset", "default": "", "description": "Distance from the anchor end, e.g. '10 in'. Omit to attach at the end."},
|
||||
{"flag": "--anchor", "variable": "anchor", "default": "end_b", "description": "Measure offset from 'end' (far end) or 'start'"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "join", part_b]\n'
|
||||
'if to: cmd += ["--to", to]\n'
|
||||
'if angle: cmd += ["--angle", str(angle)]\n'
|
||||
'if offset: cmd += ["--offset", offset]\n'
|
||||
'if anchor: cmd += ["--anchor", anchor]'
|
||||
),
|
||||
},
|
||||
"wood-stand": {
|
||||
"description": "Stand a board up vertically (e.g. a table or chair leg). Use for 'stand up', 'make it vertical', 'upright'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."},
|
||||
{"flag": "--tilt", "variable": "tilt", "default": "90", "description": "Tilt degrees, 90 = straight up (default 90)"},
|
||||
],
|
||||
"code": code('cmd = [ws, "stand"] + ([part] if part else []) + ["--tilt", str(tilt)]'),
|
||||
},
|
||||
"wood-lay": {
|
||||
"description": "Lay a board flat / horizontal. Use for 'lay it down', 'make it flat', 'horizontal'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."},
|
||||
],
|
||||
"code": code('cmd = [ws, "lay"] + ([part] if part else [])'),
|
||||
},
|
||||
"wood-rotate": {
|
||||
"description": "Rotate / re-orient a board. Use for 'rotate', 'turn', 'angle it'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
|
||||
{"flag": "--yaw", "variable": "yaw", "default": "", "description": "Heading in the horizontal plane, degrees"},
|
||||
{"flag": "--tilt", "variable": "tilt", "default": "", "description": "Elevation toward vertical, degrees"},
|
||||
{"flag": "--roll", "variable": "roll", "default": "", "description": "Rotation about the board's own length, degrees"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "rotate"] + ([part] if part else [])\n'
|
||||
'if yaw != "": cmd += ["--yaw", str(yaw)]\n'
|
||||
'if tilt != "": cmd += ["--tilt", str(tilt)]\n'
|
||||
'if roll != "": cmd += ["--roll", str(roll)]'
|
||||
),
|
||||
},
|
||||
"wood-move": {
|
||||
"description": "Move/slide a board by an offset (or to an absolute position). Use for 'move', 'slide', 'shift', 'nudge'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
|
||||
{"flag": "--dx", "variable": "dx", "default": "", "description": "X offset, e.g. '5 in', '-2 ft' (X = along the first board)"},
|
||||
{"flag": "--dy", "variable": "dy", "default": "", "description": "Y offset"},
|
||||
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset (up/down)"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "move"] + ([part] if part else [])\n'
|
||||
'if dx != "": cmd += ["--dx", dx]\n'
|
||||
'if dy != "": cmd += ["--dy", dy]\n'
|
||||
'if dz != "": cmd += ["--dz", dz]'
|
||||
),
|
||||
},
|
||||
"wood-trim": {
|
||||
"description": "Cut a board down to a new length. Use for 'cut it to', 'trim to', 'shorten to', 'make it N feet'.",
|
||||
"arguments": [
|
||||
{"flag": "--length", "variable": "length", "description": "New length with units, e.g. '4 ft'"},
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
|
||||
],
|
||||
"code": code('cmd = [ws, "trim", length] + (["--part", part] if part else [])'),
|
||||
},
|
||||
"wood-copy": {
|
||||
"description": "Duplicate a board, offset by dx/dy/dz. Use for 'copy', 'duplicate', 'another one like that'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board to copy (default: most recent)"},
|
||||
{"flag": "--dx", "variable": "dx", "default": "", "description": "X offset for the copy, e.g. '46 in'"},
|
||||
{"flag": "--dy", "variable": "dy", "default": "", "description": "Y offset"},
|
||||
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "copy"] + ([part] if part else [])\n'
|
||||
'if dx != "": cmd += ["--dx", dx]\n'
|
||||
'if dy != "": cmd += ["--dy", dy]\n'
|
||||
'if dz != "": cmd += ["--dz", dz]'
|
||||
),
|
||||
},
|
||||
"wood-rename": {
|
||||
"description": "Give a board a human-friendly name so it can be referred to by name later. Use for 'call it', 'name it', 'this is the'.",
|
||||
"arguments": [
|
||||
{"flag": "--name", "variable": "name", "description": "The name, e.g. 'front-left leg'"},
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id (default: most recent)"},
|
||||
],
|
||||
"code": code('cmd = [ws, "rename", name] + (["--part", part] if part else [])'),
|
||||
},
|
||||
"wood-sand": {
|
||||
"description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit to sand the most recent board ('it')."},
|
||||
],
|
||||
"code": code('cmd = [ws, "sand"] + ([part] if part else [])'),
|
||||
},
|
||||
"wood-delete": {
|
||||
"description": "Remove a board. Use for 'delete', 'remove', 'get rid of', 'scrap'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
|
||||
],
|
||||
"code": code('cmd = [ws, "delete"] + ([part] if part else [])'),
|
||||
},
|
||||
"wood-select": {
|
||||
"description": "Set the current selection — the board future commands like 'rotate that' or 'delete it' act on. Use for 'select', 'pick', 'grab the', 'use the'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "description": "Board id or name to select, e.g. p3 or 'front-left leg'"},
|
||||
],
|
||||
"code": code('cmd = [ws, "select", part]'),
|
||||
},
|
||||
"wood-undo": {
|
||||
"description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "undo"]'),
|
||||
},
|
||||
"wood-redo": {
|
||||
"description": "Redo the last undone operation. Use for 'redo', 'put it back', 'never mind that undo'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "redo"]'),
|
||||
},
|
||||
"wood-clear": {
|
||||
"description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "clear"]'),
|
||||
},
|
||||
"wood-feature": {
|
||||
"description": "Add a joinery feature to a board: tenon (male tongue), mortise (pocket), hole, slot, dado, or rabbet. Use for 'add a tenon', 'cut a mortise', 'drill a hole', 'cut a slot'.",
|
||||
"arguments": [
|
||||
{"flag": "--kind", "variable": "kind", "description": "tenon | mortise | hole | slot | dado | rabbet"},
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id/name (default: most recent)"},
|
||||
{"flag": "--face", "variable": "face", "default": "end_b", "description": "Which face: end_a, end_b, top, bottom, left, right"},
|
||||
{"flag": "--along", "variable": "along", "default": "", "description": "Position along the board (e.g. '3 in'), or 1st offset on an end"},
|
||||
{"flag": "--across", "variable": "across", "default": "", "description": "Offset across the face from centre"},
|
||||
{"flag": "--width", "variable": "width", "default": "", "description": "Feature width, e.g. '1.5 in'"},
|
||||
{"flag": "--height", "variable": "height", "default": "", "description": "Feature height/thickness"},
|
||||
{"flag": "--depth", "variable": "depth", "default": "", "description": "Cut depth, or tenon protrusion length"},
|
||||
{"flag": "--diameter", "variable": "diameter", "default": "", "description": "Hole diameter, e.g. '0.5 in'"},
|
||||
{"flag": "--rotation", "variable": "rotation", "default": "", "description": "Rotate the feature about its face normal, degrees"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "feature", kind]\n'
|
||||
'if part: cmd += ["--part", part]\n'
|
||||
'if face: cmd += ["--face", face]\n'
|
||||
'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n'
|
||||
' ("--height", height), ("--depth", depth), ("--diameter", diameter),\n'
|
||||
' ("--rotation", rotation)]:\n'
|
||||
' if val != "": cmd += [flag, str(val)]'
|
||||
),
|
||||
},
|
||||
"wood-connect": {
|
||||
"description": "Move/orient one board so its tenon/mortise seats into another's matching feature. Use for 'connect', 'assemble', 'join the pieces together', 'fit them together'.",
|
||||
"arguments": [
|
||||
{"flag": "--anchor", "variable": "anchor", "description": "Feature id that stays put, e.g. f1"},
|
||||
{"flag": "--moving", "variable": "moving", "description": "Feature id whose board moves to mate, e.g. f2"},
|
||||
],
|
||||
"code": code('cmd = [ws, "connect", anchor, moving]'),
|
||||
},
|
||||
"wood-explode": {
|
||||
"description": "Back connected boards off along their joint axes for an exploded view. Use for 'explode', 'back off the connections', 'show it pre-assembled'.",
|
||||
"arguments": [
|
||||
{"flag": "--distance", "variable": "distance", "description": "How far to separate, e.g. '3 in'"},
|
||||
],
|
||||
"code": code('cmd = [ws, "explode", distance]'),
|
||||
},
|
||||
"wood-assemble": {
|
||||
"description": "Re-seat all connections (reverse an explode / re-fit the joints). Use for 'assemble', 'put it back together', 're-fit', 'close it up'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "assemble"]'),
|
||||
},
|
||||
"wood-disconnect": {
|
||||
"description": "Break a connection so the pieces become independent (they stay where they are). Use for 'disconnect', 'break the connection', 'separate them'.",
|
||||
"arguments": [
|
||||
{"flag": "--connection", "variable": "connection", "description": "Connection id, e.g. c1"},
|
||||
],
|
||||
"code": code('cmd = [ws, "disconnect", connection]'),
|
||||
},
|
||||
"wood-feature-delete": {
|
||||
"description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.",
|
||||
"arguments": [
|
||||
{"flag": "--fid", "variable": "fid", "description": "Feature id, e.g. f1"},
|
||||
],
|
||||
"code": code('cmd = [ws, "feature-delete", fid]'),
|
||||
},
|
||||
"wood-cutlist": {
|
||||
"description": "Report the cut list / bill of materials: every board, board-feet, and how much lumber to buy. Use for 'cut list', 'what do I need to buy', 'bill of materials', 'how much wood'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "cutlist"]'),
|
||||
},
|
||||
"wood-save": {
|
||||
"description": "Save the current design as a named project. Use for 'save this as', 'save the project', 'remember this design'.",
|
||||
"arguments": [
|
||||
{"flag": "--name", "variable": "name", "description": "Project name, e.g. 'coffee table'"},
|
||||
],
|
||||
"code": code('cmd = [ws, "save", name]'),
|
||||
},
|
||||
"wood-open": {
|
||||
"description": "Open a previously saved project (replaces the current scene). Use for 'open', 'load the', 'go back to my'.",
|
||||
"arguments": [
|
||||
{"flag": "--name", "variable": "name", "description": "Name of the project to open"},
|
||||
],
|
||||
"code": code('cmd = [ws, "open", name]'),
|
||||
},
|
||||
"wood-projects": {
|
||||
"description": "List saved projects. Use for 'what projects do I have', 'list my designs'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "projects"]'),
|
||||
},
|
||||
}
|
||||
|
||||
WRAPPER = ('#!/bin/bash\n# CmdForge wrapper for \'{name}\'\n# Auto-generated - do not edit\n'
|
||||
'exec "{py}" -m cmdforge.runner "{name}" "$@"\n')
|
||||
|
||||
for name, spec in TOOLS.items():
|
||||
tool_dir = CMDFORGE_DIR / name
|
||||
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||
config = {
|
||||
"name": name, "description": spec["description"], "category": "Other",
|
||||
"version": "0.2.0", "arguments": spec["arguments"],
|
||||
"steps": [{"type": "code", "code": spec["code"], "output_var": "out"}],
|
||||
"output": "{out}",
|
||||
}
|
||||
(tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
|
||||
wrapper = BIN_DIR / name
|
||||
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY))
|
||||
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
print(f"created {name}")
|
||||
print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}")
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
"""WoodShop - voice-driven conversational 3D woodworking & furniture modeler.
|
||||
|
||||
Architecture (see CLAUDE.md):
|
||||
- The *scene* (parts + joints) is the single source of truth, persisted as JSON.
|
||||
- Voice/AI/agent-loop plumbing is reused from existing CmdForge tools
|
||||
(`dictate`, `read-aloud`, `pa-load-tools`, `pa-reason-core`, `pa-tool-loop`).
|
||||
- This package owns only what is genuinely woodshop-specific: the scene model,
|
||||
the woodworking operations, and a live 3D viewport.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,444 +0,0 @@
|
|||
"""WoodShop command-line interface.
|
||||
|
||||
Each subcommand loads the active scene, applies one operation, saves, and prints
|
||||
a short human-readable confirmation (which the driver speaks back via TTS). The
|
||||
CmdForge `wood-*` tools are thin wrappers around these subcommands, so the
|
||||
operation logic lives here once.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from .scene import Scene, SceneError
|
||||
from .units import to_inches
|
||||
|
||||
|
||||
def _fmt_len(inches: float) -> str:
|
||||
feet, rem = divmod(round(inches, 2), 12)
|
||||
if feet and rem:
|
||||
return f"{int(feet)} ft {rem:g} in"
|
||||
if feet:
|
||||
return f"{int(feet)} ft"
|
||||
return f"{rem:g} in"
|
||||
|
||||
|
||||
def cmd_place(scene: Scene, args) -> str:
|
||||
length = to_inches(args.length, default_unit=args.unit)
|
||||
width = to_inches(args.width, default_unit=args.unit) if getattr(args, "width", None) else None
|
||||
part = scene.place(args.stock, length, width_in=width)
|
||||
extra = f" ({_fmt_len(part.section_in[1])} wide)" if width else ""
|
||||
return f"Placed {part.id}: a {_fmt_len(length)} {part.stock}{extra}."
|
||||
|
||||
|
||||
_ANCHOR_ALIASES = {
|
||||
"end_a": "end_a", "start": "end_a", "near": "end_a", "beginning": "end_a",
|
||||
"end_b": "end_b", "end": "end_b", "far": "end_b", "tip": "end_b",
|
||||
}
|
||||
|
||||
|
||||
def normalize_anchor(value: str) -> str:
|
||||
"""Accept loose spoken anchors ('the end', 'start') -> end_a/end_b."""
|
||||
return _ANCHOR_ALIASES.get((value or "end_b").strip().lower(), "end_b")
|
||||
|
||||
|
||||
def cmd_join(scene: Scene, args) -> str:
|
||||
anchor = normalize_anchor(args.anchor)
|
||||
offset = to_inches(args.offset, default_unit=args.unit) if args.offset else 0.0
|
||||
joint = scene.join(args.part_a, args.part_b, angle_deg=args.angle,
|
||||
offset_in=offset, anchor=anchor)
|
||||
where = f" {_fmt_len(offset)} from {'the start' if anchor == 'end_a' else 'the end'}" if offset else ""
|
||||
return f"Joined {joint.part_b} to {joint.part_a} at {args.angle:g} degrees{where}."
|
||||
|
||||
|
||||
def cmd_sand(scene: Scene, args) -> str:
|
||||
part = scene.finish(args.part, kind="sanded")
|
||||
return f"Sanded {part.id}."
|
||||
|
||||
|
||||
def cmd_delete(scene: Scene, args) -> str:
|
||||
return scene.delete(args.part)
|
||||
|
||||
|
||||
def cmd_select(scene: Scene, args) -> str:
|
||||
part = scene.select(args.part)
|
||||
return f"Selected {part.id}" + (f" ('{part.name}')" if part.name else "") + "."
|
||||
|
||||
|
||||
def cmd_undo(scene: Scene, args) -> str:
|
||||
return scene.undo()
|
||||
|
||||
|
||||
def cmd_redo(scene: Scene, args) -> str:
|
||||
return scene.redo()
|
||||
|
||||
|
||||
def cmd_stand(scene: Scene, args) -> str:
|
||||
part = scene.stand(args.part, tilt_deg=args.tilt)
|
||||
how = "standing up" if part.is_vertical else f"tilted to {args.tilt:g}°"
|
||||
return f"Set {part.id} {how}."
|
||||
|
||||
|
||||
def cmd_lay(scene: Scene, args) -> str:
|
||||
part = scene.stand(args.part, tilt_deg=0.0)
|
||||
return f"Laid {part.id} flat."
|
||||
|
||||
|
||||
def cmd_rotate(scene: Scene, args) -> str:
|
||||
part = scene.orient(args.part, yaw=args.yaw, tilt=args.tilt, roll=args.roll)
|
||||
return (f"Oriented {part.id}: yaw {part.yaw_deg:g}°, "
|
||||
f"tilt {part.tilt_deg:g}°, roll {part.roll_deg:g}°.")
|
||||
|
||||
|
||||
def cmd_move(scene: Scene, args) -> str:
|
||||
dx = to_inches(args.dx, args.unit) if args.dx else 0.0
|
||||
dy = to_inches(args.dy, args.unit) if args.dy else 0.0
|
||||
dz = to_inches(args.dz, args.unit) if args.dz else 0.0
|
||||
part = scene.move(args.part, dx, dy, dz, absolute=args.absolute)
|
||||
verb = "Positioned" if args.absolute else "Moved"
|
||||
return f"{verb} {part.id}."
|
||||
|
||||
|
||||
def cmd_trim(scene: Scene, args) -> str:
|
||||
length = to_inches(args.length, default_unit=args.unit)
|
||||
part = scene.set_length(args.part, length)
|
||||
return f"Cut {part.id} to {_fmt_len(length)}."
|
||||
|
||||
|
||||
def cmd_copy(scene: Scene, args) -> str:
|
||||
dx = to_inches(args.dx, args.unit) if args.dx else 0.0
|
||||
dy = to_inches(args.dy, args.unit) if args.dy else 0.0
|
||||
dz = to_inches(args.dz, args.unit) if args.dz else 0.0
|
||||
part = scene.copy(args.part, dx, dy, dz)
|
||||
return f"Copied to {part.id}."
|
||||
|
||||
|
||||
def cmd_rename(scene: Scene, args) -> str:
|
||||
part = scene.rename(args.part, args.name)
|
||||
return f"Named {part.id} '{part.name}'."
|
||||
|
||||
|
||||
def cmd_clear(scene: Scene, args) -> str:
|
||||
return scene.clear()
|
||||
|
||||
|
||||
def cmd_save(scene: Scene, args) -> str:
|
||||
from .scene import project_path
|
||||
path = scene.save(project_path(args.name))
|
||||
return f"Saved project '{args.name}' ({len(scene.parts)} parts)."
|
||||
|
||||
|
||||
def cmd_open(scene: Scene, args) -> str:
|
||||
from .scene import project_path
|
||||
path = project_path(args.name)
|
||||
if not path.exists():
|
||||
from .scene import list_projects
|
||||
avail = ", ".join(list_projects()) or "none"
|
||||
raise SceneError(f"No project '{args.name}'. Available: {avail}")
|
||||
loaded = Scene.load(path)
|
||||
scene.__dict__.update(loaded.__dict__)
|
||||
return f"Opened project '{args.name}' ({len(scene.parts)} parts)."
|
||||
|
||||
|
||||
def cmd_projects(scene: Scene, args) -> str:
|
||||
from .scene import list_projects
|
||||
names = list_projects()
|
||||
return "Saved projects: " + (", ".join(names) if names else "none yet")
|
||||
|
||||
|
||||
def _optlen(v, unit="inch"):
|
||||
return to_inches(v, default_unit=unit) if v not in (None, "") else None
|
||||
|
||||
|
||||
def _optdeg(v):
|
||||
return float(v) if v not in (None, "") else None
|
||||
|
||||
|
||||
def cmd_feature(scene: Scene, args) -> str:
|
||||
feat = scene.add_feature(
|
||||
args.part, args.kind, face=args.face,
|
||||
along_in=_optlen(args.along), across_in=_optlen(args.across),
|
||||
width_in=_optlen(args.width), height_in=_optlen(args.height),
|
||||
depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter),
|
||||
rotation_deg=_optdeg(args.rotation))
|
||||
part = scene.find_feature(feat.id)[0]
|
||||
return f"Added {feat.kind} ({feat.id}) to {part.id} on {feat.face}."
|
||||
|
||||
|
||||
def cmd_feature_edit(scene: Scene, args) -> str:
|
||||
feat = scene.edit_feature(
|
||||
args.fid, face=args.face,
|
||||
along_in=_optlen(args.along), across_in=_optlen(args.across),
|
||||
width_in=_optlen(args.width), height_in=_optlen(args.height),
|
||||
depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter),
|
||||
rotation_deg=_optdeg(args.rotation))
|
||||
return f"Updated feature {feat.id}."
|
||||
|
||||
|
||||
def cmd_connect(scene: Scene, args) -> str:
|
||||
return scene.connect(args.anchor, args.moving)
|
||||
|
||||
|
||||
def cmd_connections(scene: Scene, args) -> str:
|
||||
if not scene.connections:
|
||||
return "No connections."
|
||||
lines = []
|
||||
for c in scene.connections:
|
||||
if not scene._conn_valid(c):
|
||||
lines.append(f" {c.id}: (stale)")
|
||||
continue
|
||||
ap, mp = scene.feature_owner(c.anchor), scene.feature_owner(c.moving)
|
||||
off = f" (backed off {c.backed_off_in:g}\")" if c.backed_off_in else ""
|
||||
lines.append(f" {c.id}: {mp.id}.{c.moving} → {ap.id}.{c.anchor}{off}")
|
||||
groups = [g for g in scene.groups() if len(g) > 1]
|
||||
if groups:
|
||||
lines.append("Assemblies: " + "; ".join("+".join(g) for g in groups))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cmd_disconnect(scene: Scene, args) -> str:
|
||||
return scene.disconnect(cid=args.connection)
|
||||
|
||||
|
||||
def cmd_explode(scene: Scene, args) -> str:
|
||||
return scene.explode(to_inches(args.distance))
|
||||
|
||||
|
||||
def cmd_assemble(scene: Scene, args) -> str:
|
||||
return scene.assemble()
|
||||
|
||||
|
||||
def cmd_feature_delete(scene: Scene, args) -> str:
|
||||
return scene.delete_feature(args.fid)
|
||||
|
||||
|
||||
def cmd_feature_list(scene: Scene, args) -> str:
|
||||
rows = [(p, f) for p in scene.parts for f in p.features
|
||||
if not args.part or p.id == args.part or p.name == args.part]
|
||||
if not rows:
|
||||
return "No features."
|
||||
lines = []
|
||||
for p, f in rows:
|
||||
dims = (f"⌀{f.diameter_in:g}" if f.kind == "hole"
|
||||
else f"{f.width_in:g}×{f.height_in:g}×{f.depth_in:g}")
|
||||
lines.append(f" {f.id}: {f.kind} on {p.id} {f.face} ({dims})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cmd_export(scene: Scene, args) -> str:
|
||||
from .geometry import export # lazy: keeps build123d out of the core path
|
||||
path = export(scene, args.path)
|
||||
return f"Exported {len(scene.parts)} part(s) to {path}."
|
||||
|
||||
|
||||
def cmd_cutlist(scene: Scene, args) -> str:
|
||||
from .cutlist import format_cutlist # lazy
|
||||
return format_cutlist(scene)
|
||||
|
||||
|
||||
def cmd_render(scene: Scene, args) -> str:
|
||||
from .viewer import render_to_file # lazy: pulls in pyvista
|
||||
if not scene.parts:
|
||||
return "Nothing to render — the scene is empty."
|
||||
path = render_to_file(scene, args.path)
|
||||
return f"Rendered {len(scene.parts)} part(s) to {path}."
|
||||
|
||||
|
||||
def _describe_part(p) -> str:
|
||||
bits = [f"{_fmt_len(p.length_in)} {p.stock}"]
|
||||
if p.name:
|
||||
bits.append(f'"{p.name}"')
|
||||
if p.is_vertical:
|
||||
bits.append("vertical")
|
||||
elif p.tilt_deg:
|
||||
bits.append(f"tilt {p.tilt_deg:g}°")
|
||||
if p.yaw_deg:
|
||||
bits.append(f"yaw {p.yaw_deg:g}°")
|
||||
if p.finishes:
|
||||
bits.append(f"[{', '.join(p.finishes)}]")
|
||||
if p.features:
|
||||
bits.append(f"{{{', '.join(f.kind for f in p.features)}}}")
|
||||
return f" {p.id}: " + ", ".join(bits)
|
||||
|
||||
|
||||
def cmd_status(scene: Scene, args) -> str:
|
||||
if not scene.parts:
|
||||
return "The scene is empty."
|
||||
lines = [f"{len(scene.parts)} part(s), {len(scene.joints)} joint(s); "
|
||||
f"selection: {scene.selection or 'none'}"]
|
||||
lines += [_describe_part(p) for p in scene.parts]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="woodshop", description="Voice/CLI woodworking operations.")
|
||||
p.add_argument("--scene", help="Path to scene.json (default: $WOODSHOP_SCENE or XDG data dir)")
|
||||
# No subcommand launches the GUI studio (see main()).
|
||||
sub = p.add_subparsers(dest="command", required=False)
|
||||
|
||||
sp = sub.add_parser("place", help="Place a new board")
|
||||
sp.add_argument("stock", help="Nominal stock, e.g. 2x4, or plywood like ply-3/4")
|
||||
sp.add_argument("length", help="Length, e.g. '6 ft' or '72'")
|
||||
sp.add_argument("--width", help="Panel width (required for plywood), e.g. '24 in'")
|
||||
sp.add_argument("--unit", default="inch", help="Default unit for bare numbers (inch|foot)")
|
||||
sp.set_defaults(func=cmd_place)
|
||||
|
||||
sp = sub.add_parser("join", help="Join one board to another")
|
||||
sp.add_argument("part_b", help="Board being attached, e.g. p2")
|
||||
sp.add_argument("--to", dest="part_a", default=None, help="Board to attach to (default: selection)")
|
||||
sp.add_argument("--angle", type=float, default=90.0, help="Angle in degrees")
|
||||
sp.add_argument("--offset", default=None, help="Distance from anchor, e.g. '10 in'")
|
||||
sp.add_argument("--anchor", default="end_b",
|
||||
help="Measure offset from start (end_a/start) or far end (end_b/end)")
|
||||
sp.add_argument("--unit", default="inch")
|
||||
sp.set_defaults(func=cmd_join)
|
||||
|
||||
sp = sub.add_parser("sand", help="Sand a board")
|
||||
sp.add_argument("part", nargs="?", default=None, help="Board id (default: selection)")
|
||||
sp.set_defaults(func=cmd_sand)
|
||||
|
||||
sp = sub.add_parser("delete", help="Delete a board")
|
||||
sp.add_argument("part", nargs="?", default=None)
|
||||
sp.set_defaults(func=cmd_delete)
|
||||
|
||||
sp = sub.add_parser("stand", help="Stand a board up (vertical), e.g. a leg")
|
||||
sp.add_argument("part", nargs="?", default=None)
|
||||
sp.add_argument("--tilt", type=float, default=90.0, help="Tilt degrees (90 = straight up)")
|
||||
sp.set_defaults(func=cmd_stand)
|
||||
|
||||
sp = sub.add_parser("lay", help="Lay a board flat (horizontal)")
|
||||
sp.add_argument("part", nargs="?", default=None)
|
||||
sp.set_defaults(func=cmd_lay)
|
||||
|
||||
sp = sub.add_parser("rotate", help="Set a board's orientation angles")
|
||||
sp.add_argument("part", nargs="?", default=None)
|
||||
sp.add_argument("--yaw", type=float, default=None, help="Heading in the XY plane")
|
||||
sp.add_argument("--tilt", type=float, default=None, help="Elevation toward vertical")
|
||||
sp.add_argument("--roll", type=float, default=None, help="Rotation about the board's axis")
|
||||
sp.set_defaults(func=cmd_rotate)
|
||||
|
||||
sp = sub.add_parser("move", help="Move a board by an offset (or set its position)")
|
||||
sp.add_argument("part", nargs="?", default=None)
|
||||
sp.add_argument("--dx", default=None, help="e.g. '5 in', '-2 ft'")
|
||||
sp.add_argument("--dy", default=None)
|
||||
sp.add_argument("--dz", default=None)
|
||||
sp.add_argument("--absolute", action="store_true", help="Treat dx/dy/dz as absolute position")
|
||||
sp.add_argument("--unit", default="inch")
|
||||
sp.set_defaults(func=cmd_move)
|
||||
|
||||
sp = sub.add_parser("trim", help="Cut a board to a new length")
|
||||
sp.add_argument("length", help="New length, e.g. '4 ft'")
|
||||
sp.add_argument("--part", default=None)
|
||||
sp.add_argument("--unit", default="inch")
|
||||
sp.set_defaults(func=cmd_trim)
|
||||
|
||||
sp = sub.add_parser("copy", help="Duplicate a board, offset by dx/dy/dz")
|
||||
sp.add_argument("part", nargs="?", default=None)
|
||||
sp.add_argument("--dx", default=None)
|
||||
sp.add_argument("--dy", default=None)
|
||||
sp.add_argument("--dz", default=None)
|
||||
sp.add_argument("--unit", default="inch")
|
||||
sp.set_defaults(func=cmd_copy)
|
||||
|
||||
sp = sub.add_parser("rename", help="Give a board a human-friendly name")
|
||||
sp.add_argument("name", help="e.g. 'front-left leg'")
|
||||
sp.add_argument("--part", default=None)
|
||||
sp.set_defaults(func=cmd_rename)
|
||||
|
||||
def add_dim_flags(parser, face_default="end_b"):
|
||||
parser.add_argument("--face", default=face_default,
|
||||
help="end_a|end_b|top|bottom|left|right")
|
||||
parser.add_argument("--along", help="position along the board / 1st offset")
|
||||
parser.add_argument("--across", help="offset across the face / 2nd offset")
|
||||
parser.add_argument("--width", help="feature width")
|
||||
parser.add_argument("--height", help="feature height/thickness")
|
||||
parser.add_argument("--depth", help="cut depth / tenon protrusion")
|
||||
parser.add_argument("--diameter", help="hole diameter")
|
||||
parser.add_argument("--rotation", help="rotate the feature about its face normal (deg)")
|
||||
|
||||
sp = sub.add_parser("feature", help="Add a joinery feature (tenon/mortise/hole/slot)")
|
||||
sp.add_argument("kind", help="tenon | mortise | hole | slot | dado | rabbet")
|
||||
sp.add_argument("--part", default=None, help="Board id/name (default: selection)")
|
||||
add_dim_flags(sp)
|
||||
sp.set_defaults(func=cmd_feature)
|
||||
|
||||
sp = sub.add_parser("feature-edit", help="Adjust an existing feature")
|
||||
sp.add_argument("fid", help="Feature id, e.g. f1")
|
||||
add_dim_flags(sp, face_default=None)
|
||||
sp.set_defaults(func=cmd_feature_edit)
|
||||
|
||||
sp = sub.add_parser("feature-delete", help="Remove a feature")
|
||||
sp.add_argument("fid")
|
||||
sp.set_defaults(func=cmd_feature_delete)
|
||||
|
||||
sp = sub.add_parser("features", help="List joinery features")
|
||||
sp.add_argument("--part", default=None)
|
||||
sp.set_defaults(func=cmd_feature_list)
|
||||
|
||||
sp = sub.add_parser("connect", help="Move a board so its feature seats into another")
|
||||
sp.add_argument("anchor", help="Anchor feature id (stays put)")
|
||||
sp.add_argument("moving", help="Feature id whose board moves to mate")
|
||||
sp.set_defaults(func=cmd_connect)
|
||||
|
||||
sub.add_parser("connections", help="List connections / assemblies").set_defaults(func=cmd_connections)
|
||||
|
||||
sp = sub.add_parser("disconnect", help="Break a connection (pieces stay in place)")
|
||||
sp.add_argument("connection", help="Connection id, e.g. c1")
|
||||
sp.set_defaults(func=cmd_disconnect)
|
||||
|
||||
sp = sub.add_parser("explode", help="Back connections off along their joint axes")
|
||||
sp.add_argument("distance", help="Distance, e.g. '3 in'")
|
||||
sp.set_defaults(func=cmd_explode)
|
||||
|
||||
sub.add_parser("assemble", help="Re-fit all connections (seat the joints)").set_defaults(func=cmd_assemble)
|
||||
|
||||
sp = sub.add_parser("save", help="Save the current scene as a named project")
|
||||
sp.add_argument("name", help="Project name, e.g. 'coffee table'")
|
||||
sp.set_defaults(func=cmd_save)
|
||||
|
||||
sp = sub.add_parser("open", help="Open a saved project")
|
||||
sp.add_argument("name", help="Project name to open")
|
||||
sp.set_defaults(func=cmd_open)
|
||||
|
||||
sub.add_parser("projects", help="List saved projects").set_defaults(func=cmd_projects)
|
||||
|
||||
sp = sub.add_parser("export", help="Export the scene to STL or STEP")
|
||||
sp.add_argument("path", help="Output file, e.g. table.stl or table.step")
|
||||
sp.set_defaults(func=cmd_export)
|
||||
|
||||
sp = sub.add_parser("render", help="Save a PNG image of the scene (works headless)")
|
||||
sp.add_argument("path", help="Output image, e.g. table.png")
|
||||
sp.set_defaults(func=cmd_render)
|
||||
|
||||
sub.add_parser("cutlist", help="Show the cut list / bill of materials").set_defaults(func=cmd_cutlist)
|
||||
sp = sub.add_parser("select", help="Set the current selection")
|
||||
sp.add_argument("part", help="Board id or name to select")
|
||||
sp.set_defaults(func=cmd_select)
|
||||
|
||||
sub.add_parser("undo", help="Undo the last operation").set_defaults(func=cmd_undo)
|
||||
sub.add_parser("redo", help="Redo the last undone operation").set_defaults(func=cmd_redo)
|
||||
sub.add_parser("clear", help="Clear the scene").set_defaults(func=cmd_clear)
|
||||
sub.add_parser("status", help="Show the scene").set_defaults(func=cmd_status)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if not args.command: # bare `woodshop` -> launch the GUI studio
|
||||
from .gui.app import main as gui_main # lazy: keep Qt out of CLI use
|
||||
return gui_main(["--scene", args.scene] if args.scene else [])
|
||||
scene = Scene.load(args.scene)
|
||||
try:
|
||||
message = args.func(scene, args)
|
||||
except (SceneError, ValueError, KeyError) as exc:
|
||||
print(str(exc).strip('"'), file=sys.stderr)
|
||||
return 1
|
||||
if args.command not in ("status", "export", "cutlist", "render", "save",
|
||||
"projects", "features", "connections"):
|
||||
scene.save(args.scene)
|
||||
print(message)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
"""Cut list, board-feet, and a stock shopping estimate.
|
||||
|
||||
This is the workshop-assistant payoff: turn the model into something you can
|
||||
actually build from. Board-feet use NOMINAL dimensions (the lumber-industry
|
||||
convention) parsed from the stock name; the shopping estimate assumes standard
|
||||
8-foot sticks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
from .scene import Scene
|
||||
|
||||
STICK_LENGTH_IN = 96.0 # a standard 8' stick
|
||||
|
||||
|
||||
def nominal_dims(stock: str) -> tuple[float, float]:
|
||||
"""'2x4' -> (2.0, 4.0). Falls back to (1, 1) for odd names."""
|
||||
try:
|
||||
t, w = stock.lower().split("x")[:2]
|
||||
return float(t), float(w)
|
||||
except (ValueError, IndexError):
|
||||
return 1.0, 1.0
|
||||
|
||||
|
||||
def board_feet(stock: str, length_in: float) -> float:
|
||||
t, w = nominal_dims(stock)
|
||||
return t * w * length_in / 144.0
|
||||
|
||||
|
||||
def cut_length(part) -> float:
|
||||
"""The length to cut the board to, including any tenon that protrudes past an
|
||||
end. Subtractive features (mortise/hole/slot/chamfer) don't change the stock
|
||||
length you buy, so they're ignored here."""
|
||||
extra = sum(f.depth_in for f in part.features
|
||||
if f.kind == "tenon" and f.face in ("end_a", "end_b"))
|
||||
return part.length_in + extra
|
||||
|
||||
|
||||
def _fmt_len(inches: float) -> str:
|
||||
feet, rem = divmod(round(inches, 2), 12)
|
||||
if feet and rem:
|
||||
return f"{int(feet)}' {rem:g}\""
|
||||
if feet:
|
||||
return f"{int(feet)}'"
|
||||
return f'{rem:g}"'
|
||||
|
||||
|
||||
def cut_rows(scene: Scene) -> list[dict]:
|
||||
"""One row per distinct (stock, length, width), with a count. Lumber rows
|
||||
carry board_feet; plywood rows carry sq_ft (it's a cut panel)."""
|
||||
from .lumber import is_plywood
|
||||
groups: dict[tuple, int] = defaultdict(int)
|
||||
for p in scene.parts:
|
||||
groups[(p.stock, round(cut_length(p), 2), round(p.section_in[1], 2))] += 1
|
||||
rows = []
|
||||
for (stock, length, width), count in sorted(groups.items()):
|
||||
row = {"stock": stock, "length_in": length, "width_in": width,
|
||||
"count": count, "plywood": is_plywood(stock)}
|
||||
if row["plywood"]:
|
||||
row["sq_ft"] = (length * width / 144.0) * count
|
||||
else:
|
||||
row["board_feet"] = board_feet(stock, length) * count
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def shopping(scene: Scene) -> dict[str, int]:
|
||||
"""How many to buy per stock: lumber in 8' sticks, plywood in 4×8 sheets,
|
||||
from the actual cutting-stock nesting (kerf-aware)."""
|
||||
from .layout import stock_counts
|
||||
return dict(sorted(stock_counts(scene).items()))
|
||||
|
||||
|
||||
def format_cutlist(scene: Scene) -> str:
|
||||
if not scene.parts:
|
||||
return "Nothing to cut yet — the scene is empty."
|
||||
rows = cut_rows(scene)
|
||||
lines = ["CUT LIST"]
|
||||
for r in rows:
|
||||
if r["plywood"]:
|
||||
lines.append(f" {r['count']:>2} × {r['stock']:<7} {_fmt_len(r['width_in'])} × "
|
||||
f"{_fmt_len(r['length_in'])} ({r['sq_ft']:.1f} sq ft)")
|
||||
else:
|
||||
lines.append(f" {r['count']:>2} × {r['stock']:<7} @ {_fmt_len(r['length_in']):<8}"
|
||||
f" ({r['board_feet']:.1f} bd-ft)")
|
||||
total_bf = sum(r.get("board_feet", 0) for r in rows)
|
||||
total_sf = sum(r.get("sq_ft", 0) for r in rows)
|
||||
tot = f"{len(scene.parts)} board(s)"
|
||||
if total_bf:
|
||||
tot += f", {total_bf:.1f} board-feet"
|
||||
if total_sf:
|
||||
tot += f", {total_sf:.1f} sq ft plywood"
|
||||
lines.append(" Total: " + tot)
|
||||
if any(cut_length(p) > p.length_in for p in scene.parts):
|
||||
lines.append(" (cut lengths include protruding tenons)")
|
||||
lines.append("SHOPPING (8' sticks / 4×8 sheets, +10% waste)")
|
||||
for stock, qty in shopping(scene).items():
|
||||
unit = "sheet(s)" if stock.startswith("ply-") else "stick(s)"
|
||||
lines.append(f" {qty} × {stock} {unit}")
|
||||
return "\n".join(lines)
|
||||
|
|
@ -1,658 +0,0 @@
|
|||
"""CutPlan: the deterministic shop-output artifact everything else builds on.
|
||||
|
||||
A CutPlan packs the scene's required pieces (CutItems) onto physical stock
|
||||
(StockPieces) at explicit positions (Placements), with kerf, waste, warnings,
|
||||
and a detailed score. It is plain-dataclass, JSON-serializable, and uses stable
|
||||
ids (never list position) so manual edits, alternate strategies, instructions,
|
||||
and jig references can all point at the same objects.
|
||||
|
||||
The math here is deterministic and inspectable; AI is never used for numbers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
|
||||
from .cutlist import cut_length
|
||||
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
|
||||
|
||||
_EPS = 1e-6
|
||||
|
||||
|
||||
def _stable_hash(text: str) -> int:
|
||||
"""Process-stable hash (unlike built-in hash(), which is salted per run)."""
|
||||
return int(hashlib.md5(text.encode()).hexdigest()[:8], 16)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShopSettings:
|
||||
kerf_in: float = 0.125
|
||||
stick_len_in: float = 96.0 # an 8' stick
|
||||
sheet_w_in: float = 48.0
|
||||
sheet_l_in: float = 96.0
|
||||
offcut_usable_in: float = 12.0 # lumber offcut ≥ this counts as reusable
|
||||
offcut_usable_sqft: float = 1.0 # plywood reusable threshold
|
||||
allow_plywood_rotation: bool = True
|
||||
grain_direction: bool = False # honor grain (future; disables rotation)
|
||||
# tolerances — defaults present from day one even before they're in the UI
|
||||
mortise_tenon_clearance_in: float = 1 / 32
|
||||
sanding_allowance_in: float = 0.0
|
||||
reveal_in: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict | None) -> "ShopSettings":
|
||||
valid = {f.name for f in fields(cls)}
|
||||
return cls(**{k: v for k, v in (d or {}).items() if k in valid})
|
||||
|
||||
|
||||
@dataclass
|
||||
class CutItem:
|
||||
id: str
|
||||
part_id: str
|
||||
stock: str
|
||||
length_in: float
|
||||
width_in: float
|
||||
is_sheet: bool
|
||||
note: str = "" # e.g. "incl. tenon"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Placement:
|
||||
id: str
|
||||
item_id: str
|
||||
x_in: float # along the stock length
|
||||
y_in: float = 0.0 # across the stock width (plywood)
|
||||
len_in: float = 0.0 # placed footprint along length
|
||||
wid_in: float = 0.0 # placed footprint across width
|
||||
rotated: bool = False
|
||||
locked: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class WasteRegion:
|
||||
x_in: float
|
||||
length_in: float
|
||||
width_in: float = 0.0 # 0 -> full section width (lumber offcut)
|
||||
reusable: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class StockPiece:
|
||||
id: str
|
||||
stock: str
|
||||
is_sheet: bool
|
||||
length_in: float
|
||||
width_in: float
|
||||
placements: list = field(default_factory=list) # Placement
|
||||
waste: list = field(default_factory=list) # WasteRegion
|
||||
|
||||
|
||||
@dataclass
|
||||
class CutPlan:
|
||||
settings: ShopSettings
|
||||
items: list = field(default_factory=list) # CutItem
|
||||
stock_pieces: list = field(default_factory=list) # StockPiece
|
||||
unplaced: list = field(default_factory=list) # CutItem ids that didn't fit
|
||||
strategy: str = "decreasing"
|
||||
score: dict = field(default_factory=dict)
|
||||
warnings: list = field(default_factory=list)
|
||||
|
||||
def item(self, item_id: str) -> CutItem:
|
||||
return next(i for i in self.items if i.id == item_id)
|
||||
|
||||
# ----- serialization (JSON-friendly) -------------------------------
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"settings": self.settings.to_dict(),
|
||||
"items": [asdict(i) for i in self.items],
|
||||
"stock_pieces": [asdict(sp) for sp in self.stock_pieces],
|
||||
"unplaced": list(self.unplaced),
|
||||
"strategy": self.strategy,
|
||||
"score": self.score,
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "CutPlan":
|
||||
def sp_from(s):
|
||||
return StockPiece(
|
||||
id=s["id"], stock=s["stock"], is_sheet=s["is_sheet"],
|
||||
length_in=s["length_in"], width_in=s["width_in"],
|
||||
placements=[Placement(**p) for p in s.get("placements", [])],
|
||||
waste=[WasteRegion(**w) for w in s.get("waste", [])])
|
||||
return cls(
|
||||
settings=ShopSettings.from_dict(d.get("settings")),
|
||||
items=[CutItem(**i) for i in d.get("items", [])],
|
||||
stock_pieces=[sp_from(s) for s in d.get("stock_pieces", [])],
|
||||
unplaced=list(d.get("unplaced", [])),
|
||||
strategy=d.get("strategy", "decreasing"),
|
||||
score=d.get("score", {}),
|
||||
warnings=list(d.get("warnings", [])))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
def _cut_items(scene) -> list:
|
||||
items = []
|
||||
for n, p in enumerate(scene.parts, 1):
|
||||
ln = cut_length(p)
|
||||
items.append(CutItem(
|
||||
id=f"ci{n}", part_id=p.id, stock=p.stock,
|
||||
length_in=round(ln, 3), width_in=round(p.section_in[1], 3),
|
||||
is_sheet=is_plywood(p.stock),
|
||||
note="incl. tenon" if ln > p.length_in + _EPS else ""))
|
||||
return items
|
||||
|
||||
|
||||
def _ordered(items, strategy):
|
||||
key = lambda it: max(it.length_in, it.width_in)
|
||||
if strategy == "increasing":
|
||||
return sorted(items, key=key)
|
||||
if strategy.startswith("shuffle"): # "shuffle", "shuffle1", ... distinct salts
|
||||
salt = strategy[7:]
|
||||
return sorted(items, key=lambda it: _stable_hash(it.id + salt))
|
||||
return sorted(items, key=key, reverse=True) # decreasing (FFD) & bestfit (BFD)
|
||||
|
||||
|
||||
def _lumber_avail(sp, s):
|
||||
end = max((p.x_in + p.len_in for p in sp.placements), default=0.0)
|
||||
cursor = end + (s.kerf_in if sp.placements else 0.0)
|
||||
return sp.length_in - cursor, cursor # (room left, x where the next piece starts)
|
||||
|
||||
|
||||
def _pack_lumber(items, stock, s: ShopSettings, ids, fit="first") -> tuple[list, list]:
|
||||
"""Pack lengths into sticks. fit='first' (FFD) or 'best' (BFD = tightest fit)."""
|
||||
sticks, unplaced = [], []
|
||||
for it in items:
|
||||
if it.length_in > s.stick_len_in + _EPS:
|
||||
unplaced.append(it.id)
|
||||
continue
|
||||
candidates = [(sp,) + _lumber_avail(sp, s) for sp in sticks]
|
||||
candidates = [(sp, room, x) for sp, room, x in candidates if it.length_in <= room + _EPS]
|
||||
if candidates:
|
||||
sp, _room, x = (min(candidates, key=lambda c: c[1]) if fit == "best"
|
||||
else candidates[0])
|
||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=x,
|
||||
len_in=it.length_in, wid_in=it.width_in))
|
||||
else:
|
||||
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=False,
|
||||
length_in=s.stick_len_in, width_in=it.width_in)
|
||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0,
|
||||
len_in=it.length_in, wid_in=it.width_in))
|
||||
sticks.append(sp)
|
||||
for sp in sticks: # offcut at the end of each stick
|
||||
end = max((p.x_in + p.len_in for p in sp.placements), default=0.0)
|
||||
off = round(sp.length_in - end, 3)
|
||||
if off > 0.5:
|
||||
sp.waste.append(WasteRegion(x_in=end, length_in=off, width_in=sp.width_in,
|
||||
reusable=off >= s.offcut_usable_in))
|
||||
return sticks, unplaced
|
||||
|
||||
|
||||
def _pack_plywood(items, stock, s: ShopSettings, ids) -> tuple[list, list]:
|
||||
"""Shelf packing onto sheets (no rotation yet — Phase 1 adds it)."""
|
||||
sheets, rest = [], list(items)
|
||||
unplaced = []
|
||||
|
||||
def orientations(it):
|
||||
# (len_along_sheet, width_across, rotated). Rotation allowed unless grain is honored.
|
||||
opts = [(it.length_in, it.width_in, False)]
|
||||
if s.allow_plywood_rotation and not s.grain_direction and it.width_in != it.length_in:
|
||||
opts.append((it.width_in, it.length_in, True))
|
||||
return opts
|
||||
|
||||
def pack_one(panels):
|
||||
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=True,
|
||||
length_in=s.sheet_l_in, width_in=s.sheet_w_in)
|
||||
shelves, leftover = [], [] # shelves: [y, height, x_cursor]
|
||||
for it in panels:
|
||||
done = False
|
||||
for pl, pw, rot in orientations(it):
|
||||
for sh in shelves: # fit into an existing shelf
|
||||
x = sh[2] + (s.kerf_in if sh[2] else 0.0)
|
||||
if pw <= sh[1] + _EPS and x + pl <= s.sheet_l_in + _EPS:
|
||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=x, y_in=sh[0],
|
||||
len_in=pl, wid_in=pw, rotated=rot))
|
||||
sh[2] = x + pl
|
||||
done = True
|
||||
break
|
||||
if done:
|
||||
break
|
||||
if not done:
|
||||
for pl, pw, rot in orientations(it): # start a new shelf
|
||||
y = (shelves[-1][0] + shelves[-1][1] + s.kerf_in) if shelves else 0.0
|
||||
if y + pw <= s.sheet_w_in + _EPS and pl <= s.sheet_l_in + _EPS:
|
||||
shelves.append([y, pw, pl])
|
||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0, y_in=y,
|
||||
len_in=pl, wid_in=pw, rotated=rot))
|
||||
done = True
|
||||
break
|
||||
if not done:
|
||||
leftover.append(it)
|
||||
return sp, leftover
|
||||
|
||||
while rest:
|
||||
sp, rest = pack_one(rest)
|
||||
if not sp.placements: # a single panel bigger than a sheet
|
||||
big = rest.pop(0)
|
||||
unplaced.append(big.id)
|
||||
continue
|
||||
sheets.append(sp)
|
||||
return sheets, unplaced
|
||||
|
||||
|
||||
def _min_bins(order_sizes, cap, kerf):
|
||||
"""Branch-and-bound minimum number of bins (sticks) for small lumber jobs.
|
||||
order_sizes = [(idx, length)]; returns list of bins (each a list of idx)."""
|
||||
order = sorted(order_sizes, key=lambda t: -t[1])
|
||||
ffd = [] # first-fit-decreasing seed / bound
|
||||
for idx, size in order:
|
||||
for b in ffd:
|
||||
if b[0] + kerf + size <= cap + _EPS:
|
||||
b[0] += kerf + size
|
||||
b[1].append(idx)
|
||||
break
|
||||
else:
|
||||
ffd.append([size, [idx]])
|
||||
best = [[list(b[1]) for b in ffd]]
|
||||
best_count = [len(ffd)]
|
||||
bins = []
|
||||
|
||||
def dfs(k):
|
||||
if len(bins) >= best_count[0]:
|
||||
return
|
||||
if k == len(order):
|
||||
best_count[0] = len(bins)
|
||||
best[0] = [list(b[1]) for b in bins]
|
||||
return
|
||||
idx, size = order[k]
|
||||
for b in bins:
|
||||
if b[0] + kerf + size <= cap + _EPS:
|
||||
b[0] += kerf + size
|
||||
b[1].append(idx)
|
||||
dfs(k + 1)
|
||||
b[1].pop()
|
||||
b[0] -= kerf + size
|
||||
bins.append([size, [idx]])
|
||||
dfs(k + 1)
|
||||
bins.pop()
|
||||
|
||||
dfs(0)
|
||||
return best[0]
|
||||
|
||||
|
||||
def _pack_lumber_exact(items, stock, s, ids) -> tuple[list, list]:
|
||||
"""Provably-minimum stick count for small jobs (≤12 pieces); else best-fit."""
|
||||
if len(items) > 12:
|
||||
return _pack_lumber(items, stock, s, ids, fit="best")
|
||||
oversize = [it.id for it in items if it.length_in > s.stick_len_in + _EPS]
|
||||
packable = [(i, it.length_in) for i, it in enumerate(items)
|
||||
if it.length_in <= s.stick_len_in + _EPS]
|
||||
sticks = []
|
||||
for bin_idx in _min_bins(packable, s.stick_len_in, s.kerf_in):
|
||||
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=False,
|
||||
length_in=s.stick_len_in, width_in=items[bin_idx[0]].width_in)
|
||||
x = 0.0
|
||||
for idx in bin_idx:
|
||||
it = items[idx]
|
||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=round(x, 3),
|
||||
len_in=it.length_in, wid_in=it.width_in))
|
||||
x += it.length_in + s.kerf_in
|
||||
end = x - s.kerf_in
|
||||
off = round(s.stick_len_in - end, 3)
|
||||
if off > 0.5:
|
||||
sp.waste.append(WasteRegion(x_in=round(end, 3), length_in=off, width_in=sp.width_in,
|
||||
reusable=off >= s.offcut_usable_in))
|
||||
sticks.append(sp)
|
||||
return sticks, oversize
|
||||
|
||||
|
||||
def _pack_plywood_guillotine(items, stock, s, ids) -> tuple[list, list]:
|
||||
"""Guillotine free-rectangle packing (best-area-fit + rotation) — usually
|
||||
tighter than shelf packing for mixed panel sizes."""
|
||||
sheets, unplaced, rects = [], [], {}
|
||||
|
||||
def orientations(it):
|
||||
opts = [(it.length_in, it.width_in, False)]
|
||||
if s.allow_plywood_rotation and not s.grain_direction and it.length_in != it.width_in:
|
||||
opts.append((it.width_in, it.length_in, True))
|
||||
return opts
|
||||
|
||||
def new_sheet():
|
||||
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=True,
|
||||
length_in=s.sheet_l_in, width_in=s.sheet_w_in)
|
||||
sheets.append(sp)
|
||||
rects[sp.id] = [[0.0, 0.0, s.sheet_l_in, s.sheet_w_in]]
|
||||
return sp
|
||||
|
||||
for it in sorted(items, key=lambda i: -(i.length_in * i.width_in)):
|
||||
best = None
|
||||
for sp in sheets:
|
||||
for ri, (rx, ry, rw, rh) in enumerate(rects[sp.id]):
|
||||
for pl, pw, rot in orientations(it):
|
||||
if pl <= rw + _EPS and pw <= rh + _EPS:
|
||||
leftover = rw * rh - pl * pw
|
||||
if best is None or leftover < best[0]:
|
||||
best = (leftover, sp, ri, pl, pw, rot, rx, ry, rw, rh)
|
||||
if best is None:
|
||||
sp = new_sheet()
|
||||
rx, ry, rw, rh = rects[sp.id][0]
|
||||
for pl, pw, rot in orientations(it):
|
||||
if pl <= rw + _EPS and pw <= rh + _EPS:
|
||||
best = (0, sp, 0, pl, pw, rot, rx, ry, rw, rh)
|
||||
break
|
||||
if best is None: # bigger than a whole sheet
|
||||
unplaced.append(it.id)
|
||||
sheets.pop()
|
||||
del rects[sp.id]
|
||||
continue
|
||||
_lo, sp, ri, pl, pw, rot, rx, ry, rw, rh = best
|
||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=round(rx, 3), y_in=round(ry, 3),
|
||||
len_in=pl, wid_in=pw, rotated=rot))
|
||||
del rects[sp.id][ri]
|
||||
k = s.kerf_in
|
||||
for r in ([rx + pl + k, ry, rw - pl - k, pw], [rx, ry + pw + k, rw, rh - pw - k]):
|
||||
if r[2] > 0.5 and r[3] > 0.5:
|
||||
rects[sp.id].append(r)
|
||||
return sheets, unplaced
|
||||
|
||||
|
||||
def build_cut_plan(scene, settings: ShopSettings | None = None,
|
||||
strategy: str = "decreasing") -> CutPlan:
|
||||
s = settings or ShopSettings()
|
||||
items = _cut_items(scene)
|
||||
by_id = {it.id: it for it in items}
|
||||
|
||||
counter = {"n": 0}
|
||||
|
||||
def ids(prefix="pl"):
|
||||
counter["n"] += 1
|
||||
return f"{prefix}{counter['n']}"
|
||||
|
||||
fit = "best" if strategy == "bestfit" else "first"
|
||||
by_stock: dict[str, list] = {}
|
||||
for it in _ordered(items, strategy):
|
||||
by_stock.setdefault(it.stock, []).append(it)
|
||||
|
||||
stock_pieces, unplaced, warnings = [], [], []
|
||||
for stock, its in by_stock.items():
|
||||
if its[0].is_sheet:
|
||||
sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine"
|
||||
else _pack_plywood(its, stock, s, ids))
|
||||
elif strategy == "exact":
|
||||
sps, un = _pack_lumber_exact(its, stock, s, ids)
|
||||
else:
|
||||
sps, un = _pack_lumber(its, stock, s, ids, fit=fit)
|
||||
stock_pieces += sps
|
||||
unplaced += un
|
||||
for item_id in unplaced:
|
||||
it = by_id[item_id]
|
||||
warnings.append(f"{it.part_id} ({it.stock}) doesn't fit standard stock — too big.")
|
||||
|
||||
score = _score(stock_pieces, s, strategy, warnings)
|
||||
if score["yield_pct"] < 50 and stock_pieces:
|
||||
warnings.append(f"Low yield: only {score['yield_pct']:.0f}% of bought stock is used.")
|
||||
return CutPlan(settings=s, items=items, stock_pieces=stock_pieces,
|
||||
unplaced=unplaced, strategy=strategy, score=score, warnings=warnings)
|
||||
|
||||
|
||||
def _score(stock_pieces, s, strategy, warnings) -> dict:
|
||||
waste_area = used_area = bought_area = 0.0
|
||||
reusable = 0
|
||||
for sp in stock_pieces:
|
||||
used = sum(p.len_in * p.wid_in for p in sp.placements)
|
||||
used_area += used
|
||||
if sp.is_sheet:
|
||||
bought_area += sp.length_in * sp.width_in
|
||||
waste_area += sp.length_in * sp.width_in - used
|
||||
else:
|
||||
bought_area += sp.length_in * sp.width_in
|
||||
for w in sp.waste:
|
||||
waste_area += w.length_in * (w.width_in or sp.width_in)
|
||||
if w.reusable:
|
||||
reusable += 1
|
||||
reusable_in = sum(w.length_in for sp in stock_pieces if not sp.is_sheet
|
||||
for w in sp.waste if w.reusable)
|
||||
return {
|
||||
"strategy_name": strategy,
|
||||
"stock_count": len(stock_pieces),
|
||||
"waste_area": round(waste_area, 1),
|
||||
"reusable_offcuts": reusable,
|
||||
"reusable_in": round(reusable_in, 1),
|
||||
"yield_pct": round(used_area / bought_area * 100, 1) if bought_area else 0.0,
|
||||
"warnings": list(warnings),
|
||||
}
|
||||
|
||||
|
||||
def _free_segments(sp: StockPiece, kerf: float) -> list:
|
||||
"""Usable free intervals [start, length] on a lumber stick, leaving a kerf
|
||||
margin beside each occupied placement."""
|
||||
occ = sorted((p.x_in, p.x_in + p.len_in) for p in sp.placements)
|
||||
segs, cursor = [], 0.0
|
||||
for a, b in occ:
|
||||
end = a - kerf
|
||||
if end - cursor > 0.5:
|
||||
segs.append([cursor, end - cursor])
|
||||
cursor = b + kerf
|
||||
if sp.length_in - cursor > 0.5:
|
||||
segs.append([cursor, sp.length_in - cursor])
|
||||
return segs
|
||||
|
||||
|
||||
def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPlan:
|
||||
"""Re-pack while PRESERVING locked placements where they sit. Unlocked lumber
|
||||
is packed into the free space around locked pieces (then new sticks); unlocked
|
||||
plywood goes onto fresh sheets (locked sheets keep their locked panels)."""
|
||||
s = base_plan.settings
|
||||
items = _cut_items(scene)
|
||||
locked = [p for sp in base_plan.stock_pieces for p in sp.placements if p.locked]
|
||||
locked_ids = {p.item_id for p in locked}
|
||||
counter = {"n": 0}
|
||||
|
||||
def ids(prefix="rpl"):
|
||||
counter["n"] += 1
|
||||
return f"{prefix}{counter['n']}"
|
||||
|
||||
# Seed stock pieces from those holding locked placements (keep only locked).
|
||||
seeds: dict[str, dict] = {}
|
||||
for sp in base_plan.stock_pieces:
|
||||
kept = [p for p in sp.placements if p.locked]
|
||||
if not kept:
|
||||
continue
|
||||
seeds.setdefault(sp.stock, {})[sp.id] = StockPiece(
|
||||
id=sp.id, stock=sp.stock, is_sheet=sp.is_sheet,
|
||||
length_in=sp.length_in, width_in=sp.width_in,
|
||||
placements=[Placement(id=p.id, item_id=p.item_id, x_in=p.x_in, y_in=p.y_in,
|
||||
len_in=p.len_in, wid_in=p.wid_in, rotated=p.rotated, locked=True)
|
||||
for p in kept])
|
||||
|
||||
unlocked = [it for it in _ordered(items, strategy) if it.id not in locked_ids]
|
||||
by_stock: dict[str, list] = {}
|
||||
for it in unlocked:
|
||||
by_stock.setdefault(it.stock, []).append(it)
|
||||
|
||||
stock_pieces, unplaced, warnings = [], [], []
|
||||
for stock in set(by_stock) | set(seeds):
|
||||
its = by_stock.get(stock, [])
|
||||
seed_pieces = list(seeds.get(stock, {}).values())
|
||||
is_sheet = (its and its[0].is_sheet) or (seed_pieces and seed_pieces[0].is_sheet)
|
||||
if is_sheet:
|
||||
new_sheets, un = _pack_plywood(its, stock, s, ids) if its else ([], [])
|
||||
stock_pieces += seed_pieces + new_sheets
|
||||
else:
|
||||
sps, un = _pack_lumber_seeded(its, stock, s, ids, seed_pieces)
|
||||
stock_pieces += sps
|
||||
unplaced += un
|
||||
for iid in unplaced:
|
||||
it = next(i for i in items if i.id == iid)
|
||||
warnings.append(f"{it.part_id} ({it.stock}) doesn't fit standard stock — too big.")
|
||||
|
||||
plan = CutPlan(settings=s, items=items, stock_pieces=stock_pieces, unplaced=unplaced,
|
||||
strategy=strategy + "+locked",
|
||||
score=_score(stock_pieces, s, strategy + "+locked", warnings), warnings=warnings)
|
||||
recompute(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def _pack_lumber_seeded(items, stock, s, ids, seeds) -> tuple[list, list]:
|
||||
"""Place items into the free segments of seeded sticks first, then new sticks."""
|
||||
sticks = list(seeds)
|
||||
free = {sp.id: _free_segments(sp, s.kerf_in) for sp in sticks}
|
||||
unplaced = []
|
||||
for it in items:
|
||||
if it.length_in > s.stick_len_in + _EPS:
|
||||
unplaced.append(it.id)
|
||||
continue
|
||||
cands = [(sp, seg) for sp in sticks for seg in free[sp.id] if it.length_in <= seg[1] + _EPS]
|
||||
if cands:
|
||||
sp, seg = min(cands, key=lambda c: c[1][1]) # tightest free segment
|
||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=round(seg[0], 3),
|
||||
len_in=it.length_in, wid_in=it.width_in))
|
||||
used = it.length_in + s.kerf_in
|
||||
seg[0] += used
|
||||
seg[1] -= used
|
||||
else:
|
||||
sp = StockPiece(id=ids("rsp"), stock=stock, is_sheet=False,
|
||||
length_in=s.stick_len_in, width_in=it.width_in)
|
||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0,
|
||||
len_in=it.length_in, wid_in=it.width_in))
|
||||
sticks.append(sp)
|
||||
free[sp.id] = [[it.length_in + s.kerf_in, s.stick_len_in - it.length_in - s.kerf_in]]
|
||||
return sticks, unplaced
|
||||
|
||||
|
||||
def _plan_key(plan: CutPlan):
|
||||
"""Lower is better: fewest stock pieces, least waste, then prefer more & longer
|
||||
reusable offcuts."""
|
||||
sc = plan.score
|
||||
return (sc["stock_count"], sc["waste_area"], -sc["reusable_offcuts"], -sc.get("reusable_in", 0))
|
||||
|
||||
|
||||
# Strategies the "Try alternative" button cycles through.
|
||||
STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shuffle"]
|
||||
|
||||
|
||||
def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24) -> CutPlan:
|
||||
"""Find a better layout by trying several strategies + shuffle restarts and
|
||||
keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
|
||||
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
|
||||
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
|
||||
best = None
|
||||
for st in strategies:
|
||||
plan = build_cut_plan(scene, settings, strategy=st)
|
||||
if best is None or _plan_key(plan) < _plan_key(best):
|
||||
best = plan
|
||||
if best is not None:
|
||||
best.strategy = "optimized"
|
||||
best.score["strategy_name"] = "optimized"
|
||||
return best
|
||||
|
||||
|
||||
# --- manual editing helpers (deterministic; the drag UI builds on these) -----
|
||||
def find_placement(plan: CutPlan, pid: str):
|
||||
for sp in plan.stock_pieces:
|
||||
for p in sp.placements:
|
||||
if p.id == pid:
|
||||
return sp, p
|
||||
raise KeyError(pid)
|
||||
|
||||
|
||||
def _too_close(a: Placement, b: Placement, kerf: float) -> bool:
|
||||
"""True if a and b are closer than a saw kerf in BOTH axes (so a cut can't
|
||||
separate them) — i.e. they overlap or leave less than kerf between them."""
|
||||
x_ov = min(a.x_in + a.len_in, b.x_in + b.len_in) - max(a.x_in, b.x_in)
|
||||
y_ov = min(a.y_in + a.wid_in, b.y_in + b.wid_in) - max(a.y_in, b.y_in)
|
||||
return x_ov > -kerf + _EPS and y_ov > -kerf + _EPS
|
||||
|
||||
|
||||
def placement_fits(sp: StockPiece, placement: Placement, kerf: float) -> bool:
|
||||
"""Is `placement` inside `sp` and kerf-clear of its other placements?"""
|
||||
if placement.x_in < -_EPS or placement.x_in + placement.len_in > sp.length_in + _EPS:
|
||||
return False
|
||||
if placement.y_in < -_EPS or placement.y_in + placement.wid_in > sp.width_in + _EPS:
|
||||
return False
|
||||
return not any(_too_close(placement, q, kerf) for q in sp.placements if q.id != placement.id)
|
||||
|
||||
|
||||
def snap_x(sp: StockPiece, placement: Placement, x: float, kerf: float, tol: float = 2.0) -> float:
|
||||
"""Snap an x position to stock edges / neighbour edges (+kerf), within `tol`."""
|
||||
cands = [0.0, sp.length_in - placement.len_in]
|
||||
for q in sp.placements:
|
||||
if q.id == placement.id:
|
||||
continue
|
||||
cands.append(q.x_in + q.len_in + kerf) # butt to the right of q (+kerf)
|
||||
cands.append(q.x_in - placement.len_in - kerf) # butt to the left of q
|
||||
best = min(cands, key=lambda c: abs(c - x))
|
||||
return best if abs(best - x) <= tol else x
|
||||
|
||||
|
||||
def relocate(plan: CutPlan, pid: str, target_sp_id: str, x_in: float, y_in: float = 0.0) -> None:
|
||||
"""Move a placement to a stock piece at (x,y). Does not validate (caller checks)."""
|
||||
sp, p = find_placement(plan, pid)
|
||||
target = next(s for s in plan.stock_pieces if s.id == target_sp_id)
|
||||
if target is not sp:
|
||||
sp.placements.remove(p)
|
||||
target.placements.append(p)
|
||||
p.x_in, p.y_in = x_in, y_in
|
||||
|
||||
|
||||
def rotate_placement(plan: CutPlan, pid: str) -> None:
|
||||
"""Swap a placement's footprint (plywood rotation)."""
|
||||
_sp, p = find_placement(plan, pid)
|
||||
p.len_in, p.wid_in = p.wid_in, p.len_in
|
||||
p.rotated = not p.rotated
|
||||
|
||||
|
||||
def recompute(plan: CutPlan) -> None:
|
||||
"""Rebuild waste regions (incl. gaps left by manual moves) and the score —
|
||||
call after any manual edit so the diagram and yield stay truthful."""
|
||||
s = plan.settings
|
||||
for sp in plan.stock_pieces:
|
||||
sp.waste = []
|
||||
if sp.is_sheet:
|
||||
continue
|
||||
cursor = 0.0
|
||||
for p in sorted(sp.placements, key=lambda p: p.x_in):
|
||||
gap = round(p.x_in - cursor, 3)
|
||||
if gap > 0.5:
|
||||
sp.waste.append(WasteRegion(x_in=round(cursor, 3), length_in=gap,
|
||||
width_in=sp.width_in, reusable=gap >= s.offcut_usable_in))
|
||||
cursor = max(cursor, p.x_in + p.len_in)
|
||||
tail = round(sp.length_in - cursor, 3)
|
||||
if tail > 0.5:
|
||||
sp.waste.append(WasteRegion(x_in=round(cursor, 3), length_in=tail,
|
||||
width_in=sp.width_in, reusable=tail >= s.offcut_usable_in))
|
||||
plan.score = _score(plan.stock_pieces, s, plan.strategy, plan.warnings)
|
||||
|
||||
|
||||
def validate_cut_plan(plan: CutPlan) -> list:
|
||||
"""Return a list of problems ([] means valid): pieces inside stock, no
|
||||
overlaps, kerf respected, every item placed-or-warned."""
|
||||
problems = []
|
||||
s = plan.settings
|
||||
items = {it.id: it for it in plan.items}
|
||||
rot_ok = s.allow_plywood_rotation and not s.grain_direction
|
||||
placed_items = set()
|
||||
for sp in plan.stock_pieces:
|
||||
for p in sp.placements:
|
||||
placed_items.add(p.item_id)
|
||||
if p.x_in < -_EPS or p.x_in + p.len_in > sp.length_in + _EPS:
|
||||
problems.append(f"{p.id} runs off {sp.id} lengthwise")
|
||||
if p.y_in < -_EPS or p.y_in + p.wid_in > sp.width_in + _EPS:
|
||||
problems.append(f"{p.id} runs off {sp.id} widthwise")
|
||||
it = items.get(p.item_id)
|
||||
if it and it.stock != sp.stock:
|
||||
problems.append(f"{p.id} ({it.stock}) is on a {sp.stock} stock piece")
|
||||
if p.rotated and not rot_ok:
|
||||
problems.append(f"{p.id} is rotated but rotation isn't allowed")
|
||||
ps = sp.placements
|
||||
for i in range(len(ps)):
|
||||
for j in range(i + 1, len(ps)):
|
||||
if _too_close(ps[i], ps[j], s.kerf_in):
|
||||
problems.append(f"{ps[i].id} and {ps[j].id} are closer than a kerf on {sp.id}")
|
||||
for it in plan.items:
|
||||
if it.id not in placed_items and it.id not in plan.unplaced:
|
||||
problems.append(f"{it.part_id} ({it.id}) is neither placed nor flagged unplaced")
|
||||
return problems
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
"""The conversational driver: speak (or type) a command, watch it build.
|
||||
|
||||
Reuses existing CmdForge tools for everything that isn't woodshop-specific:
|
||||
* `dictate` -> speech to text (with --voice)
|
||||
* `pa-load-tools` -> turns the wood-* tools into Claude function schemas
|
||||
* `claude -p` -> interprets the utterance into tool calls
|
||||
* `pa-execute-tool`-> dispatches each wood-* tool
|
||||
* `read-aloud` -> speaks the confirmation back
|
||||
|
||||
Only the orchestration here is woodshop-specific (it must be: we use Claude
|
||||
rather than pa-tool-loop's hard-wired local model). Run the viewer alongside it:
|
||||
|
||||
woodshop-view & # 3D window
|
||||
woodshop-talk # type commands; add --voice to speak them
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
TOOL_FILTER = "wood-*" # auto-discover every wood-* tool, no hardcoded list
|
||||
REASON_PROVIDER = "claude -p" # chosen for reliable structured tool-calling
|
||||
|
||||
# A board placed earlier in the SAME utterance is referenced as $1, $2, ...
|
||||
_SYMBOL = re.compile(r"\$(\d+)")
|
||||
|
||||
|
||||
def _run(cmd: list[str], stdin: str = "") -> str:
|
||||
proc = subprocess.run(cmd, input=stdin, capture_output=True, text=True)
|
||||
return (proc.stdout or "").strip()
|
||||
|
||||
|
||||
def load_schemas() -> str:
|
||||
return _run(["pa-load-tools", "--filter", TOOL_FILTER, "--format", "anthropic"])
|
||||
|
||||
|
||||
def scene_summary() -> str:
|
||||
ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")
|
||||
return _run([ws, "status"]) or "empty"
|
||||
|
||||
|
||||
SYSTEM = """You are WoodShop, a voice-driven woodworking assistant. Translate the \
|
||||
user's spoken command into a JSON array of tool calls that build/modify a 3D model \
|
||||
of furniture from dimensional lumber.
|
||||
|
||||
Tools (JSON schemas):
|
||||
{schemas}
|
||||
|
||||
Current scene:
|
||||
{scene}
|
||||
|
||||
Rules:
|
||||
- Respond with ONLY a JSON array. No prose, no markdown fences.
|
||||
- Each element is {{"tool": "<name>", "args": {{...}}}}.
|
||||
- Refer to boards that ALREADY exist by their real id (p1, p2, ...) or their name.
|
||||
- For a board you place earlier in THIS response, refer to it later as $1, $2, ...
|
||||
numbered by the order you place boards in this response (the first wood-place is $1).
|
||||
- A "Layout" section gives each board's bounding box (in inches). Use it to
|
||||
reason about where boards are. Boards should TOUCH at faces, never overlap or
|
||||
leave gaps. To position one board flush against / next to another, compute the
|
||||
offset from the two bounding boxes and emit wood-move with the relative
|
||||
dx/dy/dz that makes their faces meet (e.g. move so the moving board's x-max
|
||||
equals the target board's x-min). Fix any "Interpenetrating" pairs the same way.
|
||||
- "these" / "them" / "the selected ones" refer to the currently-selected boards
|
||||
listed under the scene; emit one call per selected board (e.g. wood-move for each).
|
||||
- Plywood is sheet stock named like 'ply-3/4' (¾" thick), 'ply-1/2', 'ply-1/4'.
|
||||
When placing plywood you MUST give wood-place a width as well as a length
|
||||
(e.g. a tabletop or cabinet back). Lumber ignores width.
|
||||
- Legs and uprights must be stood up: place the board, then wood-stand it.
|
||||
- For wood-join, "part_b" is the board being attached (it gets moved into place);
|
||||
"to" is the board it attaches to. Anchor is "end" (far end) or "start".
|
||||
- Decompose multi-step requests (e.g. "build a table frame") into the full sequence
|
||||
of place/stand/join/move calls. Use wood-rename to label important parts (legs, rails).
|
||||
- For questions like "what do I have" or "cut list / how much wood", call
|
||||
wood-cutlist (or answer with "say").
|
||||
- If the command is ambiguous or not about woodworking, return a single
|
||||
{{"tool": "say", "args": {{"text": "<short question or reply>"}}}}.
|
||||
|
||||
User said: "{utterance}"
|
||||
"""
|
||||
|
||||
|
||||
def _extract_calls(raw: str) -> list[dict] | None:
|
||||
"""Pull a JSON array of calls out of a model response, tolerating code
|
||||
fences and trailing prose. Tries the whole string, then the FIRST balanced
|
||||
[...] (not greedy-to-last-bracket, which would swallow trailing text)."""
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw)
|
||||
raw = re.sub(r"\n?```$", "", raw).strip()
|
||||
|
||||
candidates = [raw]
|
||||
start = raw.find("[")
|
||||
if start != -1:
|
||||
depth = 0
|
||||
for i in range(start, len(raw)):
|
||||
if raw[i] == "[":
|
||||
depth += 1
|
||||
elif raw[i] == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidates.append(raw[start:i + 1])
|
||||
break
|
||||
|
||||
for candidate in candidates:
|
||||
try:
|
||||
value = json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
return None
|
||||
|
||||
|
||||
def interpret(utterance: str, schemas: str, scene_text: str | None = None) -> list[dict]:
|
||||
scene = scene_text if scene_text is not None else scene_summary()
|
||||
prompt = SYSTEM.format(schemas=schemas, scene=scene, utterance=utterance)
|
||||
raw = _run(REASON_PROVIDER.split(), stdin=prompt)
|
||||
calls = _extract_calls(raw)
|
||||
if calls is None:
|
||||
return [{"tool": "say", "args": {"text": "Sorry, I couldn't parse that command."}}]
|
||||
return calls
|
||||
|
||||
|
||||
def _subprocess_executor(tool: str, args: dict) -> str:
|
||||
"""Default executor: dispatch a wood-* tool via the CmdForge pa-execute-tool."""
|
||||
result = _run(["pa-execute-tool", "--tool-name", tool,
|
||||
"--tool-args", json.dumps(args)])
|
||||
try:
|
||||
payload = json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
payload = {"success": False, "output": "", "error": result}
|
||||
return payload.get("output") or payload.get("error") or "(no output)"
|
||||
|
||||
|
||||
def dispatch(calls: list[dict], verbose: bool = True, executor=None) -> list[str]:
|
||||
"""Execute calls in order, resolving $N to ids of boards placed this turn.
|
||||
|
||||
`executor(tool, args) -> message` performs one operation; defaults to the
|
||||
CmdForge subprocess. The GUI passes an in-process executor that mutates its
|
||||
live Scene directly while reusing this $N-resolution logic.
|
||||
"""
|
||||
executor = executor or _subprocess_executor
|
||||
placed: list[str] = []
|
||||
messages: list[str] = []
|
||||
|
||||
def resolve(value):
|
||||
if isinstance(value, str):
|
||||
def sub(m):
|
||||
i = int(m.group(1)) - 1
|
||||
return placed[i] if 0 <= i < len(placed) else m.group(0)
|
||||
return _SYMBOL.sub(sub, value)
|
||||
return value
|
||||
|
||||
for call in calls:
|
||||
tool = call.get("tool", "")
|
||||
args = {k: resolve(v) for k, v in (call.get("args") or {}).items()}
|
||||
|
||||
if tool == "say":
|
||||
messages.append(args.get("text", ""))
|
||||
continue
|
||||
|
||||
out = executor(tool, args)
|
||||
if tool == "wood-place":
|
||||
m = re.search(r"\b(p\d+)\b", out) # remember the new id for $N
|
||||
if m:
|
||||
placed.append(m.group(1))
|
||||
messages.append(out)
|
||||
if verbose:
|
||||
print(f" {tool}{args} -> {out}")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def speak(text: str) -> None:
|
||||
if text.strip():
|
||||
subprocess.run(["read-aloud", "--strip-md", "true"], input=text, text=True)
|
||||
|
||||
|
||||
# Concise spoken verbs per tool (for a short summary instead of reading every line).
|
||||
_VERB = {
|
||||
"wood-place": "placed", "wood-join": "joined", "wood-stand": "stood up",
|
||||
"wood-lay": "laid flat", "wood-rotate": "rotated", "wood-move": "moved",
|
||||
"wood-trim": "cut", "wood-copy": "copied", "wood-rename": "named",
|
||||
"wood-sand": "sanded", "wood-delete": "removed", "wood-undo": "undid",
|
||||
"wood-clear": "cleared the scene", "wood-save": "saved", "wood-open": "opened",
|
||||
}
|
||||
# Tools whose text output IS the answer and should be spoken verbatim.
|
||||
_QUERY_TOOLS = {"wood-cutlist", "wood-projects"}
|
||||
|
||||
|
||||
def summarize(calls: list[dict], messages: list[str]) -> str:
|
||||
"""A short, speakable summary. Verbatim for queries/clarifications; otherwise
|
||||
a verb+count roll-up so building a table doesn't read 12 sentences aloud."""
|
||||
from collections import Counter
|
||||
|
||||
verbatim = [m for c, m in zip(calls, messages)
|
||||
if c.get("tool") in _QUERY_TOOLS or c.get("tool") == "say"]
|
||||
if verbatim:
|
||||
return " ".join(verbatim).strip()
|
||||
|
||||
counts = Counter(c.get("tool", "") for c in calls)
|
||||
chunks = []
|
||||
for tool, n in counts.items():
|
||||
verb = _VERB.get(tool)
|
||||
if not verb:
|
||||
continue
|
||||
chunks.append(verb if "scene" in verb or n == 1 else f"{verb} {n}")
|
||||
return ("Done — " + ", ".join(chunks) + ".") if chunks else "Done."
|
||||
|
||||
|
||||
def handle(utterance: str, schemas: str, voice: bool, verbose: bool) -> None:
|
||||
calls = interpret(utterance, schemas)
|
||||
messages = dispatch(calls, verbose=verbose)
|
||||
full = " ".join(m for m in messages if m).strip()
|
||||
spoken = summarize(calls, messages)
|
||||
print(f"WoodShop: {full or spoken}")
|
||||
if voice:
|
||||
speak(spoken)
|
||||
|
||||
|
||||
def get_utterance(voice: bool, duration: int) -> str | None:
|
||||
if voice:
|
||||
print(f"[listening {duration}s...]")
|
||||
text = _run(["dictate", "--duration", str(duration)])
|
||||
print(f"You said: {text!r}")
|
||||
return text or None
|
||||
try:
|
||||
return input("you> ").strip() or None
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(prog="woodshop-talk", description="Conversational woodworking.")
|
||||
ap.add_argument("--voice", action="store_true", help="Listen on the mic instead of typing")
|
||||
ap.add_argument("--duration", type=int, default=6, help="Mic recording seconds (--voice)")
|
||||
ap.add_argument("--once", help="Run a single command (non-interactive) and exit")
|
||||
ap.add_argument("--quiet", action="store_true", help="Don't print per-call detail")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
schemas = load_schemas()
|
||||
if not schemas:
|
||||
print("Could not load wood-* tool schemas (is CmdForge/pa-load-tools available?)",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.once is not None:
|
||||
handle(args.once, schemas, voice=args.voice, verbose=not args.quiet)
|
||||
return 0
|
||||
|
||||
print("WoodShop ready. Say things like 'place a 6 foot 2x4'. Ctrl-C to quit.")
|
||||
while True:
|
||||
utterance = get_utterance(args.voice, args.duration)
|
||||
if utterance is None:
|
||||
print()
|
||||
return 0
|
||||
if utterance.lower() in ("quit", "exit", "stop", "done"):
|
||||
return 0
|
||||
try:
|
||||
handle(utterance, schemas, voice=args.voice, verbose=not args.quiet)
|
||||
except Exception as exc: # never let one bad command kill the session
|
||||
print(f"WoodShop: sorry, that command failed ({exc}).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
"""Turn a scene into real solids with build123d (accurate, exportable geometry).
|
||||
|
||||
This is the buildable side of the house: it produces watertight solids that can
|
||||
be written to STL (3D printing) or STEP (CAD / CNC). The live viewer renders
|
||||
lightweight boxes for speed; this module is the source of truth for export.
|
||||
|
||||
Coordinate convention matches scene.py: a board is length(X) x width(Y) x
|
||||
thickness(Z), centered on its length axis, with end_a (the start) at the part's
|
||||
``position_in`` before rotation about Z.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .scene import Feature, Part, Scene, face_frame as _face_frame
|
||||
|
||||
|
||||
def _orient_z_to(solid, n):
|
||||
"""Rotate a Z-axis primitive (Cylinder) so its axis points along n."""
|
||||
from build123d import Rot
|
||||
if abs(n[2]) > 0.9:
|
||||
return solid
|
||||
if abs(n[1]) > 0.9:
|
||||
return Rot(X=90) * solid
|
||||
return Rot(Y=90) * solid
|
||||
|
||||
|
||||
def _feature_solid_local(feat: Feature, L: float, w: float, t: float):
|
||||
"""Return (solid, is_cut) for one feature, in the board's local frame."""
|
||||
from build123d import Box, Cylinder, Pos
|
||||
|
||||
o, n, u, v = _face_frame(feat.face, L, w, t)
|
||||
# Position on the face. When u is the length axis, `along_in` is measured
|
||||
# from end_a; otherwise both offsets are from the face centre.
|
||||
off_u = feat.along_in - (L / 2 if u == (1, 0, 0) else 0.0)
|
||||
off_v = feat.across_in
|
||||
fp = tuple(o[i] + off_u * u[i] + off_v * v[i] for i in range(3))
|
||||
depth = feat.depth_in
|
||||
|
||||
if feat.kind == "hole":
|
||||
r = feat.diameter_in / 2
|
||||
thru = abs(n[0]) * L + abs(n[1]) * w + abs(n[2]) * t + 0.1
|
||||
h = depth if depth > 0 else thru
|
||||
cyl = _orient_z_to(Cylinder(radius=r, height=h), n)
|
||||
c = tuple(fp[i] - n[i] * h / 2 for i in range(3)) # extend into the board
|
||||
return Pos(*c) * cyl, True
|
||||
|
||||
# Box-shaped feature: cross-section width(u) × height(v), depth along normal.
|
||||
sx = feat.width_in * abs(u[0]) + feat.height_in * abs(v[0]) + depth * abs(n[0])
|
||||
sy = feat.width_in * abs(u[1]) + feat.height_in * abs(v[1]) + depth * abs(n[1])
|
||||
sz = feat.width_in * abs(u[2]) + feat.height_in * abs(v[2]) + depth * abs(n[2])
|
||||
sign = 1 if feat.kind == "tenon" else -1 # tenon protrudes; others cut inward
|
||||
c = tuple(fp[i] + sign * n[i] * depth / 2 for i in range(3))
|
||||
solid = Pos(*c) * Box(sx, sy, sz)
|
||||
if feat.rotation_deg: # spin the cross-section about the normal
|
||||
from build123d import Axis
|
||||
solid = solid.rotate(Axis(fp, n), feat.rotation_deg)
|
||||
return solid, (feat.kind != "tenon")
|
||||
|
||||
|
||||
def _face_plane(face: str, L: float, w: float, t: float):
|
||||
"""(axis index, coordinate) of a face's plane, for selecting its edges."""
|
||||
return {
|
||||
"top": (2, t / 2), "bottom": (2, -t / 2),
|
||||
"right": (1, w / 2), "left": (1, -w / 2),
|
||||
"end_b": (0, L), "end_a": (0, 0.0),
|
||||
}[face]
|
||||
|
||||
|
||||
def _apply_chamfer(solid, feat: Feature, L: float, w: float, t: float):
|
||||
"""Bevel the edges around the feature's face by width_in."""
|
||||
from build123d import chamfer
|
||||
|
||||
axis, value = _face_plane(feat.face, L, w, t)
|
||||
size = feat.width_in or min(t, w) / 3
|
||||
|
||||
def coord(e):
|
||||
c = e.center()
|
||||
return (c.X, c.Y, c.Z)[axis]
|
||||
|
||||
edges = [e for e in solid.edges() if abs(coord(e) - value) < 1e-3]
|
||||
if not edges:
|
||||
return solid
|
||||
try:
|
||||
return chamfer(edges, min(size, min(t, w) / 2 - 1e-3))
|
||||
except Exception:
|
||||
return solid # over-sized / invalid chamfer: leave the board unbevelled
|
||||
|
||||
|
||||
def part_solid(part: Part):
|
||||
from build123d import Box, Pos, Rot
|
||||
|
||||
length = part.length_in
|
||||
thickness, width = part.section_in
|
||||
solid = Pos(length / 2, 0, 0) * Box(length, width, thickness) # local, start at origin
|
||||
|
||||
for feat in part.features: # apply joinery
|
||||
if feat.kind == "chamfer":
|
||||
solid = _apply_chamfer(solid, feat, length, width, thickness)
|
||||
continue
|
||||
fsolid, is_cut = _feature_solid_local(feat, length, width, thickness)
|
||||
solid = (solid - fsolid) if is_cut else (solid + fsolid)
|
||||
|
||||
# place: roll about its own axis (X), tilt up toward Z (about Y), heading (Z).
|
||||
solid = Rot(X=part.roll_deg) * solid
|
||||
solid = Rot(Y=-part.tilt_deg) * solid
|
||||
solid = Rot(Z=part.yaw_deg) * solid
|
||||
solid = Pos(*part.position_in) * solid
|
||||
return solid
|
||||
|
||||
|
||||
def scene_compound(scene: Scene):
|
||||
from build123d import Compound
|
||||
|
||||
solids = [part_solid(p) for p in scene.parts]
|
||||
if not solids:
|
||||
return None
|
||||
return Compound(children=solids)
|
||||
|
||||
|
||||
def export(scene: Scene, path: str | Path) -> Path:
|
||||
"""Export the whole scene to STL or STEP based on the file extension."""
|
||||
from build123d import export_step, export_stl
|
||||
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
compound = scene_compound(scene)
|
||||
if compound is None:
|
||||
raise ValueError("Nothing to export: the scene is empty.")
|
||||
if path.suffix.lower() == ".step":
|
||||
export_step(compound, str(path))
|
||||
elif path.suffix.lower() == ".stl":
|
||||
export_stl(compound, str(path))
|
||||
else:
|
||||
raise ValueError(f"Unsupported export format: {path.suffix} (use .stl or .step)")
|
||||
return path
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
"""The WoodShop desktop studio: a unified PySide6 window combining the live 3D
|
||||
viewport, a parts panel with quick actions, and a voice/text command bar.
|
||||
|
||||
It's a thin shell over the same Scene model, operations, and Claude interpreter
|
||||
used by the CLI and the standalone tools — see controller.py.
|
||||
"""
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
"""Entry point for the WoodShop desktop studio (`woodshop-gui`, or bare
|
||||
`woodshop`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(prog="woodshop-gui", description="WoodShop desktop studio.")
|
||||
ap.add_argument("--scene", help="Path to scene.json")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
# Make every subprocess we spawn (dictate, tools) use the same scene file.
|
||||
if args.scene:
|
||||
os.environ["WOODSHOP_SCENE"] = args.scene
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from .main_window import MainWindow
|
||||
|
||||
app = QApplication.instance() or QApplication(sys.argv[:1])
|
||||
app.setApplicationName("WoodShop")
|
||||
window = MainWindow(args.scene)
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,449 +0,0 @@
|
|||
"""The shop-output window: tabbed Cut List / Shopping List / Cut Layout, each
|
||||
printable. The Cut Layout tab draws the cutting-stock nesting and can try
|
||||
alternative arrangements."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
||||
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
|
||||
from PySide6.QtWidgets import (QDialog, QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
|
||||
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QLabel,
|
||||
QMenu, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from ..cutlist import _fmt_len, board_feet
|
||||
from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement,
|
||||
placement_fits, recompute, relocate, reoptimize, rotate_placement,
|
||||
snap_x, _plan_key)
|
||||
from ..instructions import build_steps, format_steps, polish_prompt
|
||||
from ..jigs import explain_prompt, format_jigs, suggest_jigs
|
||||
from .workers import run_async
|
||||
|
||||
_PX = 7.0 # pixels per inch in the layout view
|
||||
_PIECE = "#c8965a"
|
||||
_WASTE = "#3a3a3a"
|
||||
|
||||
|
||||
class _Piece(QGraphicsRectItem):
|
||||
"""A draggable cut piece on the layout. Reports drops/rotate/lock back to the
|
||||
window, which snaps + validates against the CutPlan."""
|
||||
|
||||
def __init__(self, win, pid, sp_id, w, h, locked, text):
|
||||
super().__init__(0, 0, w, h)
|
||||
self.win, self.pid, self.sp_id = win, pid, sp_id
|
||||
self.setBrush(QBrush(QColor(_PIECE)))
|
||||
self.setPen(QPen(QColor("#ffd700" if locked else "#111111"), 2 if locked else 1))
|
||||
if not locked:
|
||||
self.setFlag(QGraphicsItem.ItemIsMovable, True)
|
||||
self.setCursor(Qt.OpenHandCursor)
|
||||
if text:
|
||||
t = QGraphicsSimpleTextItem(("🔒 " if locked else "") + text, self)
|
||||
t.setBrush(QBrush(QColor("white")))
|
||||
t.setPos(3, 3)
|
||||
self._home = None
|
||||
|
||||
def mousePressEvent(self, e):
|
||||
self._home = (self.sp_id, self.pos().x(), self.pos().y())
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
super().mouseReleaseEvent(e)
|
||||
if self._home is not None:
|
||||
self.win._drop_piece(self, self._home)
|
||||
|
||||
def mouseDoubleClickEvent(self, e):
|
||||
self.win._rotate_piece(self.pid)
|
||||
|
||||
def contextMenuEvent(self, e):
|
||||
self.win._piece_menu(self.pid, e.screenPos())
|
||||
|
||||
|
||||
class BomWindow(QDialog):
|
||||
def __init__(self, controller, parent=None):
|
||||
super().__init__(parent)
|
||||
self.c = controller
|
||||
self.setWindowTitle("Cut List & BOM")
|
||||
self.resize(820, 640)
|
||||
self._order = 0
|
||||
self._optimized = False
|
||||
self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render
|
||||
self._px = _PX
|
||||
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
|
||||
self.pool = QThreadPool.globalInstance()
|
||||
|
||||
self._cut_te = self._mono_te()
|
||||
self._shop_te = self._mono_te()
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._print_wrap(self._cut_te), "Cut List")
|
||||
tabs.addTab(self._print_wrap(self._shop_te), "Shopping List")
|
||||
tabs.addTab(self._layout_tab(), "Cut Layout")
|
||||
tabs.addTab(self._instructions_tab(), "Instructions")
|
||||
tabs.addTab(self._jigs_tab(), "Jigs")
|
||||
root = QVBoxLayout(self)
|
||||
root.addWidget(tabs)
|
||||
self._refresh_all()
|
||||
|
||||
# ----- one active plan; all tabs render from it ---------------------
|
||||
def _set_plan(self, plan) -> None:
|
||||
recompute(plan) # keep waste/score truthful after any change
|
||||
self._plan = plan
|
||||
self._refresh_all()
|
||||
|
||||
def _refresh_all(self) -> None:
|
||||
self._cut_te.setPlainText(self._cut_text())
|
||||
self._shop_te.setPlainText(self._shop_text())
|
||||
self._instr.setPlainText(format_steps(build_steps(self.c.scene, self._plan)))
|
||||
self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene)))
|
||||
self._draw_layout()
|
||||
|
||||
# ----- text tabs ----------------------------------------------------
|
||||
def _mono_te(self) -> QTextEdit:
|
||||
te = QTextEdit(readOnly=True)
|
||||
te.setFont(QFont("monospace"))
|
||||
return te
|
||||
|
||||
def _print_wrap(self, te: QTextEdit) -> QWidget:
|
||||
w = QWidget()
|
||||
v = QVBoxLayout(w)
|
||||
v.addWidget(te)
|
||||
btn = QPushButton("Print…")
|
||||
btn.clicked.connect(lambda: self._print_text(te))
|
||||
row = QHBoxLayout(); row.addStretch(); row.addWidget(btn)
|
||||
v.addLayout(row)
|
||||
return w
|
||||
|
||||
def _cut_text(self) -> str:
|
||||
plan = self._plan
|
||||
groups = Counter((it.stock, round(it.length_in, 2), round(it.width_in, 2), it.is_sheet)
|
||||
for it in plan.items)
|
||||
lines = ["CUT LIST", ""]
|
||||
for (stock, ln, wd, sheet), n in sorted(groups.items()):
|
||||
if sheet:
|
||||
lines.append(f" {n:>2} × {stock:<8} {_fmt_len(wd)} × {_fmt_len(ln)}"
|
||||
f" ({wd * ln / 144 * n:.1f} sq ft)")
|
||||
else:
|
||||
lines.append(f" {n:>2} × {stock:<8} @ {_fmt_len(ln):<9}"
|
||||
f" ({board_feet(stock, ln) * n:.1f} bd-ft)")
|
||||
if not plan.items:
|
||||
lines.append(" (nothing to cut yet)")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _shop_text(self) -> str:
|
||||
plan = self._plan
|
||||
lines = ["SHOPPING LIST", "", "Buy:"]
|
||||
for stock, qty in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()):
|
||||
s = "s" if qty != 1 else ""
|
||||
unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')"
|
||||
lines.append(f" {qty} × {stock} {unit}")
|
||||
if not plan.stock_pieces:
|
||||
lines.append(" (nothing yet)")
|
||||
if plan.unplaced:
|
||||
lines += ["", "⚠ Won't fit standard stock — source / cut specially:"]
|
||||
for iid in plan.unplaced:
|
||||
it = plan.item(iid)
|
||||
lines.append(f" {it.part_id}: {_fmt_len(it.length_in)} {it.stock}")
|
||||
lines += ["", "Yield (used / bought):"]
|
||||
for stock in sorted({sp.stock for sp in plan.stock_pieces}):
|
||||
sps = [sp for sp in plan.stock_pieces if sp.stock == stock]
|
||||
sheet = sps[0].is_sheet
|
||||
used = sum(p.len_in * p.wid_in for sp in sps for p in sp.placements)
|
||||
cap = sum(sp.length_in * sp.width_in for sp in sps)
|
||||
pct = used / cap * 100 if cap else 0
|
||||
lines.append(f" {stock}: {pct:.0f}% used over {len(sps)} "
|
||||
f"{'sheet' if sheet else 'stick'}{'s' if len(sps) != 1 else ''}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _print_text(self, te: QTextEdit) -> None:
|
||||
printer = QPrinter()
|
||||
if QPrintDialog(printer, self).exec():
|
||||
te.print_(printer)
|
||||
|
||||
# ----- instructions tab --------------------------------------------
|
||||
def _instructions_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
v = QVBoxLayout(w)
|
||||
self._instr = QTextEdit(readOnly=True)
|
||||
self._instr.setFont(QFont("monospace"))
|
||||
self._instr.setPlainText(format_steps(build_steps(self.c.scene)))
|
||||
v.addWidget(self._instr)
|
||||
row = QHBoxLayout()
|
||||
self._polish = QPushButton("Rewrite in plain English (AI)")
|
||||
self._polish.clicked.connect(self._polish_instructions)
|
||||
pr = QPushButton("Print…")
|
||||
pr.clicked.connect(lambda: self._print_text(self._instr))
|
||||
row.addWidget(self._polish); row.addStretch(); row.addWidget(pr)
|
||||
v.addLayout(row)
|
||||
return w
|
||||
|
||||
def _polish_instructions(self) -> None:
|
||||
prompt = polish_prompt(build_steps(self.c.scene, self._plan))
|
||||
self._polish.setEnabled(False)
|
||||
self._polish.setText("Rewriting…")
|
||||
|
||||
def work():
|
||||
r = subprocess.run(["claude", "-p"], input=prompt, capture_output=True, text=True)
|
||||
return (r.stdout or "").strip()
|
||||
|
||||
def done(text):
|
||||
self._polish.setEnabled(True)
|
||||
self._polish.setText("Rewrite in plain English (AI)")
|
||||
if text:
|
||||
self._instr.setPlainText(text)
|
||||
|
||||
def failed(err):
|
||||
self._polish.setEnabled(True)
|
||||
self._polish.setText("Rewrite in plain English (AI)")
|
||||
|
||||
run_async(self.pool, work, on_done=done, on_error=failed)
|
||||
|
||||
# ----- jigs tab -----------------------------------------------------
|
||||
def _jigs_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
v = QVBoxLayout(w)
|
||||
self._jigs = QTextEdit(readOnly=True)
|
||||
self._jigs.setFont(QFont("monospace"))
|
||||
self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene)))
|
||||
v.addWidget(self._jigs)
|
||||
row = QHBoxLayout()
|
||||
self._jig_btn = QPushButton("Explain jigs (AI)")
|
||||
self._jig_btn.clicked.connect(self._explain_jigs)
|
||||
pr = QPushButton("Print…")
|
||||
pr.clicked.connect(lambda: self._print_text(self._jigs))
|
||||
row.addWidget(self._jig_btn); row.addStretch(); row.addWidget(pr)
|
||||
v.addLayout(row)
|
||||
return w
|
||||
|
||||
def _explain_jigs(self) -> None:
|
||||
jigs = suggest_jigs(self.c.scene)
|
||||
if not jigs:
|
||||
return
|
||||
prompt = explain_prompt(jigs)
|
||||
self._jig_btn.setEnabled(False)
|
||||
self._jig_btn.setText("Explaining…")
|
||||
|
||||
def work():
|
||||
r = subprocess.run(["claude", "-p"], input=prompt, capture_output=True, text=True)
|
||||
return (r.stdout or "").strip()
|
||||
|
||||
def done(text):
|
||||
self._jig_btn.setEnabled(True)
|
||||
self._jig_btn.setText("Explain jigs (AI)")
|
||||
if text:
|
||||
self._jigs.setPlainText(format_jigs(jigs) + "\n\n— HOW TO BUILD/USE —\n\n" + text)
|
||||
|
||||
def failed(err):
|
||||
self._jig_btn.setEnabled(True)
|
||||
self._jig_btn.setText("Explain jigs (AI)")
|
||||
|
||||
run_async(self.pool, work, on_done=done, on_error=failed)
|
||||
|
||||
# ----- layout tab (editable) ---------------------------------------
|
||||
def _layout_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
v = QVBoxLayout(w)
|
||||
self.scene = QGraphicsScene()
|
||||
self.view = QGraphicsView(self.scene)
|
||||
v.addWidget(self.view)
|
||||
self._status = QLabel("Drag a piece to re-place it · double-click a panel to rotate "
|
||||
"· right-click to lock")
|
||||
self._status.setStyleSheet("color:#aaaaaa; font-size:11px;")
|
||||
v.addWidget(self._status)
|
||||
row = QHBoxLayout()
|
||||
opt = QPushButton("Find better layout")
|
||||
opt.setToolTip("Try several packing strategies and keep the best-scoring one")
|
||||
opt.clicked.connect(self._optimize)
|
||||
bestn = QPushButton("Best of 100")
|
||||
bestn.setToolTip("Run 100 packing attempts and keep the best")
|
||||
bestn.clicked.connect(self._best_of_n)
|
||||
alt = QPushButton("Try alternative")
|
||||
alt.clicked.connect(self._next_arrangement)
|
||||
pr = QPushButton("Print…")
|
||||
pr.clicked.connect(self._print_layout)
|
||||
row.addWidget(opt); row.addWidget(bestn); row.addWidget(alt)
|
||||
row.addStretch(); row.addWidget(pr)
|
||||
v.addLayout(row)
|
||||
self._draw_layout()
|
||||
return w
|
||||
|
||||
def _has_locks(self) -> bool:
|
||||
return any(p.locked for sp in self._plan.stock_pieces for p in sp.placements)
|
||||
|
||||
def _optimize(self) -> None:
|
||||
self._optimized = True
|
||||
if self._has_locks():
|
||||
best = min((reoptimize(self.c.scene, self._plan, st) for st in STRATEGIES),
|
||||
key=_plan_key)
|
||||
self._set_plan(best)
|
||||
self._status.setText("✓ optimized around locked pieces")
|
||||
else:
|
||||
self._set_plan(best_cut_plan(self.c.scene))
|
||||
self._status.setText("✓ optimized")
|
||||
|
||||
def _best_of_n(self) -> None:
|
||||
self._optimized = True
|
||||
if self._has_locks():
|
||||
best = min((reoptimize(self.c.scene, self._plan, st) for st in STRATEGIES),
|
||||
key=_plan_key)
|
||||
self._set_plan(best)
|
||||
self._status.setText("✓ best around locked pieces")
|
||||
else:
|
||||
self._set_plan(best_cut_plan(self.c.scene, attempts=100))
|
||||
self._status.setText("✓ best of 100 attempts")
|
||||
|
||||
def _next_arrangement(self) -> None:
|
||||
self._optimized = False
|
||||
self._order = (self._order + 1) % len(STRATEGIES)
|
||||
st = STRATEGIES[self._order]
|
||||
plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks()
|
||||
else build_cut_plan(self.c.scene, strategy=st))
|
||||
self._set_plan(plan)
|
||||
|
||||
def _draw_layout(self) -> None:
|
||||
plan = self._plan
|
||||
self.scene.clear()
|
||||
self._rows = []
|
||||
names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
|
||||
part_of = {it.id: it.part_id for it in plan.items}
|
||||
label = lambda iid: names.get(part_of.get(iid, ""), iid)
|
||||
px, y, bar = self._px, 30.0, 34.0
|
||||
|
||||
sc = plan.score
|
||||
self._label(0, 2, f"{sc['strategy_name']} · {sc['stock_count']} stock · "
|
||||
f"{sc['yield_pct']:.0f}% used · {sc['reusable_offcuts']} reusable")
|
||||
|
||||
n = m = 0
|
||||
for sp in plan.stock_pieces: # lumber sticks first
|
||||
if sp.is_sheet:
|
||||
continue
|
||||
n += 1
|
||||
self._label(0, y - 15, f"{sp.stock} stick {n}")
|
||||
self._rows.append((y, y + bar, sp))
|
||||
for w in sp.waste:
|
||||
self._rect(w.x_in * px, y, w.length_in * px, bar, _WASTE,
|
||||
f"waste {_fmt_len(w.length_in)}")
|
||||
for p in sp.placements:
|
||||
self._add_piece(sp, p, p.x_in * px, y, p.len_in * px, bar,
|
||||
f"{label(p.item_id)} · {_fmt_len(p.len_in)}")
|
||||
y += bar + 24
|
||||
|
||||
for sp in plan.stock_pieces: # then plywood sheets
|
||||
if not sp.is_sheet:
|
||||
continue
|
||||
m += 1
|
||||
h = sp.width_in * px
|
||||
self._label(0, y - 15, f"{sp.stock} sheet {m} "
|
||||
f"({_fmt_len(sp.width_in)}×{_fmt_len(sp.length_in)})")
|
||||
self._rect(0, y, sp.length_in * px, h, _WASTE, "")
|
||||
self._rows.append((y, y + h, sp))
|
||||
for p in sp.placements:
|
||||
self._add_piece(sp, p, p.x_in * px, y + p.y_in * px,
|
||||
p.len_in * px, p.wid_in * px, label(p.item_id))
|
||||
y += h + 34
|
||||
|
||||
for warn in plan.warnings:
|
||||
self._label(0, y, "⚠ " + warn)
|
||||
y += 18
|
||||
self.view.setSceneRect(self.scene.itemsBoundingRect())
|
||||
|
||||
def _add_piece(self, sp, p, x, y, w, h, text) -> None:
|
||||
item = _Piece(self, p.id, sp.id, w, h, p.locked, text)
|
||||
item.setPos(x, y)
|
||||
self.scene.addItem(item)
|
||||
|
||||
# ----- drag / rotate / lock handlers -------------------------------
|
||||
def _row_of(self, sp_id):
|
||||
return next((y0 for y0, _y1, s in self._rows if s.id == sp_id), 0.0)
|
||||
|
||||
def _revert(self, plan, pid, home) -> None:
|
||||
hsp, hx, hy = home
|
||||
hrow = self._row_of(hsp)
|
||||
home_sp = next(s for s in plan.stock_pieces if s.id == hsp)
|
||||
relocate(plan, pid, hsp, hx / self._px,
|
||||
(hy - hrow) / self._px if home_sp.is_sheet else 0.0)
|
||||
|
||||
def _drop_piece(self, item, home) -> None:
|
||||
plan, px = self._plan, self._px
|
||||
cy = item.sceneBoundingRect().center().y()
|
||||
target = next((s for y0, y1, s in self._rows if y0 - 2 <= cy <= y1 + 2), None)
|
||||
sp_cur, p = find_placement(plan, item.pid)
|
||||
if target is None:
|
||||
target = sp_cur
|
||||
# Stock-type compatibility: a 2x4 can't go on a plywood sheet, etc.
|
||||
item_stock = plan.item(p.item_id).stock
|
||||
if item_stock != target.stock:
|
||||
self._revert(plan, item.pid, home)
|
||||
self._status.setText(f"✗ {item_stock} can't go on {target.stock} — reverted")
|
||||
recompute(plan); self._refresh_all()
|
||||
return
|
||||
row_y0 = self._row_of(target.id)
|
||||
x_in = max(item.pos().x() / px, 0.0)
|
||||
y_in = max((item.pos().y() - row_y0) / px, 0.0) if target.is_sheet else 0.0
|
||||
relocate(plan, item.pid, target.id, x_in, y_in)
|
||||
if not target.is_sheet:
|
||||
p.x_in = snap_x(target, p, p.x_in, plan.settings.kerf_in)
|
||||
if placement_fits(target, p, plan.settings.kerf_in):
|
||||
self._status.setText("✓ placed")
|
||||
else:
|
||||
self._revert(plan, item.pid, home)
|
||||
self._status.setText("✗ overlap / off-stock — move reverted")
|
||||
recompute(plan) # refresh waste/score after the edit
|
||||
self._refresh_all()
|
||||
|
||||
def _rotate_piece(self, pid) -> None:
|
||||
plan = self._plan
|
||||
sp, p = find_placement(plan, pid)
|
||||
if not sp.is_sheet:
|
||||
return
|
||||
if not plan.settings.allow_plywood_rotation or plan.settings.grain_direction:
|
||||
self._status.setText("✗ rotation isn't allowed (grain / settings)")
|
||||
return
|
||||
rotate_placement(plan, pid)
|
||||
if placement_fits(sp, p, plan.settings.kerf_in):
|
||||
self._status.setText("✓ rotated")
|
||||
else:
|
||||
rotate_placement(plan, pid) # rotate back
|
||||
self._status.setText("✗ rotation doesn't fit")
|
||||
recompute(plan)
|
||||
self._refresh_all()
|
||||
|
||||
def _piece_menu(self, pid, screen_pos) -> None:
|
||||
plan = self._plan
|
||||
sp, p = find_placement(plan, pid)
|
||||
menu = QMenu(self)
|
||||
menu.addAction("Unlock" if p.locked else "Lock", lambda: self._toggle_lock(pid))
|
||||
if sp.is_sheet and plan.settings.allow_plywood_rotation and not plan.settings.grain_direction:
|
||||
menu.addAction("Rotate", lambda: self._rotate_piece(pid))
|
||||
menu.exec(screen_pos)
|
||||
|
||||
def _toggle_lock(self, pid) -> None:
|
||||
_sp, p = find_placement(self._plan, pid)
|
||||
p.locked = not p.locked
|
||||
self._refresh_all()
|
||||
|
||||
def _rect(self, x, y, w, h, color, text) -> None:
|
||||
item = QGraphicsRectItem(x, y, w, h)
|
||||
item.setBrush(QBrush(QColor(color)))
|
||||
item.setPen(QPen(QColor("#111111")))
|
||||
self.scene.addItem(item)
|
||||
if text:
|
||||
t = QGraphicsSimpleTextItem(text)
|
||||
t.setBrush(QBrush(QColor("white")))
|
||||
t.setPos(x + 3, y + 3)
|
||||
self.scene.addItem(t)
|
||||
|
||||
def _label(self, x, y, text) -> None:
|
||||
t = QGraphicsSimpleTextItem(text)
|
||||
t.setBrush(QBrush(QColor("#cccccc")))
|
||||
t.setPos(x, y)
|
||||
self.scene.addItem(t)
|
||||
|
||||
def _print_layout(self) -> None:
|
||||
printer = QPrinter()
|
||||
if QPrintDialog(printer, self).exec():
|
||||
from PySide6.QtGui import QPainter
|
||||
painter = QPainter(printer)
|
||||
self.scene.render(painter)
|
||||
painter.end()
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
"""Command bar: type a command or push-to-talk, see the transcript, optionally
|
||||
hear the reply. Slow work (LLM, dictate, TTS) runs off the UI thread."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtWidgets import (QCheckBox, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QTextEdit, QVBoxLayout, QWidget)
|
||||
|
||||
from .controller import Controller
|
||||
from .workers import run_async
|
||||
|
||||
_WHO_COLOR = {"you": "#9cdcfe", "ws": "#c8965a", "sys": "#e06c75"}
|
||||
|
||||
|
||||
class CommandBar(QWidget):
|
||||
def __init__(self, controller: Controller, pool: QThreadPool, parent=None):
|
||||
super().__init__(parent)
|
||||
self.c = controller
|
||||
self.pool = pool
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.transcript = QTextEdit(readOnly=True)
|
||||
self.transcript.setMaximumHeight(150)
|
||||
root.addWidget(self.transcript)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.mic = QPushButton("🎤")
|
||||
self.mic.setToolTip("Click and speak a command")
|
||||
self.mic.setFixedWidth(40)
|
||||
self.mic.clicked.connect(self._listen)
|
||||
row.addWidget(self.mic)
|
||||
|
||||
self.input = QLineEdit()
|
||||
self.input.setPlaceholderText("Type a command, e.g. 'build a coffee table' — Enter to send")
|
||||
self.input.returnPressed.connect(self._send)
|
||||
row.addWidget(self.input, 1)
|
||||
|
||||
send = QPushButton("Send")
|
||||
send.clicked.connect(self._send)
|
||||
row.addWidget(send)
|
||||
root.addLayout(row)
|
||||
|
||||
bottom = QHBoxLayout()
|
||||
self.speak = QCheckBox("Speak replies")
|
||||
bottom.addWidget(self.speak)
|
||||
bottom.addStretch()
|
||||
self.status = QLabel("")
|
||||
bottom.addWidget(self.status)
|
||||
root.addLayout(bottom)
|
||||
|
||||
self.c.logged.connect(self._log)
|
||||
|
||||
# ----- logging -----------------------------------------------------
|
||||
def _log(self, who: str, text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
color = _WHO_COLOR.get(who, "#cccccc")
|
||||
label = {"you": "you", "ws": "WoodShop", "sys": "⚠"}.get(who, who)
|
||||
self.transcript.append(f'<span style="color:{color}"><b>{label}:</b> '
|
||||
f'{text.replace(chr(10), "<br>")}</span>')
|
||||
self.transcript.verticalScrollBar().setValue(self.transcript.verticalScrollBar().maximum())
|
||||
|
||||
def _busy(self, on: bool, msg: str = "") -> None:
|
||||
self.input.setEnabled(not on)
|
||||
self.mic.setEnabled(not on)
|
||||
self.status.setText(msg)
|
||||
|
||||
# ----- send typed/spoken command -----------------------------------
|
||||
def _send(self) -> None:
|
||||
text = self.input.text().strip()
|
||||
if not text:
|
||||
return
|
||||
self.input.clear()
|
||||
self._run(text)
|
||||
|
||||
def submit(self, text: str) -> None:
|
||||
"""Run a command programmatically (e.g. from a Build-menu template)."""
|
||||
self._run(text)
|
||||
|
||||
def _run(self, text: str) -> None:
|
||||
self._log("you", text)
|
||||
self._busy(True, "thinking…")
|
||||
|
||||
def work():
|
||||
return self.c.run_command(text)
|
||||
|
||||
def done(summary):
|
||||
self._busy(False)
|
||||
if summary:
|
||||
self._log("ws", summary)
|
||||
if self.speak.isChecked():
|
||||
run_async(self.pool, lambda: subprocess.run(
|
||||
["read-aloud", "--strip-md", "true"], input=summary, text=True))
|
||||
|
||||
def failed(err):
|
||||
self._busy(False)
|
||||
self._log("sys", err)
|
||||
|
||||
run_async(self.pool, work, on_done=done, on_error=failed)
|
||||
|
||||
# ----- voice -------------------------------------------------------
|
||||
def _listen(self) -> None:
|
||||
self._busy(True, "listening…")
|
||||
|
||||
def work():
|
||||
r = subprocess.run(["dictate", "--duration", "6"], capture_output=True, text=True)
|
||||
return (r.stdout or "").strip()
|
||||
|
||||
def done(text):
|
||||
self._busy(False)
|
||||
if text:
|
||||
self._run(text)
|
||||
else:
|
||||
self._log("sys", "Didn't catch that.")
|
||||
|
||||
run_async(self.pool, work, on_done=done, on_error=lambda e: (self._busy(False), self._log("sys", e)))
|
||||
|
|
@ -1,431 +0,0 @@
|
|||
"""Controller: the single in-memory Scene and every way to mutate it.
|
||||
|
||||
Buttons/menus call the typed methods directly (instant); voice/typed commands
|
||||
go through the Claude interpreter and are applied via `execute_call`, which
|
||||
reuses the CLI's command functions so GUI and CLI behave identically. Every
|
||||
mutation saves to disk (keeping the CLI/headless tools interoperable) and emits
|
||||
`changed` so the views refresh.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from .. import cli, driver
|
||||
from ..scene import Scene, SceneError, default_scene_path
|
||||
|
||||
|
||||
def _f(v):
|
||||
"""Parse an optional float arg ('' / None -> None)."""
|
||||
return float(v) if v not in (None, "") else None
|
||||
|
||||
|
||||
def _opt(v):
|
||||
"""Normalize an optional string arg ('' -> None)."""
|
||||
return v if v not in (None, "") else None
|
||||
|
||||
|
||||
# Map each wood-* tool to (cli command fn, namespace builder). Reusing the CLI
|
||||
# command functions means voice and CLI share one implementation.
|
||||
TOOL_CMD = {
|
||||
"wood-place": lambda a: (cli.cmd_place, SimpleNamespace(
|
||||
stock=a["stock"], length=a["length"], width=_opt(a.get("width")), unit="inch")),
|
||||
"wood-join": lambda a: (cli.cmd_join, SimpleNamespace(
|
||||
part_b=a["part_b"], part_a=_opt(a.get("to")), angle=float(a.get("angle") or 90),
|
||||
offset=_opt(a.get("offset")), anchor=a.get("anchor") or "end_b", unit="inch")),
|
||||
"wood-stand": lambda a: (cli.cmd_stand, SimpleNamespace(part=_opt(a.get("part")), tilt=float(a.get("tilt") or 90))),
|
||||
"wood-lay": lambda a: (cli.cmd_lay, SimpleNamespace(part=_opt(a.get("part")))),
|
||||
"wood-rotate": lambda a: (cli.cmd_rotate, SimpleNamespace(
|
||||
part=_opt(a.get("part")), yaw=_f(a.get("yaw")), tilt=_f(a.get("tilt")), roll=_f(a.get("roll")))),
|
||||
"wood-move": lambda a: (cli.cmd_move, SimpleNamespace(
|
||||
part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")),
|
||||
absolute=False, unit="inch")),
|
||||
"wood-trim": lambda a: (cli.cmd_trim, SimpleNamespace(length=a["length"], part=_opt(a.get("part")), unit="inch")),
|
||||
"wood-copy": lambda a: (cli.cmd_copy, SimpleNamespace(
|
||||
part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")), unit="inch")),
|
||||
"wood-rename": lambda a: (cli.cmd_rename, SimpleNamespace(name=a["name"], part=_opt(a.get("part")))),
|
||||
"wood-feature": lambda a: (cli.cmd_feature, SimpleNamespace(
|
||||
kind=a["kind"], part=_opt(a.get("part")), face=a.get("face") or "end_b",
|
||||
along=_opt(a.get("along")), across=_opt(a.get("across")), width=_opt(a.get("width")),
|
||||
height=_opt(a.get("height")), depth=_opt(a.get("depth")), diameter=_opt(a.get("diameter")),
|
||||
rotation=_opt(a.get("rotation")))),
|
||||
"wood-feature-delete": lambda a: (cli.cmd_feature_delete, SimpleNamespace(fid=a["fid"])),
|
||||
"wood-connect": lambda a: (cli.cmd_connect, SimpleNamespace(anchor=a["anchor"], moving=a["moving"])),
|
||||
"wood-explode": lambda a: (cli.cmd_explode, SimpleNamespace(distance=a["distance"])),
|
||||
"wood-assemble": lambda a: (cli.cmd_assemble, SimpleNamespace()),
|
||||
"wood-disconnect": lambda a: (cli.cmd_disconnect, SimpleNamespace(connection=a["connection"])),
|
||||
"wood-sand": lambda a: (cli.cmd_sand, SimpleNamespace(part=_opt(a.get("part")))),
|
||||
"wood-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))),
|
||||
"wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])),
|
||||
"wood-undo": lambda a: (cli.cmd_undo, SimpleNamespace()),
|
||||
"wood-redo": lambda a: (cli.cmd_redo, SimpleNamespace()),
|
||||
"wood-clear": lambda a: (cli.cmd_clear, SimpleNamespace()),
|
||||
"wood-cutlist": lambda a: (cli.cmd_cutlist, SimpleNamespace()),
|
||||
"wood-save": lambda a: (cli.cmd_save, SimpleNamespace(name=a["name"])),
|
||||
"wood-open": lambda a: (cli.cmd_open, SimpleNamespace(name=a["name"])),
|
||||
"wood-projects": lambda a: (cli.cmd_projects, SimpleNamespace()),
|
||||
}
|
||||
|
||||
|
||||
class Controller(QObject):
|
||||
changed = Signal() # scene or selection changed -> refresh views
|
||||
logged = Signal(str, str) # (who, text): who in {"you","ws","sys"}
|
||||
preview_changed = Signal() # pending feature preview changed -> redraw red ghost
|
||||
|
||||
def __init__(self, scene_path: str | None = None):
|
||||
super().__init__()
|
||||
self.scene_path = Path(scene_path) if scene_path else default_scene_path()
|
||||
self.scene = Scene.load(self.scene_path)
|
||||
self._schemas: str | None = None
|
||||
self.selected: list[str] = [self.scene.selection] if self.scene.selection else []
|
||||
self.active_feature: str | None = None # feature currently being edited
|
||||
self.preview = None # (Part, Feature) shown as an overlay, or None
|
||||
self.preview_kind = "edit" # "edit" (red pending) | "highlight" (cyan)
|
||||
|
||||
# ----- persistence / notify ----------------------------------------
|
||||
def save(self) -> None:
|
||||
self.scene.save(self.scene_path)
|
||||
|
||||
def _commit(self, message: str | None = None) -> None:
|
||||
self.save()
|
||||
if message:
|
||||
self.logged.emit("ws", message)
|
||||
self.changed.emit()
|
||||
|
||||
# ----- selection (single + multi) ----------------------------------
|
||||
def _valid(self, ids):
|
||||
have = {p.id for p in self.scene.parts}
|
||||
return [i for i in ids if i in have]
|
||||
|
||||
def set_selected(self, ids) -> None:
|
||||
"""Replace the whole selection set. Primary = last in the list."""
|
||||
self.selected = self._valid(list(dict.fromkeys(ids)))
|
||||
self.scene.selection = self.selected[-1] if self.selected else None
|
||||
self.changed.emit()
|
||||
|
||||
def select(self, ref: str | None) -> None:
|
||||
"""Single-select by id/name (e.g. from a 3D click without Ctrl)."""
|
||||
if not ref:
|
||||
self.set_selected([])
|
||||
return
|
||||
try:
|
||||
part = self.scene.get_part(ref)
|
||||
except SceneError:
|
||||
return
|
||||
self.set_selected([part.id])
|
||||
|
||||
def toggle(self, ref: str | None) -> None:
|
||||
"""Ctrl+click: add/remove a board from the selection."""
|
||||
if not ref:
|
||||
return
|
||||
try:
|
||||
pid = self.scene.get_part(ref).id
|
||||
except SceneError:
|
||||
return
|
||||
ids = list(self.selected)
|
||||
ids.remove(pid) if pid in ids else ids.append(pid)
|
||||
self.set_selected(ids)
|
||||
|
||||
def target_ids(self) -> list[str]:
|
||||
"""The boards an action applies to: the multi-selection, else the primary."""
|
||||
if self.selected:
|
||||
return list(self.selected)
|
||||
return [self.scene.selection] if self.scene.selection else []
|
||||
|
||||
@property
|
||||
def selected_id(self) -> str | None:
|
||||
return self.scene.selection
|
||||
|
||||
# ----- direct operations (buttons / menus) -------------------------
|
||||
def _do(self, fn) -> None:
|
||||
try:
|
||||
msg = fn()
|
||||
except (SceneError, ValueError, KeyError) as exc:
|
||||
self.logged.emit("sys", str(exc).strip('"'))
|
||||
return
|
||||
# A single op selects its result (e.g. placing a board selects it).
|
||||
self.selected = [self.scene.selection] if self.scene.selection else []
|
||||
self._commit(msg if isinstance(msg, str) else None)
|
||||
|
||||
def _do_group(self, op, verb: str) -> None:
|
||||
"""Apply `op(part_id)` to every selected board as a single undo step."""
|
||||
ids = self.target_ids()
|
||||
if not ids:
|
||||
self.logged.emit("sys", "Nothing selected.")
|
||||
return
|
||||
try:
|
||||
with self.scene.batch():
|
||||
for pid in ids:
|
||||
op(pid)
|
||||
except (SceneError, ValueError, KeyError) as exc:
|
||||
self.logged.emit("sys", str(exc).strip('"'))
|
||||
return
|
||||
self.selected = self._valid(self.selected) # drop any deleted ids
|
||||
if self.scene.selection not in {p.id for p in self.scene.parts}:
|
||||
self.scene.selection = self.selected[-1] if self.selected else None
|
||||
n = len(ids)
|
||||
self._commit(f"{verb} {n} board{'s' if n > 1 else ''}.")
|
||||
|
||||
def place(self, stock: str, length_in: float, width_in: float | None = None):
|
||||
self._do(lambda: f"Placed {self.scene.place(stock, length_in, width_in).id}.")
|
||||
|
||||
# group-aware (act on the whole selection)
|
||||
def stand(self): self._do_group(lambda pid: self.scene.stand(pid), "Stood up")
|
||||
def lay(self): self._do_group(lambda pid: self.scene.stand(pid, 0.0), "Laid flat")
|
||||
def sand(self): self._do_group(lambda pid: self.scene.finish(pid), "Sanded")
|
||||
def delete(self): self._do_group(lambda pid: self.scene.delete(pid), "Deleted")
|
||||
|
||||
def move_selected(self, dx=0.0, dy=0.0, dz=0.0):
|
||||
self._do_group(lambda pid: self.scene.move(pid, dx, dy, dz), "Moved")
|
||||
|
||||
def rotate_selected(self, dyaw=0.0, dtilt=0.0):
|
||||
def op(pid):
|
||||
p = self.scene.get_part(pid)
|
||||
self.scene.orient(pid, yaw=p.yaw_deg + dyaw, tilt=p.tilt_deg + dtilt)
|
||||
self._do_group(op, "Rotated")
|
||||
|
||||
def rotate_90(self): self.rotate_selected(dyaw=90)
|
||||
|
||||
# single-part (act on the primary selection)
|
||||
def undo(self): self._do(self.scene.undo)
|
||||
def redo(self): self._do(self.scene.redo)
|
||||
def clear(self): self._do(self.scene.clear)
|
||||
def duplicate(self): self._do(lambda: f"Copied to {self.scene.copy(self.scene.selection).id}.")
|
||||
def rename(self, ref, name): self._do(lambda: f"Named {self.scene.rename(ref, name).id}.")
|
||||
def set_length(self, ref, length_in): self._do(lambda: f"Cut {self.scene.set_length(ref, length_in).id}.")
|
||||
|
||||
def rotate(self, ref=None, yaw=None, tilt=None, roll=None):
|
||||
self._do(lambda: f"Oriented {self.scene.orient(ref, yaw=yaw, tilt=tilt, roll=roll).id}.")
|
||||
|
||||
# ----- joinery features --------------------------------------------
|
||||
def _feature_defaults(self, kind: str, part) -> dict:
|
||||
"""Sensible starting dimensions derived from the board's size."""
|
||||
t, w = part.section_in
|
||||
L = part.length_in
|
||||
if kind == "tenon":
|
||||
return dict(face="end_b", width_in=round(w / 2, 3), height_in=round(t / 2, 3), depth_in=1.0)
|
||||
if kind == "mortise":
|
||||
return dict(face="top", along_in=round(L / 2, 3), width_in=1.5,
|
||||
height_in=round(w / 2, 3), depth_in=round(t / 2, 3))
|
||||
if kind == "hole":
|
||||
return dict(face="top", along_in=round(L / 2, 3), diameter_in=0.375, depth_in=0.0)
|
||||
if kind == "slot":
|
||||
return dict(face="top", along_in=round(L / 2, 3), width_in=2.0,
|
||||
height_in=0.5, depth_in=round(t / 2, 3))
|
||||
if kind == "chamfer":
|
||||
return dict(face="end_b", width_in=round(min(t, w) / 3, 3), depth_in=0.0)
|
||||
return dict(face="top")
|
||||
|
||||
def add_feature(self, kind: str) -> None:
|
||||
if not self.scene.selection:
|
||||
self.logged.emit("sys", "Select a board first.")
|
||||
return
|
||||
part = self.scene.get_part(self.scene.selection)
|
||||
try:
|
||||
feat = self.scene.add_feature(part.id, kind, **self._feature_defaults(kind, part))
|
||||
except (SceneError, ValueError) as exc:
|
||||
self.logged.emit("sys", str(exc).strip('"'))
|
||||
return
|
||||
self.active_feature = feat.id
|
||||
self._commit(f"Added {kind} ({feat.id}) to {part.id}.")
|
||||
|
||||
def select_feature(self, fid: str | None) -> None:
|
||||
self.active_feature = fid
|
||||
self.changed.emit()
|
||||
|
||||
def edit_active_feature(self, **dims) -> None:
|
||||
if not self.active_feature:
|
||||
return
|
||||
try:
|
||||
self.scene.edit_feature(self.active_feature, **dims)
|
||||
except (SceneError, ValueError) as exc:
|
||||
self.logged.emit("sys", str(exc).strip('"'))
|
||||
return
|
||||
self._commit()
|
||||
|
||||
def delete_active_feature(self) -> None:
|
||||
if not self.active_feature:
|
||||
return
|
||||
try:
|
||||
msg = self.scene.delete_feature(self.active_feature)
|
||||
except SceneError:
|
||||
return
|
||||
self.active_feature = None
|
||||
self._commit(msg)
|
||||
|
||||
def active_feature_obj(self):
|
||||
if not self.active_feature:
|
||||
return None
|
||||
try:
|
||||
return self.scene.find_feature(self.active_feature)[1]
|
||||
except SceneError:
|
||||
return None
|
||||
|
||||
def features_of_kind(self, kind: str) -> list[tuple]:
|
||||
"""All (part, feature) of a kind, excluding the one being edited."""
|
||||
return [(p, f) for p in self.scene.parts for f in p.features
|
||||
if f.kind == kind and f.id != self.active_feature]
|
||||
|
||||
def fit_feature(self, target_fid: str, clearance: float = 1 / 32) -> None:
|
||||
"""Resize the active feature to mate with another (tenon<->mortise)."""
|
||||
feat = self.active_feature_obj()
|
||||
if not feat:
|
||||
return
|
||||
try:
|
||||
_, target = self.scene.find_feature(target_fid)
|
||||
except SceneError:
|
||||
return
|
||||
if feat.kind == "mortise" and target.kind == "tenon":
|
||||
dims = dict(width_in=target.width_in + clearance,
|
||||
height_in=target.height_in + clearance,
|
||||
depth_in=target.depth_in + clearance) # pocket slightly deeper
|
||||
elif feat.kind == "tenon" and target.kind == "mortise":
|
||||
dims = dict(width_in=max(target.width_in - clearance, 0.05),
|
||||
height_in=max(target.height_in - clearance, 0.05),
|
||||
depth_in=target.depth_in) # tongue reaches the bottom
|
||||
else:
|
||||
self.logged.emit("sys", "Fit a mortise to a tenon (or a tenon to a mortise).")
|
||||
return
|
||||
self.scene.edit_feature(feat.id, **dims)
|
||||
self._commit(f"Fitted {feat.id} to {target_fid}.")
|
||||
|
||||
def explode(self, distance: float = 3.0) -> None:
|
||||
self._do(lambda: self.scene.explode(distance))
|
||||
|
||||
def assemble(self) -> None:
|
||||
self._do(self.scene.assemble)
|
||||
|
||||
def break_connections(self, part_id: str | None = None) -> None:
|
||||
self._do(lambda: self.scene.disconnect(part=part_id) if part_id
|
||||
else self.scene.disconnect())
|
||||
|
||||
def feature_connection_ids(self, fid: str) -> list[str]:
|
||||
return [c.id for c in self.scene.connections if fid in (c.anchor, c.moving)]
|
||||
|
||||
def break_feature_connection(self, fid: str) -> None:
|
||||
"""Break the connection(s) that this specific feature is part of."""
|
||||
cids = self.feature_connection_ids(fid)
|
||||
if not cids:
|
||||
return
|
||||
|
||||
def op():
|
||||
with self.scene.batch():
|
||||
for cid in cids:
|
||||
self.scene.disconnect(cid=cid)
|
||||
return f"Broke {len(cids)} connection(s) on {fid}."
|
||||
|
||||
self._do(op)
|
||||
|
||||
def groups(self) -> list[list[str]]:
|
||||
return self.scene.groups()
|
||||
|
||||
def make_connection(self, target_fid: str, move_self: bool = False) -> None:
|
||||
"""Seat the two features together. By default the target's board moves;
|
||||
move_self moves the active feature's board instead. The moving board's
|
||||
whole sub-assembly travels with it."""
|
||||
feat = self.active_feature_obj()
|
||||
if not feat:
|
||||
return
|
||||
anchor, moving = (target_fid, feat.id) if move_self else (feat.id, target_fid)
|
||||
try:
|
||||
msg = self.scene.connect(anchor, moving)
|
||||
except SceneError as exc:
|
||||
self.logged.emit("sys", str(exc).strip('"'))
|
||||
return
|
||||
self._commit(msg)
|
||||
|
||||
# ----- live preview of a pending feature edit ----------------------
|
||||
def set_preview(self, **fields) -> None:
|
||||
"""Stash a pending edit (does NOT change the model) and redraw the ghost."""
|
||||
feat = self.active_feature_obj()
|
||||
if not feat:
|
||||
self.preview = None
|
||||
else:
|
||||
part = self.scene.find_feature(feat.id)[0]
|
||||
pending = copy.copy(feat)
|
||||
for k, val in fields.items():
|
||||
if val is not None and hasattr(pending, k):
|
||||
setattr(pending, k, val)
|
||||
self.preview = (part, pending)
|
||||
self.preview_kind = "edit"
|
||||
self.preview_changed.emit()
|
||||
|
||||
def highlight_feature(self, fid: str | None) -> None:
|
||||
"""Show a cyan highlight of a feature in the scene (no edit)."""
|
||||
if not fid:
|
||||
self.preview = None
|
||||
else:
|
||||
try:
|
||||
part, feat = self.scene.find_feature(fid)
|
||||
self.preview = (part, copy.copy(feat))
|
||||
self.preview_kind = "highlight"
|
||||
except SceneError:
|
||||
self.preview = None
|
||||
self.preview_changed.emit()
|
||||
|
||||
def clear_preview(self) -> None:
|
||||
self.preview = None
|
||||
self.preview_changed.emit()
|
||||
|
||||
def apply_preview(self) -> None:
|
||||
"""Commit the pending edit to the real feature (then re-render geometry)."""
|
||||
if not self.preview:
|
||||
return
|
||||
_, pending = self.preview
|
||||
self.preview = None
|
||||
self.scene.edit_feature(
|
||||
pending.id, face=pending.face, along_in=pending.along_in,
|
||||
across_in=pending.across_in, width_in=pending.width_in,
|
||||
height_in=pending.height_in, depth_in=pending.depth_in,
|
||||
diameter_in=pending.diameter_in, rotation_deg=pending.rotation_deg)
|
||||
self._commit() # re-tessellates with the new geometry
|
||||
self.preview_changed.emit() # clear the ghost
|
||||
|
||||
# ----- project / export --------------------------------------------
|
||||
def open_project(self, name): self._do(lambda: cli.cmd_open(self.scene, SimpleNamespace(name=name)))
|
||||
def save_project(self, name):
|
||||
from ..scene import project_path
|
||||
self.scene.save(project_path(name))
|
||||
self.logged.emit("ws", f"Saved project '{name}'.")
|
||||
|
||||
def export(self, path: str):
|
||||
from ..geometry import export
|
||||
self.logged.emit("ws", f"Exported to {export(self.scene, path)}.")
|
||||
|
||||
def cutlist_text(self) -> str:
|
||||
from ..cutlist import format_cutlist
|
||||
return format_cutlist(self.scene)
|
||||
|
||||
# ----- voice / typed commands --------------------------------------
|
||||
def schemas(self) -> str:
|
||||
if self._schemas is None:
|
||||
self._schemas = driver.load_schemas()
|
||||
return self._schemas
|
||||
|
||||
def execute_call(self, tool: str, args: dict) -> str:
|
||||
"""In-process executor for driver.dispatch (mutates the live scene)."""
|
||||
entry = TOOL_CMD.get(tool)
|
||||
if entry is None:
|
||||
return f"(unknown tool {tool})"
|
||||
func, ns = entry(args)
|
||||
try:
|
||||
return func(self.scene, ns)
|
||||
except (SceneError, ValueError, KeyError) as exc:
|
||||
return str(exc).strip('"')
|
||||
|
||||
def run_command(self, text: str) -> str:
|
||||
"""Interpret a spoken/typed command and apply it. Returns a spoken summary.
|
||||
(Slow — call from a worker thread.)"""
|
||||
from ..scene import spatial_summary
|
||||
self.save() # ensure disk reflects current state
|
||||
sel = ", ".join(self.selected) if self.selected else "none"
|
||||
scene_text = (cli.cmd_status(self.scene, None)
|
||||
+ f"\nCurrently selected ('these' / 'them' / 'the selected'): {sel}"
|
||||
+ "\n" + spatial_summary(self.scene))
|
||||
calls = driver.interpret(text, self.schemas(), scene_text=scene_text)
|
||||
messages = driver.dispatch(calls, verbose=False, executor=self.execute_call)
|
||||
self._commit()
|
||||
return driver.summarize(calls, messages)
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
"""Joinery panel: add tenon/mortise/hole/slot/chamfer features to the selected
|
||||
board, then tweak the active feature's fields. Add-with-default-then-edit:
|
||||
clicking a kind drops a sensibly-sized feature you can immediately adjust."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
||||
QDoubleSpinBox, QFormLayout, QGridLayout, QHBoxLayout,
|
||||
QLabel, QListWidget, QListWidgetItem, QMenu,
|
||||
QMessageBox, QPushButton, QVBoxLayout, QWidget)
|
||||
|
||||
from ..scene import FACES
|
||||
from .controller import Controller
|
||||
|
||||
_KINDS = ["tenon", "mortise", "hole", "slot", "chamfer"]
|
||||
|
||||
|
||||
class FeaturePanel(QWidget):
|
||||
def __init__(self, controller: Controller, parent=None):
|
||||
super().__init__(parent)
|
||||
self.c = controller
|
||||
self._loading = False
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.addWidget(QLabel("<b>Joinery on the selected board</b>"))
|
||||
|
||||
add = QGridLayout()
|
||||
for i, kind in enumerate(_KINDS):
|
||||
b = QPushButton("+ " + kind.capitalize())
|
||||
b.clicked.connect(lambda _=False, k=kind: self.c.add_feature(k))
|
||||
add.addWidget(b, i // 2, i % 2)
|
||||
root.addLayout(add)
|
||||
|
||||
self.list = QListWidget()
|
||||
self.list.itemSelectionChanged.connect(self._on_row)
|
||||
self.list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.list.customContextMenuRequested.connect(self._feat_menu)
|
||||
root.addWidget(self.list, 1)
|
||||
|
||||
self.hint = QLabel("")
|
||||
self.hint.setWordWrap(True)
|
||||
self.hint.setStyleSheet("color:#aaaaaa; font-size:11px;")
|
||||
root.addWidget(self.hint)
|
||||
|
||||
form = QFormLayout()
|
||||
self.face = QComboBox(); self.face.addItems(FACES)
|
||||
self.face.currentIndexChanged.connect(self._preview)
|
||||
# (key, label, tooltip)
|
||||
self._fields = [
|
||||
("along_in", "Along board", "Position along the board's length (or 1st offset on an end)"),
|
||||
("across_in", "Across", "Offset from the centre of the face"),
|
||||
("width_in", "Width", "Feature size across the face (1st dimension)"),
|
||||
("height_in", "Height", "Feature size across the face (2nd dimension)"),
|
||||
("depth_in", "Depth", "How deep it cuts — or how far a tenon sticks out"),
|
||||
("diameter_in", "Diameter", "Hole diameter (holes only)"),
|
||||
("rotation_deg", "Rotate", "Spin the feature about its face normal to line up the cross-section"),
|
||||
]
|
||||
self._spins = {}
|
||||
for key, label, tip in self._fields:
|
||||
sp = QDoubleSpinBox()
|
||||
if key == "rotation_deg":
|
||||
sp.setRange(-180, 180); sp.setSingleStep(15); sp.setSuffix(" °")
|
||||
else:
|
||||
sp.setRange(-48, 96); sp.setSingleStep(0.25); sp.setSuffix(" in")
|
||||
sp.setToolTip(tip)
|
||||
sp.valueChanged.connect(self._preview) # live red ghost as you drag
|
||||
self._spins[key] = sp
|
||||
form.addRow(label, sp)
|
||||
form.insertRow(0, "Face", self.face)
|
||||
root.addLayout(form)
|
||||
|
||||
self.fit_btn = QPushButton("Fit to mate…")
|
||||
self.fit_btn.setToolTip("Resize this tenon/mortise to fit a matching one on another board")
|
||||
self.fit_btn.clicked.connect(self._fit)
|
||||
root.addWidget(self.fit_btn)
|
||||
|
||||
btns = QHBoxLayout()
|
||||
self.apply_btn = QPushButton("Apply")
|
||||
self.apply_btn.setToolTip("Commit the previewed change (cuts/adds the real geometry)")
|
||||
self.apply_btn.clicked.connect(self.c.apply_preview)
|
||||
self.del_btn = QPushButton("Delete feature")
|
||||
self.del_btn.clicked.connect(self.c.delete_active_feature)
|
||||
btns.addWidget(self.apply_btn)
|
||||
btns.addWidget(self.del_btn)
|
||||
root.addLayout(btns)
|
||||
|
||||
self.c.changed.connect(self.refresh)
|
||||
self.refresh()
|
||||
|
||||
def _part(self):
|
||||
pid = self.c.selected_id
|
||||
return next((p for p in self.c.scene.parts if p.id == pid), None) if pid else None
|
||||
|
||||
def refresh(self) -> None:
|
||||
self._loading = True
|
||||
part = self._part()
|
||||
self.list.clear()
|
||||
feats = part.features if part else []
|
||||
# Map each connected feature -> the board(s) it mates with.
|
||||
mates: dict[str, list[str]] = {}
|
||||
for c in self.c.scene.connections:
|
||||
if not self.c.scene._conn_valid(c):
|
||||
continue
|
||||
ap, mp = self.c.scene.feature_owner(c.anchor), self.c.scene.feature_owner(c.moving)
|
||||
mates.setdefault(c.anchor, []).append(mp.name or mp.id)
|
||||
mates.setdefault(c.moving, []).append(ap.name or ap.id)
|
||||
# keep the active feature pointing at something on this board
|
||||
ids = [f.id for f in feats]
|
||||
if self.c.active_feature not in ids:
|
||||
self.c.active_feature = ids[0] if ids else None
|
||||
for f in feats:
|
||||
mark = f" 🔗 → {', '.join(mates[f.id])}" if f.id in mates else ""
|
||||
label = f"{f.id}: {f.kind} · {f.face}{mark}"
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.UserRole, f.id)
|
||||
self.list.addItem(item)
|
||||
if f.id == self.c.active_feature:
|
||||
item.setSelected(True)
|
||||
|
||||
feat = self.c.active_feature_obj()
|
||||
editable = feat is not None
|
||||
self.face.setEnabled(editable)
|
||||
self.del_btn.setEnabled(editable)
|
||||
self.apply_btn.setEnabled(editable)
|
||||
for sp in self._spins.values():
|
||||
sp.setEnabled(editable)
|
||||
if feat:
|
||||
self.face.setCurrentText(feat.face)
|
||||
for key, sp in self._spins.items():
|
||||
sp.setValue(getattr(feat, key))
|
||||
self.hint.setText(_HINTS.get(feat.kind, "") if feat else
|
||||
"Add a feature above, then adjust it here.")
|
||||
mate = {"tenon": "mortise", "mortise": "tenon"}.get(feat.kind if feat else "")
|
||||
self.fit_btn.setEnabled(bool(mate))
|
||||
self.fit_btn.setText(f"Fit to {mate}…" if mate else "Fit to mate…")
|
||||
self._loading = False
|
||||
# Highlight the selected feature in the scene (cyan), or clear if none.
|
||||
self.c.highlight_feature(feat.id if feat else None)
|
||||
|
||||
def _on_row(self) -> None:
|
||||
if self._loading:
|
||||
return
|
||||
items = self.list.selectedItems()
|
||||
if items:
|
||||
self.c.select_feature(items[0].data(Qt.UserRole))
|
||||
|
||||
def _feat_menu(self, pos) -> None:
|
||||
item = self.list.itemAt(pos)
|
||||
if not item:
|
||||
return
|
||||
fid = item.data(Qt.UserRole)
|
||||
self.c.select_feature(fid)
|
||||
menu = QMenu(self)
|
||||
if self.c.feature_connection_ids(fid):
|
||||
menu.addAction("Break this connection",
|
||||
lambda: self.c.break_feature_connection(fid))
|
||||
menu.addAction("Delete feature", self.c.delete_active_feature)
|
||||
menu.exec(self.list.viewport().mapToGlobal(pos))
|
||||
|
||||
def _preview(self) -> None:
|
||||
"""Live: show a red ghost of the pending values (no commit until Apply)."""
|
||||
if self._loading or not self.c.active_feature:
|
||||
return
|
||||
dims = {key: sp.value() for key, sp in self._spins.items()}
|
||||
self.c.set_preview(face=self.face.currentText(), **dims)
|
||||
|
||||
def _fit(self) -> None:
|
||||
feat = self.c.active_feature_obj()
|
||||
mate = {"tenon": "mortise", "mortise": "tenon"}.get(feat.kind if feat else "")
|
||||
if not mate:
|
||||
return
|
||||
cands = self.c.features_of_kind(mate)
|
||||
if not cands:
|
||||
QMessageBox.information(self, "Fit", f"No {mate} on another board to fit to.")
|
||||
return
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle(f"Fit {feat.kind} to a {mate}")
|
||||
lay = QVBoxLayout(dlg)
|
||||
lay.addWidget(QLabel(f"Select the {mate} to mate with:"))
|
||||
lst = QListWidget()
|
||||
for part, f in cands:
|
||||
who = f"{part.id} ({part.name})" if part.name else part.id
|
||||
label = f"{who} · {f.id}: {f.kind} {f.width_in:g}×{f.height_in:g}×{f.depth_in:g}"
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.UserRole, f.id)
|
||||
lst.addItem(item)
|
||||
# Highlight whichever candidate is selected so the user sees it in 3D.
|
||||
lst.currentItemChanged.connect(
|
||||
lambda cur, _prev: self.c.highlight_feature(cur.data(Qt.UserRole)) if cur else None)
|
||||
lst.setCurrentRow(0)
|
||||
lay.addWidget(lst)
|
||||
connect_cb = QCheckBox("Make connection (seat the joint together)")
|
||||
lay.addWidget(connect_cb)
|
||||
which = QComboBox()
|
||||
which.addItems(["Reposition the other board", "Reposition this board"])
|
||||
which.setToolTip("Which board moves to seat the joint (its whole assembly moves with it)")
|
||||
which.setEnabled(False)
|
||||
connect_cb.toggled.connect(which.setEnabled)
|
||||
lay.addWidget(which)
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
bb.accepted.connect(dlg.accept)
|
||||
bb.rejected.connect(dlg.reject)
|
||||
lay.addWidget(bb)
|
||||
accepted = dlg.exec()
|
||||
if accepted and lst.currentItem():
|
||||
target = lst.currentItem().data(Qt.UserRole)
|
||||
self.c.fit_feature(target) # size to match
|
||||
if connect_cb.isChecked():
|
||||
self.c.make_connection(target, move_self=which.currentIndex() == 1)
|
||||
else:
|
||||
self.c.highlight_feature(feat.id) # restore highlight on cancel
|
||||
|
||||
|
||||
_HINTS = {
|
||||
"tenon": "Tenon — a tongue on the chosen end. Width × Height = its cross-section; "
|
||||
"Depth = how far it sticks out; Across = shift it off-centre.",
|
||||
"mortise": "Mortise — a pocket. Along = position down the board; Width × Height = "
|
||||
"the opening on the face; Depth = how deep it goes.",
|
||||
"hole": "Hole — Along = position down the board; Across = off-centre; "
|
||||
"Diameter = size; Depth 0 = drilled all the way through.",
|
||||
"slot": "Slot — a channel. Along = position; Width × Height = the opening; "
|
||||
"Depth = how deep.",
|
||||
"chamfer": "Chamfer — bevels the edges around the chosen Face; Width = bevel size "
|
||||
"(the red preview highlights the face).",
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
"""The WoodShop studio main window: viewport + parts panel on top, command bar
|
||||
below, with menus tying everything to the controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtGui import QAction, QKeySequence
|
||||
from PySide6.QtWidgets import (QFileDialog, QInputDialog, QMainWindow, QMessageBox,
|
||||
QSplitter, QTabWidget, QVBoxLayout, QWidget)
|
||||
|
||||
from ..cutlist import board_feet
|
||||
from ..scene import list_projects
|
||||
from .command_bar import CommandBar
|
||||
from .controller import Controller
|
||||
from .feature_panel import FeaturePanel
|
||||
from .numpad import NumpadPanel
|
||||
from .panels import PartsPanel
|
||||
from .viewport import Viewport
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, scene_path: str | None = None):
|
||||
super().__init__()
|
||||
self.setWindowTitle("WoodShop")
|
||||
self.resize(1280, 860)
|
||||
self.pool = QThreadPool.globalInstance()
|
||||
|
||||
self.controller = Controller(scene_path)
|
||||
self.viewport = Viewport()
|
||||
self.parts = PartsPanel(self.controller)
|
||||
self.features_panel = FeaturePanel(self.controller)
|
||||
self.numpad = NumpadPanel(self.controller, self.viewport)
|
||||
self.command = CommandBar(self.controller, self.pool)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self.parts, "Parts")
|
||||
tabs.addTab(self.features_panel, "Joinery")
|
||||
|
||||
right = QWidget()
|
||||
rlayout = QVBoxLayout(right)
|
||||
rlayout.setContentsMargins(0, 0, 0, 0)
|
||||
rlayout.addWidget(tabs, 1)
|
||||
rlayout.addWidget(self.numpad)
|
||||
|
||||
top = QSplitter(Qt.Horizontal)
|
||||
top.addWidget(self.viewport)
|
||||
top.addWidget(right)
|
||||
top.setStretchFactor(0, 3)
|
||||
top.setStretchFactor(1, 1)
|
||||
|
||||
split = QSplitter(Qt.Vertical)
|
||||
split.addWidget(top)
|
||||
split.addWidget(self.command)
|
||||
split.setStretchFactor(0, 4)
|
||||
split.setStretchFactor(1, 1)
|
||||
self.setCentralWidget(split)
|
||||
|
||||
self.viewport.picked.connect(self._on_pick)
|
||||
self.controller.changed.connect(self._on_changed)
|
||||
self.controller.preview_changed.connect(
|
||||
lambda: self.viewport.set_preview(self.controller.preview,
|
||||
self.controller.preview_kind))
|
||||
self._build_menus()
|
||||
self._on_changed() # initial render + status
|
||||
|
||||
def _on_pick(self, pid: str, additive: bool):
|
||||
self.controller.toggle(pid) if additive else self.controller.select(pid)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
# Physical numpad drives the move/rotate panel — but only when the user
|
||||
# isn't typing in the command box.
|
||||
if (event.modifiers() & Qt.KeypadModifier) and not self.command.input.hasFocus():
|
||||
if self.numpad.trigger(event.key()):
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# ----- menus -------------------------------------------------------
|
||||
def _act(self, menu, text, slot, shortcut=None):
|
||||
a = QAction(text, self)
|
||||
if shortcut:
|
||||
a.setShortcut(QKeySequence(shortcut))
|
||||
a.triggered.connect(slot)
|
||||
menu.addAction(a)
|
||||
return a
|
||||
|
||||
def _build_menus(self):
|
||||
mb = self.menuBar()
|
||||
|
||||
f = mb.addMenu("&File")
|
||||
self._act(f, "&New", self.controller.clear, "Ctrl+N")
|
||||
self._act(f, "&Open Project…", self._open_project, "Ctrl+O")
|
||||
self._act(f, "&Save Project…", self._save_project, "Ctrl+S")
|
||||
f.addSeparator()
|
||||
self._act(f, "&Export STL/STEP…", self._export)
|
||||
self._act(f, "Save &Image…", self._render)
|
||||
f.addSeparator()
|
||||
self._act(f, "&Quit", self.close, "Ctrl+Q")
|
||||
|
||||
e = mb.addMenu("&Edit")
|
||||
self._act(e, "&Undo", self.controller.undo, "Ctrl+Z")
|
||||
self._act(e, "&Redo", self.controller.redo, "Ctrl+Y")
|
||||
e.addSeparator()
|
||||
self._act(e, "&Delete selected", self.controller.delete, "Del")
|
||||
self._act(e, "&Clear scene", self.controller.clear)
|
||||
|
||||
v = mb.addMenu("&View")
|
||||
self._act(v, "Top", lambda: self._camera(self.viewport.plotter.view_xy))
|
||||
self._act(v, "Front", lambda: self._camera(self.viewport.plotter.view_xz))
|
||||
self._act(v, "Side", lambda: self._camera(self.viewport.plotter.view_yz))
|
||||
self._act(v, "Isometric", lambda: self._camera(self.viewport.plotter.view_isometric))
|
||||
self._act(v, "Fit", lambda: self._camera(self.viewport.plotter.reset_camera))
|
||||
|
||||
b = mb.addMenu("&Build")
|
||||
self._act(b, "Cut list / BOM…", self._show_cutlist)
|
||||
self._act(b, "Table base…", lambda: self._template(
|
||||
"build a table base: a {L} by {W} frame of 2x4s with four legs {H} tall standing at the corners",
|
||||
[("Length", "48 in"), ("Width", "24 in"), ("Leg height", "29 in")]))
|
||||
self._act(b, "Bookshelf side…", lambda: self._template(
|
||||
"build a bookshelf side: two {H} 2x4 uprights {W} apart with {N} shelves of 1x8 between them",
|
||||
[("Height", "48 in"), ("Width", "12 in"), ("Shelves", "3")]))
|
||||
|
||||
h = mb.addMenu("&Help")
|
||||
self._act(h, "Commands…", self._show_help)
|
||||
|
||||
# ----- slots -------------------------------------------------------
|
||||
def _on_changed(self):
|
||||
scene = self.controller.scene
|
||||
self.viewport.render_scene(scene, self.controller.selected)
|
||||
# render_scene clears all actors — re-apply the feature overlay on top.
|
||||
self.viewport.set_preview(self.controller.preview, self.controller.preview_kind)
|
||||
bf = sum(board_feet(p.stock, p.length_in) for p in scene.parts)
|
||||
sel = self.controller.selected
|
||||
sel_txt = (f"{len(sel)} selected" if len(sel) > 1
|
||||
else (sel[0] if sel else "none"))
|
||||
self.statusBar().showMessage(
|
||||
f"{len(scene.parts)} part(s) · {bf:.1f} board-feet · selection: {sel_txt}")
|
||||
|
||||
def _camera(self, fn):
|
||||
fn()
|
||||
self.viewport.plotter.render()
|
||||
|
||||
def _open_project(self):
|
||||
names = list_projects()
|
||||
if not names:
|
||||
QMessageBox.information(self, "Open", "No saved projects yet.")
|
||||
return
|
||||
name, ok = QInputDialog.getItem(self, "Open Project", "Project:", names, 0, False)
|
||||
if ok and name:
|
||||
self.controller.open_project(name)
|
||||
|
||||
def _save_project(self):
|
||||
name, ok = QInputDialog.getText(self, "Save Project", "Name:")
|
||||
if ok and name.strip():
|
||||
self.controller.save_project(name.strip())
|
||||
|
||||
def _export(self):
|
||||
path, _ = QFileDialog.getSaveFileName(self, "Export", "model.step",
|
||||
"CAD/Mesh (*.step *.stl)")
|
||||
if path:
|
||||
try:
|
||||
self.controller.export(path)
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "Export failed", str(exc))
|
||||
|
||||
def _render(self):
|
||||
path, _ = QFileDialog.getSaveFileName(self, "Save Image", "model.png", "PNG (*.png)")
|
||||
if path:
|
||||
self.viewport.plotter.screenshot(path)
|
||||
self.controller.logged.emit("ws", f"Saved image to {path}.")
|
||||
|
||||
def _template(self, template, fields):
|
||||
keys = {"Length": "L", "Width": "W", "Leg height": "H",
|
||||
"Height": "H", "Shelves": "N"}
|
||||
vals = {}
|
||||
for label, default in fields:
|
||||
text, ok = QInputDialog.getText(self, "Build", f"{label}:", text=default)
|
||||
if not ok:
|
||||
return
|
||||
vals[keys[label]] = text
|
||||
self.command.submit(template.format(**vals))
|
||||
|
||||
def _show_cutlist(self):
|
||||
from .bom_window import BomWindow
|
||||
self._bom = BomWindow(self.controller, self) # keep a ref so it isn't GC'd
|
||||
self._bom.show()
|
||||
|
||||
def _show_help(self):
|
||||
QMessageBox.information(self, "Commands", _HELP)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.controller.save()
|
||||
self.viewport.close_viewport()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
_HELP = """Speak or type, e.g.:
|
||||
• place a 6 foot 2x4
|
||||
• build a coffee table with four 18 inch legs
|
||||
• stand it up / lay it flat / rotate 90 degrees
|
||||
• move that 5 inches along x
|
||||
• select the front-left leg (or click a board)
|
||||
• sand it / delete that / undo / redo
|
||||
• what's my cut list? / save this as my table
|
||||
|
||||
Click a board (3D or list) to select it. Buttons act on the selection.
|
||||
"""
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
"""Numberpad control panel: move/rotate the selection without talking, by
|
||||
clicking buttons laid out like a numpad — or by pressing the physical numpad
|
||||
keys (MainWindow forwards them via `action_for`).
|
||||
|
||||
Layout (mirrors a keyboard numpad):
|
||||
7 ⟲yaw 8 +Y 9 ⟳yaw
|
||||
4 −X 5 Fit 6 +X
|
||||
1 ⤓tilt 2 −Y 3 ⤒tilt
|
||||
0 Front . Iso +Z↑ / −Z↓
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (QDoubleSpinBox, QGridLayout, QGroupBox, QHBoxLayout,
|
||||
QLabel, QPushButton, QVBoxLayout)
|
||||
|
||||
from .controller import Controller
|
||||
|
||||
|
||||
class NumpadPanel(QGroupBox):
|
||||
def __init__(self, controller: Controller, viewport, parent=None):
|
||||
super().__init__("Move / Rotate", parent)
|
||||
self.c = controller
|
||||
self.vp = viewport
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
steps = QHBoxLayout()
|
||||
self.step = QDoubleSpinBox(); self.step.setRange(0.05, 48); self.step.setValue(1.0)
|
||||
self.step.setSuffix(" in"); self.step.setSingleStep(0.5)
|
||||
self.angle = QDoubleSpinBox(); self.angle.setRange(1, 180); self.angle.setValue(15)
|
||||
self.angle.setSuffix(" °")
|
||||
steps.addWidget(QLabel("Step")); steps.addWidget(self.step)
|
||||
steps.addWidget(QLabel("Angle")); steps.addWidget(self.angle)
|
||||
root.addLayout(steps)
|
||||
|
||||
# key -> (button label, callable). Buttons and physical keys share this.
|
||||
self._actions = {
|
||||
Qt.Key_7: ("7\n⟲ yaw", lambda: self.c.rotate_selected(dyaw=-self._a())),
|
||||
Qt.Key_8: ("8\n+Y ↑", lambda: self.c.move_selected(dy=self._s())),
|
||||
Qt.Key_9: ("9\n⟳ yaw", lambda: self.c.rotate_selected(dyaw=self._a())),
|
||||
Qt.Key_4: ("4\n−X ←", lambda: self.c.move_selected(dx=-self._s())),
|
||||
Qt.Key_5: ("5\nFit", self.vp.fit),
|
||||
Qt.Key_6: ("6\n+X →", lambda: self.c.move_selected(dx=self._s())),
|
||||
Qt.Key_1: ("1\n⤓ tilt", lambda: self.c.rotate_selected(dtilt=-self._a())),
|
||||
Qt.Key_2: ("2\n−Y ↓", lambda: self.c.move_selected(dy=-self._s())),
|
||||
Qt.Key_3: ("3\n⤒ tilt", lambda: self.c.rotate_selected(dtilt=self._a())),
|
||||
Qt.Key_0: ("0\nFront", self.vp.set_front),
|
||||
Qt.Key_Period: (".\nIso", self.vp.set_iso),
|
||||
Qt.Key_Plus: ("+\nZ ↑", lambda: self.c.move_selected(dz=self._s())),
|
||||
Qt.Key_Minus: ("−\nZ ↓", lambda: self.c.move_selected(dz=-self._s())),
|
||||
}
|
||||
|
||||
grid = QGridLayout()
|
||||
positions = {
|
||||
Qt.Key_7: (0, 0), Qt.Key_8: (0, 1), Qt.Key_9: (0, 2),
|
||||
Qt.Key_4: (1, 0), Qt.Key_5: (1, 1), Qt.Key_6: (1, 2),
|
||||
Qt.Key_1: (2, 0), Qt.Key_2: (2, 1), Qt.Key_3: (2, 2),
|
||||
Qt.Key_0: (3, 0), Qt.Key_Period: (3, 1),
|
||||
Qt.Key_Plus: (4, 0), Qt.Key_Minus: (4, 1),
|
||||
}
|
||||
for key, (label, _) in self._actions.items():
|
||||
b = QPushButton(label)
|
||||
b.setMinimumHeight(36)
|
||||
b.clicked.connect(lambda _=False, k=key: self.trigger(k))
|
||||
r, col = positions[key]
|
||||
grid.addWidget(b, r, col)
|
||||
root.addLayout(grid)
|
||||
root.addWidget(QLabel("<i>Tip: use the keyboard numpad too.</i>"))
|
||||
|
||||
def _s(self) -> float:
|
||||
return self.step.value()
|
||||
|
||||
def _a(self) -> float:
|
||||
return self.angle.value()
|
||||
|
||||
def trigger(self, key) -> bool:
|
||||
"""Run the action bound to a numpad key. Returns True if handled."""
|
||||
entry = self._actions.get(key)
|
||||
if entry:
|
||||
entry[1]()
|
||||
return True
|
||||
return False
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
"""Parts panel: the list of boards, the selected-part inspector with editable
|
||||
fields, and quick-action buttons. Makes the current selection visible (the thing
|
||||
that solves "delete that" ambiguity)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (QAbstractItemView, QComboBox, QDoubleSpinBox,
|
||||
QFormLayout, QGridLayout, QGroupBox, QHBoxLayout,
|
||||
QInputDialog, QLabel, QMenu, QPushButton, QTreeWidget,
|
||||
QTreeWidgetItem, QVBoxLayout, QWidget)
|
||||
|
||||
from ..lumber import NOMINAL_TO_ACTUAL, PLYWOOD_FRACTIONS, is_plywood
|
||||
from .controller import Controller
|
||||
|
||||
|
||||
class PartsPanel(QWidget):
|
||||
def __init__(self, controller: Controller, parent=None):
|
||||
super().__init__(parent)
|
||||
self.c = controller
|
||||
self._loading = False
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.addWidget(QLabel("<b>Parts</b> <span style='color:#888'>(connected boards group into assemblies)</span>"))
|
||||
|
||||
# Quick manual add — no AI needed.
|
||||
add = QHBoxLayout()
|
||||
self.stock = QComboBox()
|
||||
self.stock.addItems(sorted(NOMINAL_TO_ACTUAL, key=lambda s: (s[0], len(s), s))
|
||||
+ [f"ply-{f}" for f in PLYWOOD_FRACTIONS])
|
||||
self.stock.setCurrentText("2x4")
|
||||
self.stock.currentTextChanged.connect(self._stock_changed)
|
||||
self.add_len = QDoubleSpinBox(); self.add_len.setRange(0.5, 480)
|
||||
self.add_len.setValue(24); self.add_len.setSuffix(" in")
|
||||
self.add_wid = QDoubleSpinBox(); self.add_wid.setRange(0.5, 96)
|
||||
self.add_wid.setValue(24); self.add_wid.setPrefix("w "); self.add_wid.setSuffix(" in")
|
||||
self.add_wid.setToolTip("Panel width (plywood)"); self.add_wid.setEnabled(False)
|
||||
add_btn = QPushButton("+ Add")
|
||||
add_btn.clicked.connect(self._add_board)
|
||||
add.addWidget(self.stock); add.addWidget(self.add_len)
|
||||
add.addWidget(self.add_wid); add.addWidget(add_btn)
|
||||
root.addLayout(add)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection) # Ctrl/Shift multi-select
|
||||
self.tree.itemSelectionChanged.connect(self._on_row_selected)
|
||||
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.tree.customContextMenuRequested.connect(self._context_menu)
|
||||
root.addWidget(self.tree, 1)
|
||||
|
||||
box = QGroupBox("Selected")
|
||||
bl = QVBoxLayout(box)
|
||||
self.detail = QLabel("nothing selected")
|
||||
self.detail.setWordWrap(True)
|
||||
bl.addWidget(self.detail)
|
||||
|
||||
# quick actions
|
||||
grid = QGridLayout()
|
||||
actions = [
|
||||
("Stand", lambda: self.c.stand()), ("Lay", lambda: self.c.lay()),
|
||||
("Rotate 90°", lambda: self.c.rotate_90()), ("Sand", lambda: self.c.sand()),
|
||||
("Duplicate", lambda: self.c.duplicate()), ("Rename", self._rename),
|
||||
("Delete", lambda: self.c.delete()),
|
||||
]
|
||||
for i, (label, fn) in enumerate(actions):
|
||||
b = QPushButton(label)
|
||||
b.clicked.connect(lambda _=False, f=fn: f())
|
||||
grid.addWidget(b, i // 2, i % 2)
|
||||
bl.addLayout(grid)
|
||||
|
||||
# editable fields
|
||||
form = QFormLayout()
|
||||
self.len_spin = QDoubleSpinBox(); self.len_spin.setRange(0.1, 480); self.len_spin.setSuffix(" in")
|
||||
self.yaw_spin = QDoubleSpinBox(); self.yaw_spin.setRange(-360, 360); self.yaw_spin.setSuffix(" °")
|
||||
self.tilt_spin = QDoubleSpinBox(); self.tilt_spin.setRange(-180, 180); self.tilt_spin.setSuffix(" °")
|
||||
self.len_spin.editingFinished.connect(self._apply_length)
|
||||
self.yaw_spin.editingFinished.connect(self._apply_orientation)
|
||||
self.tilt_spin.editingFinished.connect(self._apply_orientation)
|
||||
form.addRow("Length", self.len_spin)
|
||||
form.addRow("Yaw", self.yaw_spin)
|
||||
form.addRow("Tilt", self.tilt_spin)
|
||||
bl.addLayout(form)
|
||||
root.addWidget(box)
|
||||
|
||||
self.c.changed.connect(self.refresh)
|
||||
self.refresh()
|
||||
|
||||
def _part_label(self, p) -> str:
|
||||
name = f" · {p.name}" if p.name else ""
|
||||
extra = " ⊕" if p.features else ""
|
||||
return f"{p.id} {p.stock} {p.length_in:g}\"{name}{extra}"
|
||||
|
||||
def _add_leaf(self, parent, pid, selected):
|
||||
p = next((q for q in self.c.scene.parts if q.id == pid), None)
|
||||
if not p:
|
||||
return
|
||||
item = QTreeWidgetItem(parent, [self._part_label(p)])
|
||||
item.setData(0, Qt.UserRole, pid)
|
||||
if pid in selected:
|
||||
item.setSelected(True)
|
||||
|
||||
# ----- refresh from scene ------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
self._loading = True
|
||||
self.tree.clear()
|
||||
selected = set(self.c.selected)
|
||||
by_id = {p.id: p for p in self.c.scene.parts}
|
||||
for group in self.c.groups():
|
||||
if len(group) > 1: # an assembly -> parent node
|
||||
names = [by_id[i].name or i for i in group if i in by_id]
|
||||
node = QTreeWidgetItem(self.tree, [f"⛓ Assembly: {' + '.join(names)}"])
|
||||
node.setData(0, Qt.UserRole, None)
|
||||
node.setExpanded(True)
|
||||
for pid in group:
|
||||
self._add_leaf(node, pid, selected)
|
||||
elif group:
|
||||
self._add_leaf(self.tree, group[0], selected)
|
||||
|
||||
part = self._selected_part()
|
||||
if part:
|
||||
ori = "vertical" if part.is_vertical else f"yaw {part.yaw_deg:g}°, tilt {part.tilt_deg:g}°"
|
||||
fin = f" · {', '.join(part.finishes)}" if part.finishes else ""
|
||||
self.detail.setText(f"<b>{part.id}</b>{' · ' + part.name if part.name else ''}<br>"
|
||||
f"{part.length_in:g}\" {part.stock} · {ori}{fin}")
|
||||
self.len_spin.setValue(part.length_in)
|
||||
self.yaw_spin.setValue(part.yaw_deg)
|
||||
self.tilt_spin.setValue(part.tilt_deg)
|
||||
else:
|
||||
self.detail.setText("nothing selected")
|
||||
self._loading = False
|
||||
|
||||
def _selected_part(self):
|
||||
pid = self.c.selected_id
|
||||
if not pid:
|
||||
return None
|
||||
return next((p for p in self.c.scene.parts if p.id == pid), None)
|
||||
|
||||
# ----- handlers ----------------------------------------------------
|
||||
def _selected_ids(self) -> list[str]:
|
||||
ids = []
|
||||
for it in self.tree.selectedItems():
|
||||
pid = it.data(0, Qt.UserRole)
|
||||
if pid: # a part leaf
|
||||
ids.append(pid)
|
||||
else: # an assembly node -> its members
|
||||
ids += [it.child(i).data(0, Qt.UserRole) for i in range(it.childCount())]
|
||||
return list(dict.fromkeys(ids))
|
||||
|
||||
def _stock_changed(self, stock: str) -> None:
|
||||
self.add_wid.setEnabled(is_plywood(stock)) # width only matters for plywood
|
||||
|
||||
def _add_board(self) -> None:
|
||||
stock = self.stock.currentText()
|
||||
width = self.add_wid.value() if is_plywood(stock) else None
|
||||
self.c.place(stock, self.add_len.value(), width)
|
||||
|
||||
def _on_row_selected(self) -> None:
|
||||
if self._loading:
|
||||
return
|
||||
self.c.set_selected(self._selected_ids())
|
||||
|
||||
def _context_menu(self, pos) -> None:
|
||||
item = self.tree.itemAt(pos)
|
||||
menu = QMenu(self)
|
||||
if self.c.scene.connections:
|
||||
menu.addAction("Back off connections", lambda: self.c.explode(3.0))
|
||||
menu.addAction("Re-fit connections", self.c.assemble)
|
||||
if item:
|
||||
pid = item.data(0, Qt.UserRole)
|
||||
if pid:
|
||||
menu.addAction("Break this board's connections",
|
||||
lambda: self.c.break_connections(pid))
|
||||
menu.addAction("Break all connections", lambda: self.c.break_connections())
|
||||
if menu.isEmpty():
|
||||
menu.addAction("No connections yet").setEnabled(False)
|
||||
menu.exec(self.tree.viewport().mapToGlobal(pos))
|
||||
|
||||
def _rename(self) -> None:
|
||||
part = self._selected_part()
|
||||
if not part:
|
||||
return
|
||||
name, ok = QInputDialog.getText(self, "Rename", "Name:", text=part.name)
|
||||
if ok and name.strip():
|
||||
self.c.rename(part.id, name.strip())
|
||||
|
||||
def _apply_length(self) -> None:
|
||||
part = self._selected_part()
|
||||
if part and not self._loading and abs(self.len_spin.value() - part.length_in) > 1e-6:
|
||||
self.c.set_length(part.id, self.len_spin.value())
|
||||
|
||||
def _apply_orientation(self) -> None:
|
||||
part = self._selected_part()
|
||||
if part and not self._loading and (
|
||||
abs(self.yaw_spin.value() - part.yaw_deg) > 1e-6
|
||||
or abs(self.tilt_spin.value() - part.tilt_deg) > 1e-6):
|
||||
self.c.rotate(part.id, yaw=self.yaw_spin.value(), tilt=self.tilt_spin.value())
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
"""Embedded 3D viewport (pyvistaqt). Renders the live Scene, highlights the
|
||||
selection, and emits a signal when a board is clicked."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (QApplication, QHBoxLayout, QPushButton,
|
||||
QVBoxLayout, QWidget)
|
||||
|
||||
from ..scene import Scene
|
||||
from ..viewer import (_PALETTE, _add_feature_edges, _part_mesh, _quiet_vtk,
|
||||
feature_preview_mesh)
|
||||
|
||||
|
||||
class Viewport(QWidget):
|
||||
picked = Signal(str, bool) # (part id, additive?) — additive when Ctrl held
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
from pyvistaqt import QtInteractor
|
||||
|
||||
_quiet_vtk()
|
||||
self.plotter = QtInteractor(self)
|
||||
self.plotter.set_background("#2b2b2b")
|
||||
self.plotter.enable_parallel_projection()
|
||||
self._actor_to_pid: dict = {}
|
||||
self._first = True
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self.plotter.interactor)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
for label, fn in [("Top", self.plotter.view_xy), ("Front", self.plotter.view_xz),
|
||||
("Side", self.plotter.view_yz), ("Iso", self.plotter.view_isometric),
|
||||
("Fit", self.plotter.reset_camera)]:
|
||||
b = QPushButton(label)
|
||||
b.clicked.connect(lambda _=False, f=fn: (f(), self.plotter.render()))
|
||||
bar.addWidget(b)
|
||||
bar.addStretch()
|
||||
layout.addLayout(bar)
|
||||
|
||||
self._enable_picking()
|
||||
|
||||
def _enable_picking(self) -> None:
|
||||
try: # left-click picking; degrade gracefully if the API differs
|
||||
self.plotter.enable_mesh_picking(
|
||||
callback=self._on_pick, use_actor=True, show=False,
|
||||
show_message=False, left_clicking=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_pick(self, actor) -> None:
|
||||
pid = self._actor_to_pid.get(actor)
|
||||
if pid:
|
||||
additive = bool(QApplication.keyboardModifiers() & Qt.ControlModifier)
|
||||
self.picked.emit(pid, additive)
|
||||
|
||||
def render_scene(self, scene: Scene, selected_ids=None) -> None:
|
||||
selected_ids = set(selected_ids or ([scene.selection] if scene.selection else []))
|
||||
cam = None if self._first else self.plotter.camera_position
|
||||
self.plotter.clear()
|
||||
self._actor_to_pid.clear()
|
||||
|
||||
labels, pts = [], []
|
||||
for i, part in enumerate(scene.parts):
|
||||
selected = part.id in selected_ids
|
||||
mesh = _part_mesh(part)
|
||||
actor = self.plotter.add_mesh(
|
||||
mesh,
|
||||
color="#f5d76e" if selected else _PALETTE[i % len(_PALETTE)],
|
||||
show_edges=not part.features, line_width=3 if selected else 1,
|
||||
edge_color="black", reset_camera=False, pickable=True,
|
||||
)
|
||||
self._actor_to_pid[actor] = part.id
|
||||
if part.features:
|
||||
_add_feature_edges(self.plotter, mesh, selected)
|
||||
mid = [part.position_in[j] + part.axis_unit()[j] * part.length_in / 2 for j in range(3)]
|
||||
labels.append(part.name or part.id)
|
||||
pts.append(mid)
|
||||
|
||||
if pts:
|
||||
self.plotter.add_point_labels(
|
||||
pts, labels, font_size=12, text_color="white",
|
||||
shape_color="#222222", shape_opacity=0.5, point_size=1,
|
||||
name="labels", always_visible=True, reset_camera=False,
|
||||
)
|
||||
self.plotter.show_grid(color="#555555", xtitle="X (in)", ytitle="Y (in)", ztitle="Z (in)")
|
||||
self.plotter.add_axes()
|
||||
|
||||
if self._first:
|
||||
self.plotter.view_isometric()
|
||||
self._first = False
|
||||
elif cam is not None:
|
||||
self.plotter.camera_position = cam # keep the user's viewpoint
|
||||
self.plotter.render()
|
||||
|
||||
def set_preview(self, preview, kind="edit") -> None:
|
||||
"""Draw (or clear) a feature overlay: red for a pending edit, cyan to
|
||||
highlight the selected feature."""
|
||||
for name in ("preview_face", "preview_edges"):
|
||||
try:
|
||||
self.plotter.remove_actor(name)
|
||||
except Exception:
|
||||
pass
|
||||
if preview is not None:
|
||||
part, feat = preview
|
||||
color = "#33ccff" if kind == "highlight" else "red"
|
||||
try:
|
||||
mesh = feature_preview_mesh(part, feat)
|
||||
self.plotter.add_mesh(mesh, color=color, opacity=0.4, pickable=False,
|
||||
reset_camera=False, name="preview_face")
|
||||
edges = mesh.extract_feature_edges()
|
||||
if edges.n_points:
|
||||
self.plotter.add_mesh(edges, color=color, line_width=3,
|
||||
pickable=False, reset_camera=False, name="preview_edges")
|
||||
except Exception:
|
||||
pass
|
||||
self.plotter.render()
|
||||
|
||||
def set_front(self): self.plotter.view_xz(); self.plotter.render()
|
||||
def set_iso(self): self.plotter.view_isometric(); self.plotter.render()
|
||||
def fit(self): self.plotter.reset_camera(); self.plotter.render()
|
||||
|
||||
def close_viewport(self) -> None:
|
||||
try:
|
||||
self.plotter.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
"""Run slow work (dictate, the LLM call, read-aloud) off the Qt event loop so
|
||||
the UI never freezes.
|
||||
|
||||
Lifetime note: a QRunnable is auto-deleted by the pool the moment run() returns,
|
||||
which can destroy its signals object before Qt delivers the queued result to the
|
||||
UI thread (the "done" callback then never fires). We disable auto-delete and
|
||||
keep a strong reference until the result is delivered, then drop it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||
|
||||
|
||||
class _Signals(QObject):
|
||||
done = Signal(object)
|
||||
error = Signal(str)
|
||||
|
||||
|
||||
class _Task(QRunnable):
|
||||
def __init__(self, fn):
|
||||
super().__init__()
|
||||
self.fn = fn
|
||||
self.signals = _Signals()
|
||||
self.setAutoDelete(False) # we manage lifetime (see module docstring)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.signals.done.emit(self.fn())
|
||||
except Exception as exc: # surface, don't crash the pool
|
||||
self.signals.error.emit(str(exc))
|
||||
|
||||
|
||||
_active: set[_Task] = set()
|
||||
|
||||
|
||||
def run_async(pool: QThreadPool, fn, on_done=None, on_error=None) -> None:
|
||||
task = _Task(fn)
|
||||
_active.add(task) # keep alive until a result is delivered on the UI thread
|
||||
|
||||
def finish_done(result):
|
||||
_active.discard(task)
|
||||
if on_done:
|
||||
on_done(result)
|
||||
|
||||
def finish_error(message):
|
||||
_active.discard(task)
|
||||
if on_error:
|
||||
on_error(message)
|
||||
|
||||
task.signals.done.connect(finish_done)
|
||||
task.signals.error.connect(finish_error)
|
||||
pool.start(task)
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"""Build instructions: a DETERMINISTIC ordered step list from the CutPlan + scene.
|
||||
|
||||
Every number/part name comes from the model. The AI is only used (optionally) to
|
||||
rephrase these steps into friendlier prose — `polish_prompt()` builds a prompt
|
||||
that explicitly forbids changing any measurement.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from .cutlist import _fmt_len
|
||||
from .cutplan import build_cut_plan
|
||||
|
||||
|
||||
def build_steps(scene, plan=None) -> list:
|
||||
"""Return [(title, [lines])] in build order. Deterministic."""
|
||||
plan = plan or build_cut_plan(scene)
|
||||
names = {p.id: (p.name or p.id) for p in scene.parts}
|
||||
part_of = {it.id: it.part_id for it in plan.items}
|
||||
sections = []
|
||||
|
||||
buy = []
|
||||
for stock, n in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()):
|
||||
unit = "sheet" if stock.startswith("ply-") else "8' stick"
|
||||
buy.append(f"{n} × {stock} ({unit}{'s' if n != 1 else ''})")
|
||||
if buy:
|
||||
sections.append(("Gather stock", buy))
|
||||
|
||||
cuts = []
|
||||
for sp in plan.stock_pieces:
|
||||
kind = "sheet" if sp.is_sheet else "stick"
|
||||
pieces = []
|
||||
for p in sp.placements:
|
||||
nm = names.get(part_of.get(p.item_id, ""), p.item_id)
|
||||
dims = (f"{_fmt_len(p.wid_in)}×{_fmt_len(p.len_in)}" if sp.is_sheet
|
||||
else _fmt_len(p.len_in))
|
||||
pieces.append(f"{nm} ({dims})")
|
||||
if pieces:
|
||||
cuts.append(f"From a {sp.stock} {kind}: cut " + ", ".join(pieces))
|
||||
if cuts:
|
||||
sections.append(("Cut pieces to size (see the Cut Layout tab)", cuts))
|
||||
|
||||
joinery = []
|
||||
for p in scene.parts:
|
||||
for f in p.features:
|
||||
dims = (f"⌀{f.diameter_in:g}\"" if f.kind == "hole"
|
||||
else f"{f.width_in:g}×{f.height_in:g}×{f.depth_in:g}\"")
|
||||
joinery.append(f"On {names[p.id]} — {f.kind} on the {f.face} face ({dims})")
|
||||
if joinery:
|
||||
sections.append(("Mark and cut the joinery", joinery))
|
||||
|
||||
sanded = [names[p.id] for p in scene.parts if "sanded" in p.finishes]
|
||||
sections.append(("Sand", [f"Sand {', '.join(sanded)} smooth." if sanded
|
||||
else "Sand all parts smooth."]))
|
||||
|
||||
asm = []
|
||||
for c in scene.connections:
|
||||
if not scene._conn_valid(c):
|
||||
continue
|
||||
ap, mp = scene.feature_owner(c.anchor), scene.feature_owner(c.moving)
|
||||
asm.append(f"Join {names[mp.id]} to {names[ap.id]} (seat the joint).")
|
||||
if asm:
|
||||
sections.append(("Dry-fit, then glue and fasten", asm))
|
||||
|
||||
sections.append(("Finish", ["Apply your chosen finish."]))
|
||||
return sections
|
||||
|
||||
|
||||
def format_steps(sections) -> str:
|
||||
out = []
|
||||
for n, (title, lines) in enumerate(sections, 1):
|
||||
out.append(f"{n}. {title}")
|
||||
out += [f" • {ln}" for ln in lines]
|
||||
out.append("")
|
||||
return "\n".join(out).rstrip() or "Nothing to build yet."
|
||||
|
||||
|
||||
def polish_prompt(sections) -> str:
|
||||
return ("Rewrite these woodworking build steps as clear, friendly, numbered shop "
|
||||
"instructions a beginner could follow. KEEP every measurement, part name, "
|
||||
"and count EXACTLY as given — do not invent or change any number. Plain text only.\n\n"
|
||||
+ format_steps(sections))
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
"""Jig suggestions: detect REPEATED operations (rule-based, deterministic) and
|
||||
propose shop aids with computed dimensions. The AI only explains how to build/use
|
||||
them — it never sets a dimension.
|
||||
|
||||
Jigs are *shop aids*, kept separate from the project BOM (don't silently add jig
|
||||
material to what the user buys).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .cutlist import _fmt_len, cut_length
|
||||
from .lumber import is_plywood
|
||||
|
||||
|
||||
@dataclass
|
||||
class JigSuggestion:
|
||||
kind: str
|
||||
title: str
|
||||
count: int # how many repeated operations it serves
|
||||
detail: str # deterministic specifics (dimensions, what to set)
|
||||
material: list = field(default_factory=list) # optional shop-aid stock
|
||||
|
||||
|
||||
def suggest_jigs(scene, min_repeats: int = 3) -> list:
|
||||
jigs = []
|
||||
|
||||
# Repeated identical crosscuts -> stop block.
|
||||
lengths = Counter((p.stock, round(cut_length(p), 2))
|
||||
for p in scene.parts if not is_plywood(p.stock))
|
||||
for (stock, ln), n in sorted(lengths.items(), key=lambda kv: -kv[1]):
|
||||
if n >= min_repeats:
|
||||
jigs.append(JigSuggestion(
|
||||
"stop-block", f"Stop block — {n}× {stock} @ {_fmt_len(ln)}", n,
|
||||
f"Clamp a stop block {_fmt_len(ln)} from the blade (or marking edge) and cut all "
|
||||
f"{n} pieces against it for identical length every time.",
|
||||
[f"a ~3\" {stock} offcut (the stop)", "a straight fence/backer board"]))
|
||||
|
||||
# Repeated holes at the SAME registered position -> drilling template.
|
||||
# (Grouping by position, not just diameter — a fixed template only locates
|
||||
# holes that share a face + offsets.)
|
||||
holes = Counter((p.stock, f.face, round(f.along_in, 2), round(f.across_in, 2), round(f.diameter_in, 3))
|
||||
for p in scene.parts for f in p.features if f.kind == "hole")
|
||||
for (stock, face, along, across, dia), n in sorted(holes.items()):
|
||||
if n >= min_repeats:
|
||||
jigs.append(JigSuggestion(
|
||||
"drill-template", f"Drilling template — {n}× ⌀{dia:g}\" holes ({stock}, {face})", n,
|
||||
f"Make a template with a ⌀{dia:g}\" guide hole and clamp it to register the hole "
|
||||
f"at the same spot ({_fmt_len(along)} along, {across:g}\" off centre) on all {n} parts.",
|
||||
["a scrap of ply/hardboard for the template"]))
|
||||
|
||||
# Repeated mortises at the same position/size -> positioning template.
|
||||
mort = Counter((p.stock, f.face, round(f.along_in, 2), round(f.across_in, 2),
|
||||
round(f.width_in, 2), round(f.height_in, 2), round(f.depth_in, 2), round(f.rotation_deg, 1))
|
||||
for p in scene.parts for f in p.features if f.kind == "mortise")
|
||||
for (stock, face, along, across, w, h, d, _rot), n in sorted(mort.items()):
|
||||
if n >= min_repeats:
|
||||
jigs.append(JigSuggestion(
|
||||
"mortise-template", f"Mortise template — {n}× {w:g}×{h:g}\" ({stock}, {face})", n,
|
||||
f"Build a routing template with a {w:g}×{h:g}\" opening; register it at the same spot "
|
||||
f"({_fmt_len(along)} along) and rout all {n} mortises {_fmt_len(d)} deep with a guide bushing.",
|
||||
["template stock (ply/MDF)", "guide bushing"]))
|
||||
|
||||
# Repeated panel widths -> set the rip fence once.
|
||||
widths = Counter(round(p.section_in[1], 2) for p in scene.parts if is_plywood(p.stock))
|
||||
for wd, n in sorted(widths.items()):
|
||||
if n >= min_repeats:
|
||||
jigs.append(JigSuggestion(
|
||||
"rip-stop", f"Rip-fence setting — {n}× {_fmt_len(wd)}-wide panels", n,
|
||||
f"Set the rip fence to {_fmt_len(wd)} once and rip all {n} panels without re-measuring."))
|
||||
|
||||
return jigs
|
||||
|
||||
|
||||
def format_jigs(jigs) -> str:
|
||||
if not jigs:
|
||||
return "No repeated operations detected yet — no jigs suggested."
|
||||
out = ["SHOP AIDS / JIGS (optional — not part of the project BOM)", ""]
|
||||
for j in jigs:
|
||||
out.append(f"• {j.title} (saves repeating {j.count}×)")
|
||||
out.append(f" {j.detail}")
|
||||
if j.material:
|
||||
out.append(" Build from: " + ", ".join(j.material))
|
||||
out.append("")
|
||||
return "\n".join(out).rstrip()
|
||||
|
||||
|
||||
def explain_prompt(jigs) -> str:
|
||||
listing = "\n".join(f"- {j.title}: {j.detail}" for j in jigs)
|
||||
return ("Explain, in friendly beginner terms, how to build and use each of these "
|
||||
"woodworking jigs. KEEP every dimension exactly as given; do not invent numbers. "
|
||||
"Plain text.\n\n" + listing)
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
"""Backwards-compatible nesting helpers — thin wrappers over cutplan.build_cut_plan.
|
||||
|
||||
The real packing now lives in cutplan.py (the CutPlan artifact). These keep the
|
||||
older dict-shaped APIs working for existing callers/tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from .cutplan import build_cut_plan
|
||||
|
||||
STICK_LEN = 96.0
|
||||
KERF = 0.125
|
||||
|
||||
|
||||
def nest_lumber(scene, order="decreasing", **_) -> dict:
|
||||
plan = build_cut_plan(scene, strategy=order)
|
||||
out: dict[str, list] = {}
|
||||
for sp in plan.stock_pieces:
|
||||
if sp.is_sheet:
|
||||
continue
|
||||
pieces = [(plan.item(p.item_id).part_id, p.len_in) for p in sp.placements]
|
||||
used = sum(p.len_in for p in sp.placements) + plan.settings.kerf_in * max(len(pieces) - 1, 0)
|
||||
offcut = sp.waste[0].length_in if sp.waste else round(sp.length_in - used, 3)
|
||||
out.setdefault(sp.stock, []).append(
|
||||
{"pieces": pieces, "used": round(used, 3), "offcut": round(offcut, 3)})
|
||||
return out
|
||||
|
||||
|
||||
def nest_plywood(scene, order="decreasing", **_) -> dict:
|
||||
plan = build_cut_plan(scene, strategy=order)
|
||||
out: dict[str, list] = {}
|
||||
for sp in plan.stock_pieces:
|
||||
if not sp.is_sheet:
|
||||
continue
|
||||
placements = [(plan.item(p.item_id).part_id, p.x_in, p.y_in, p.len_in, p.wid_in)
|
||||
for p in sp.placements]
|
||||
out.setdefault(sp.stock, []).append(
|
||||
{"placements": placements, "sheet": (sp.width_in, sp.length_in)})
|
||||
return out
|
||||
|
||||
|
||||
def stock_counts(scene, **_) -> dict:
|
||||
return dict(Counter(sp.stock for sp in build_cut_plan(scene).stock_pieces))
|
||||
|
||||
|
||||
def waste_summary(scene, **_) -> dict:
|
||||
plan = build_cut_plan(scene)
|
||||
out: dict[str, dict] = {}
|
||||
for sp in plan.stock_pieces:
|
||||
d = out.setdefault(sp.stock, {"bought": 0, "used": 0.0, "capacity": 0.0})
|
||||
d["bought"] += 1
|
||||
if sp.is_sheet:
|
||||
d["used"] += sum(p.len_in * p.wid_in for p in sp.placements) / 144
|
||||
d["capacity"] += sp.length_in * sp.width_in / 144
|
||||
else:
|
||||
d["used"] += sum(p.len_in for p in sp.placements)
|
||||
d["capacity"] += sp.length_in
|
||||
for d in out.values():
|
||||
d["used"] = round(d["used"], 1)
|
||||
d["capacity"] = round(d["capacity"], 1)
|
||||
return out
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
"""Nominal -> actual dimensional lumber sizing.
|
||||
|
||||
A "2x4" is nominally 2"x4" but actually 1.5"x3.5" once surfaced. Getting this
|
||||
right is what makes the models buildable rather than decorative, so the table
|
||||
lives in one place and is shared by both the operations and the viewport.
|
||||
|
||||
All values are in inches. Section is (thickness, width) of the board's
|
||||
cross-section; length is supplied per-part by the user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# (thickness, width) in inches for surfaced softwood dimensional lumber.
|
||||
NOMINAL_TO_ACTUAL: dict[str, tuple[float, float]] = {
|
||||
"1x2": (0.75, 1.5),
|
||||
"1x3": (0.75, 2.5),
|
||||
"1x4": (0.75, 3.5),
|
||||
"1x6": (0.75, 5.5),
|
||||
"1x8": (0.75, 7.25),
|
||||
"1x10": (0.75, 9.25),
|
||||
"1x12": (0.75, 11.25),
|
||||
"2x2": (1.5, 1.5),
|
||||
"2x3": (1.5, 2.5),
|
||||
"2x4": (1.5, 3.5),
|
||||
"2x6": (1.5, 5.5),
|
||||
"2x8": (1.5, 7.25),
|
||||
"2x10": (1.5, 9.25),
|
||||
"2x12": (1.5, 11.25),
|
||||
"4x4": (3.5, 3.5),
|
||||
"4x6": (3.5, 5.5),
|
||||
"6x6": (5.5, 5.5),
|
||||
}
|
||||
|
||||
|
||||
# Plywood is sheet stock: a fixed thickness, cut to any width × length. Canonical
|
||||
# stock name is "ply-<fraction>"; standard sheet is 4' × 8'.
|
||||
PLYWOOD_FRACTIONS = ("1/8", "1/4", "3/8", "1/2", "5/8", "3/4")
|
||||
SHEET_WIDTH_IN, SHEET_LENGTH_IN = 48.0, 96.0
|
||||
|
||||
|
||||
def normalize_stock(stock: str) -> str:
|
||||
"""Canonicalize a stock name: '2 x 4' -> '2x4'; '3/4 plywood' -> 'ply-3/4'."""
|
||||
s = stock.strip().lower()
|
||||
if "ply" in s:
|
||||
for frac in PLYWOOD_FRACTIONS:
|
||||
if frac in s:
|
||||
return f"ply-{frac}"
|
||||
return "ply-3/4" # bare "plywood" defaults to 3/4"
|
||||
return s.replace(" ", "").replace("by", "x")
|
||||
|
||||
|
||||
def is_plywood(stock: str) -> bool:
|
||||
return normalize_stock(stock).startswith("ply-")
|
||||
|
||||
|
||||
def plywood_thickness(stock: str) -> float:
|
||||
num, den = normalize_stock(stock).split("-", 1)[1].split("/")
|
||||
return float(num) / float(den)
|
||||
|
||||
|
||||
def actual_section(stock: str) -> tuple[float, float]:
|
||||
"""Return the (thickness, width) in inches for a nominal lumber stock name.
|
||||
|
||||
Raises KeyError with the list of known stock if unknown. (Plywood is handled
|
||||
separately — its width is per-panel, not fixed by the stock.)
|
||||
"""
|
||||
key = normalize_stock(stock)
|
||||
if key not in NOMINAL_TO_ACTUAL:
|
||||
known = ", ".join(sorted(NOMINAL_TO_ACTUAL)) + ", " + \
|
||||
", ".join(f"ply-{f}" for f in PLYWOOD_FRACTIONS)
|
||||
raise KeyError(f"Unknown stock {stock!r}. Known stock: {known}")
|
||||
return NOMINAL_TO_ACTUAL[key]
|
||||
|
|
@ -1,806 +0,0 @@
|
|||
"""The WoodShop scene: the single source of truth for a model.
|
||||
|
||||
A scene is a list of *parts* (boards) and *joints* (how they attach), plus a
|
||||
*selection* (the last-touched part, so commands like "sand it" resolve) and an
|
||||
*undo stack*. It is persisted as plain JSON so that:
|
||||
|
||||
* stateless CmdForge operation tools can read -> mutate -> write it, and
|
||||
* the long-lived viewport process can watch the file and re-render.
|
||||
|
||||
Geometry convention (all inches): a part is a box of length x width x thickness.
|
||||
Unplaced, it runs along +X starting at ``position``; ``width`` along Y,
|
||||
``thickness`` along Z. ``rotation_deg`` is a rotation about the Z axis applied
|
||||
about ``position``. Joints compute the attached part's position/rotation so the
|
||||
viewport stays a dumb renderer (no constraint solver needed for the PoC).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field, fields, asdict
|
||||
from pathlib import Path
|
||||
|
||||
from .lumber import actual_section, is_plywood, normalize_stock, plywood_thickness
|
||||
|
||||
SCENE_VERSION = 1
|
||||
|
||||
|
||||
# --- small 3x3 rotation helpers (degrees) for board orientation -------------
|
||||
def _matmul(a, b):
|
||||
return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
|
||||
|
||||
|
||||
def _dot(u, v):
|
||||
return u[0] * v[0] + u[1] * v[1] + u[2] * v[2]
|
||||
|
||||
|
||||
def _transpose(M):
|
||||
return [[M[j][i] for j in range(3)] for i in range(3)]
|
||||
|
||||
|
||||
def _mv(M, x): # 3x3 matrix · 3-vector
|
||||
return tuple(sum(M[i][j] * x[j] for j in range(3)) for i in range(3))
|
||||
|
||||
|
||||
def _rot_x(deg):
|
||||
c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
|
||||
return [[1, 0, 0], [0, c, -s], [0, s, c]]
|
||||
|
||||
|
||||
def _rot_y(deg):
|
||||
c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
|
||||
return [[c, 0, s], [0, 1, 0], [-s, 0, c]]
|
||||
|
||||
|
||||
def _rot_z(deg):
|
||||
c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
|
||||
return [[c, -s, 0], [s, c, 0], [0, 0, 1]]
|
||||
|
||||
|
||||
def face_frame(face, L, w, t):
|
||||
"""Local-frame (origin, outward normal, in-plane u, in-plane v) of a board
|
||||
face. X in [0,L], Y in [-w/2,w/2], Z in [-t/2,t/2]."""
|
||||
return {
|
||||
"top": ((L / 2, 0, t / 2), (0, 0, 1), (1, 0, 0), (0, 1, 0)),
|
||||
"bottom": ((L / 2, 0, -t / 2), (0, 0, -1), (1, 0, 0), (0, 1, 0)),
|
||||
"right": ((L / 2, w / 2, 0), (0, 1, 0), (1, 0, 0), (0, 0, 1)),
|
||||
"left": ((L / 2, -w / 2, 0), (0, -1, 0), (1, 0, 0), (0, 0, 1)),
|
||||
"end_b": ((L, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)),
|
||||
"end_a": ((0, 0, 0), (-1, 0, 0), (0, 1, 0), (0, 0, 1)),
|
||||
}[face]
|
||||
|
||||
|
||||
def _rodrigues(x, n, deg):
|
||||
"""Rotate vector x about unit axis n by deg (Rodrigues' formula)."""
|
||||
a = math.radians(deg)
|
||||
c, s = math.cos(a), math.sin(a)
|
||||
cross = (n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0])
|
||||
d = n[0] * x[0] + n[1] * x[1] + n[2] * x[2]
|
||||
return tuple(x[i] * c + cross[i] * s + n[i] * d * (1 - c) for i in range(3))
|
||||
|
||||
|
||||
def matrix_to_ypr(R):
|
||||
"""Decompose a 3x3 rotation into (yaw, tilt, roll) for our convention
|
||||
R = Rz(yaw)·Ry(-tilt)·Rx(roll)."""
|
||||
sy = math.hypot(R[0][0], R[1][0])
|
||||
if sy > 1e-6:
|
||||
yaw = math.degrees(math.atan2(R[1][0], R[0][0]))
|
||||
d = math.degrees(math.atan2(-R[2][0], sy))
|
||||
roll = math.degrees(math.atan2(R[2][1], R[2][2]))
|
||||
else: # gimbal lock (pointing straight up/down)
|
||||
yaw = math.degrees(math.atan2(-R[1][2], R[1][1]))
|
||||
d = math.degrees(math.atan2(-R[2][0], sy))
|
||||
roll = 0.0
|
||||
return yaw, -d, roll
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
return Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "woodshop"
|
||||
|
||||
|
||||
def default_scene_path() -> Path:
|
||||
"""Where the active scene lives (override with $WOODSHOP_SCENE)."""
|
||||
env = os.environ.get("WOODSHOP_SCENE")
|
||||
if env:
|
||||
return Path(env).expanduser()
|
||||
return _data_dir() / "scene.json"
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
return "-".join("".join(c if c.isalnum() else " " for c in name).split()).lower()
|
||||
|
||||
|
||||
def projects_dir() -> Path:
|
||||
return _data_dir() / "projects"
|
||||
|
||||
|
||||
def project_path(name: str) -> Path:
|
||||
slug = slugify(name)
|
||||
if not slug:
|
||||
raise ValueError("Please give the project a name.")
|
||||
return projects_dir() / f"{slug}.json"
|
||||
|
||||
|
||||
def list_projects() -> list[str]:
|
||||
d = projects_dir()
|
||||
return sorted(p.stem for p in d.glob("*.json")) if d.exists() else []
|
||||
|
||||
|
||||
# Feature kinds: ADD fuses material (tenon), CUT subtracts a box/cylinder, EDGE
|
||||
# operates on the board's edges (chamfer bevel).
|
||||
ADD_KINDS = {"tenon"}
|
||||
CUT_KINDS = {"mortise", "slot", "hole", "dado", "rabbet"}
|
||||
EDGE_KINDS = {"chamfer"}
|
||||
FEATURE_KINDS = ADD_KINDS | CUT_KINDS | EDGE_KINDS
|
||||
FACES = ("end_a", "end_b", "top", "bottom", "left", "right")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Feature:
|
||||
"""A parametric joinery feature attached to a board — a boolean op (add for a
|
||||
tenon, cut for mortise/slot/hole) on a chosen face, re-editable and movable.
|
||||
|
||||
Placement on the face uses two offsets: for face=top/bottom/left/right,
|
||||
``along_in`` is the position along the board length (from end_a) and
|
||||
``across_in`` is offset from the face centre; for face=end_a/end_b the two
|
||||
offsets are the lateral (width) and vertical (thickness) positions from
|
||||
centre. ``width_in`` × ``height_in`` is the feature's cross-section on the
|
||||
face and ``depth_in`` is how deep it cuts (or how far a tenon protrudes).
|
||||
``diameter_in`` is used for round holes instead of width/height.
|
||||
"""
|
||||
id: str
|
||||
kind: str
|
||||
face: str = "end_b"
|
||||
along_in: float = 0.0
|
||||
across_in: float = 0.0
|
||||
width_in: float = 1.0
|
||||
height_in: float = 1.0
|
||||
depth_in: float = 1.0
|
||||
diameter_in: float = 0.375
|
||||
rotation_deg: float = 0.0 # rotation of the feature about its face normal
|
||||
|
||||
@property
|
||||
def is_cut(self) -> bool:
|
||||
return self.kind in CUT_KINDS
|
||||
|
||||
|
||||
def _aabb_overlap(b1, b2, eps=0.05) -> bool:
|
||||
(lo1, hi1), (lo2, hi2) = b1, b2
|
||||
return all(min(hi1[i], hi2[i]) - max(lo1[i], lo2[i]) > eps for i in range(3))
|
||||
|
||||
|
||||
def spatial_summary(scene) -> str:
|
||||
"""A compact text description of where each board sits (bounding boxes) and
|
||||
which boards interpenetrate — fed to the AI so it can reason spatially."""
|
||||
if not scene.parts:
|
||||
return "empty"
|
||||
boxes = {p.id: p.bbox() for p in scene.parts}
|
||||
lines = ["Layout (inch bounding boxes, x=length-ish, z=up):"]
|
||||
for p in scene.parts:
|
||||
lo, hi = boxes[p.id]
|
||||
label = f"{p.id}" + (f" ({p.name})" if p.name else "")
|
||||
lines.append(f" {label}: x[{lo[0]:.1f},{hi[0]:.1f}] "
|
||||
f"y[{lo[1]:.1f},{hi[1]:.1f}] z[{lo[2]:.1f},{hi[2]:.1f}]")
|
||||
ids = list(boxes)
|
||||
overlaps = [f"{ids[i]}&{ids[j]}" for i in range(len(ids)) for j in range(i + 1, len(ids))
|
||||
if _aabb_overlap(boxes[ids[i]], boxes[ids[j]])]
|
||||
if overlaps:
|
||||
lines.append("Interpenetrating (usually unintended — boards should touch, not overlap): "
|
||||
+ ", ".join(overlaps))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Part:
|
||||
id: str
|
||||
stock: str # canonical nominal name, e.g. "2x4"
|
||||
length_in: float
|
||||
section_in: tuple[float, float] # (thickness, width)
|
||||
position_in: list[float] = field(default_factory=lambda: [0.0, 0.0, 0.0])
|
||||
yaw_deg: float = 0.0 # heading about Z, in the XY plane
|
||||
tilt_deg: float = 0.0 # elevation from horizontal toward +Z (90 = standing up)
|
||||
roll_deg: float = 0.0 # rotation about the board's own length axis
|
||||
name: str = "" # optional human alias, e.g. "front-left leg"
|
||||
finishes: list[str] = field(default_factory=list)
|
||||
features: list[Feature] = field(default_factory=list)
|
||||
|
||||
def local_frame(self) -> tuple[tuple, tuple, tuple]:
|
||||
"""The board's (length, width, thickness) unit axes in world space.
|
||||
|
||||
Built by composing the same rotation geometry.py / viewer.py apply:
|
||||
R = Rz(yaw) · Ry(-tilt) · Rx(roll), then taking R's columns (the images
|
||||
of the local X=length, Y=width, Z=thickness axes).
|
||||
"""
|
||||
R = _matmul(_rot_z(self.yaw_deg),
|
||||
_matmul(_rot_y(-self.tilt_deg), _rot_x(self.roll_deg)))
|
||||
length = (R[0][0], R[1][0], R[2][0])
|
||||
width = (R[0][1], R[1][1], R[2][1])
|
||||
thick = (R[0][2], R[1][2], R[2][2])
|
||||
return length, width, thick
|
||||
|
||||
def axis_unit(self) -> tuple[float, float, float]:
|
||||
"""Unit vector along the board's length, from end_a toward end_b."""
|
||||
return self.local_frame()[0]
|
||||
|
||||
def rotation_matrix(self) -> list[list[float]]:
|
||||
"""3x3 world rotation (columns = length/width/thickness world axes)."""
|
||||
cl, cw, ct = self.local_frame()
|
||||
return [[cl[i], cw[i], ct[i]] for i in range(3)]
|
||||
|
||||
def bbox(self) -> tuple:
|
||||
"""World axis-aligned bounding box (min_xyz, max_xyz) of the board."""
|
||||
t, w = self.section_in
|
||||
L = self.length_in
|
||||
R = self.rotation_matrix()
|
||||
corners = []
|
||||
for x in (0.0, L):
|
||||
for y in (-w / 2, w / 2):
|
||||
for z in (-t / 2, t / 2):
|
||||
loc = (x, y, z)
|
||||
corners.append(tuple(self.position_in[i] + sum(R[i][j] * loc[j] for j in range(3))
|
||||
for i in range(3)))
|
||||
lo = tuple(min(c[i] for c in corners) for i in range(3))
|
||||
hi = tuple(max(c[i] for c in corners) for i in range(3))
|
||||
return lo, hi
|
||||
|
||||
def feature_world_frame(self, feat) -> tuple:
|
||||
"""(contact point, outward normal, u, v) of a feature, in world space,
|
||||
with the feature's own rotation about its normal applied to u/v."""
|
||||
R = self.rotation_matrix()
|
||||
t, w = self.section_in
|
||||
o, n, u, v = face_frame(feat.face, self.length_in, w, t)
|
||||
off_u = feat.along_in - (self.length_in / 2 if u == (1, 0, 0) else 0.0)
|
||||
fp = tuple(o[i] + off_u * u[i] + feat.across_in * v[i] for i in range(3))
|
||||
ur, vr = _rodrigues(u, n, feat.rotation_deg), _rodrigues(v, n, feat.rotation_deg)
|
||||
|
||||
def to_world(x):
|
||||
return tuple(sum(R[i][j] * x[j] for j in range(3)) for i in range(3))
|
||||
|
||||
point = tuple(self.position_in[i] + to_world(fp)[i] for i in range(3))
|
||||
return point, to_world(n), to_world(ur), to_world(vr)
|
||||
|
||||
@property
|
||||
def is_vertical(self) -> bool:
|
||||
return abs(self.tilt_deg) > 45
|
||||
|
||||
def end_point(self) -> list[float]:
|
||||
"""The far end (end_b) of the board in world space."""
|
||||
ux, uy, uz = self.axis_unit()
|
||||
return [
|
||||
self.position_in[0] + ux * self.length_in,
|
||||
self.position_in[1] + uy * self.length_in,
|
||||
self.position_in[2] + uz * self.length_in,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Joint:
|
||||
id: str
|
||||
part_a: str
|
||||
part_b: str
|
||||
angle_deg: float = 90.0
|
||||
offset_in: float = 0.0
|
||||
anchor: str = "end_a" # measure offset from "end_a" (start) or "end_b" (far end)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Connection:
|
||||
"""A recorded mate between two features (anchor stays, moving board seats into
|
||||
it). Tracking it lets us group, explode (back off), break, and re-fit."""
|
||||
id: str
|
||||
anchor: str # anchor feature id
|
||||
moving: str # moving feature id (its board was repositioned)
|
||||
backed_off_in: float = 0.0 # current explode offset along the joint axis
|
||||
|
||||
|
||||
class SceneError(Exception):
|
||||
"""Raised for invalid operations (bad references, unknown stock, ...)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scene:
|
||||
version: int = SCENE_VERSION
|
||||
units: str = "inch"
|
||||
parts: list[Part] = field(default_factory=list)
|
||||
joints: list[Joint] = field(default_factory=list)
|
||||
connections: list[Connection] = field(default_factory=list)
|
||||
selection: str | None = None
|
||||
_next_part: int = 1
|
||||
_next_joint: int = 1
|
||||
_next_feat: int = 1
|
||||
_next_conn: int = 1
|
||||
_undo: list[str] = field(default_factory=list, repr=False)
|
||||
_redo: list[str] = field(default_factory=list, repr=False)
|
||||
|
||||
# ----- lookup -------------------------------------------------------
|
||||
def get_part(self, ref: str) -> Part:
|
||||
for p in self.parts:
|
||||
if p.id == ref or (p.name and p.name.lower() == ref.lower()):
|
||||
return p
|
||||
labels = [p.id + (f" ({p.name})" if p.name else "") for p in self.parts]
|
||||
raise SceneError(f"No part {ref!r}. Parts: {labels or 'none'}")
|
||||
|
||||
def resolve(self, ref: str | None) -> Part:
|
||||
"""Resolve a part reference (id, name, or 'it'), defaulting to selection."""
|
||||
if ref in (None, "", "it", "selection", "current", "that", "this"):
|
||||
if not self.selection:
|
||||
raise SceneError("No part is selected; say which board.")
|
||||
return self.get_part(self.selection)
|
||||
return self.get_part(ref)
|
||||
|
||||
# ----- undo / redo --------------------------------------------------
|
||||
def _checkpoint(self) -> None:
|
||||
if getattr(self, "_suppress", False): # inside a batch — one undo covers it
|
||||
return
|
||||
self._undo.append(json.dumps(self._raw(), sort_keys=True))
|
||||
del self._undo[:-50] # keep the last 50 steps
|
||||
self._redo.clear() # a new action invalidates the redo history
|
||||
|
||||
@contextmanager
|
||||
def batch(self):
|
||||
"""Group several operations into a single undo step (e.g. moving a
|
||||
multi-selection)."""
|
||||
self._checkpoint()
|
||||
self._suppress = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._suppress = False
|
||||
|
||||
def _restore(self, snapshot: dict) -> None:
|
||||
restored = Scene.from_dict(snapshot)
|
||||
restored._undo = self._undo
|
||||
restored._redo = self._redo
|
||||
self.__dict__.update(restored.__dict__)
|
||||
|
||||
def undo(self) -> str:
|
||||
if not self._undo:
|
||||
raise SceneError("Nothing to undo.")
|
||||
self._redo.append(json.dumps(self._raw(), sort_keys=True))
|
||||
self._restore(json.loads(self._undo.pop()))
|
||||
return "Undid last operation."
|
||||
|
||||
def redo(self) -> str:
|
||||
if not self._redo:
|
||||
raise SceneError("Nothing to redo.")
|
||||
self._undo.append(json.dumps(self._raw(), sort_keys=True))
|
||||
self._restore(json.loads(self._redo.pop()))
|
||||
return "Redid last operation."
|
||||
|
||||
def select(self, ref: str) -> Part:
|
||||
"""Set the current selection (by id or name). Not undoable."""
|
||||
part = self.get_part(ref)
|
||||
self.selection = part.id
|
||||
return part
|
||||
|
||||
# ----- operations ---------------------------------------------------
|
||||
def place(self, stock: str, length_in: float, width_in: float | None = None) -> Part:
|
||||
self._checkpoint()
|
||||
stock = normalize_stock(stock)
|
||||
if is_plywood(stock):
|
||||
if not width_in:
|
||||
raise SceneError("Plywood is sheet stock — give it a width too "
|
||||
"(e.g. a 24 inch wide panel).")
|
||||
section = (plywood_thickness(stock), float(width_in))
|
||||
else:
|
||||
section = actual_section(stock)
|
||||
pid = f"p{self._next_part}"
|
||||
self._next_part += 1
|
||||
part = Part(id=pid, stock=stock, length_in=float(length_in), section_in=section)
|
||||
self.parts.append(part)
|
||||
self.selection = pid
|
||||
return part
|
||||
|
||||
def finish(self, ref: str | None, kind: str = "sanded") -> Part:
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
if kind not in part.finishes:
|
||||
part.finishes.append(kind)
|
||||
self.selection = part.id
|
||||
return part
|
||||
|
||||
def join(
|
||||
self,
|
||||
part_a: str | None,
|
||||
part_b: str,
|
||||
angle_deg: float = 90.0,
|
||||
offset_in: float = 0.0,
|
||||
anchor: str = "end_a",
|
||||
) -> Joint:
|
||||
self._checkpoint()
|
||||
a = self.resolve(part_a)
|
||||
b = self.get_part(part_b)
|
||||
|
||||
# Attach point: distance measured along A's axis from the chosen end.
|
||||
along = offset_in if anchor == "end_a" else max(a.length_in - offset_in, 0.0)
|
||||
a_len, a_width, a_thick = a.local_frame()
|
||||
anchor_pt = [a.position_in[i] + a_len[i] * along for i in range(3)]
|
||||
|
||||
# B inherits A's heading plus the requested angle, but keeps its own
|
||||
# tilt/roll (so a board you stood up stays standing when attached).
|
||||
b.yaw_deg = a.yaw_deg + angle_deg
|
||||
b_len = b.axis_unit() # direction B extends, away from the joint
|
||||
|
||||
# Real butt joint: B's end sits flush on A's SURFACE, not on A's
|
||||
# centerline. Push out from the centerline to A's face along B's
|
||||
# direction, by A's cross-section half-extent in that direction
|
||||
# (width/thickness only — B butts the side, not the end).
|
||||
a_half_w, a_half_t = a.section_in[1] / 2, a.section_in[0] / 2
|
||||
surface = (a_half_w * abs(_dot(b_len, a_width))
|
||||
+ a_half_t * abs(_dot(b_len, a_thick)))
|
||||
b.position_in = [anchor_pt[i] + b_len[i] * surface for i in range(3)]
|
||||
|
||||
# Flush-by-default: align B to A's reference corner — its faces line up
|
||||
# with A's top and one side, rather than B floating centered on A. For
|
||||
# each of A's cross-section axes (skipping the one B extends along, so
|
||||
# the butt contact is preserved) snap B's +face onto A's +face.
|
||||
b_l, b_w, b_t = b.local_frame()
|
||||
|
||||
def b_half_extent(axis):
|
||||
return (b.length_in / 2 * abs(_dot(b_l, axis))
|
||||
+ b.section_in[1] / 2 * abs(_dot(b_w, axis))
|
||||
+ b.section_in[0] / 2 * abs(_dot(b_t, axis)))
|
||||
|
||||
for axis, a_half in ((a_thick, a_half_t), (a_width, a_half_w)):
|
||||
if abs(_dot(b_len, axis)) > 0.9: # B runs along this axis — leave it
|
||||
continue
|
||||
a_face = _dot(a.position_in, axis) + a_half # A's far face
|
||||
b_face = _dot(b.position_in, axis) + b_half_extent(axis)
|
||||
delta = a_face - b_face
|
||||
b.position_in = [b.position_in[i] + delta * axis[i] for i in range(3)]
|
||||
|
||||
jid = f"j{self._next_joint}"
|
||||
self._next_joint += 1
|
||||
joint = Joint(id=jid, part_a=a.id, part_b=b.id,
|
||||
angle_deg=float(angle_deg), offset_in=float(offset_in), anchor=anchor)
|
||||
self.joints.append(joint)
|
||||
self.selection = b.id
|
||||
return joint
|
||||
|
||||
def stand(self, ref: str | None, tilt_deg: float = 90.0) -> Part:
|
||||
"""Tilt a board up toward vertical (90 = standing straight up)."""
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
part.tilt_deg = float(tilt_deg)
|
||||
self.selection = part.id
|
||||
return part
|
||||
|
||||
def orient(self, ref: str | None, yaw: float | None = None,
|
||||
tilt: float | None = None, roll: float | None = None) -> Part:
|
||||
"""Set any of the board's orientation angles (degrees)."""
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
if yaw is not None:
|
||||
part.yaw_deg = float(yaw)
|
||||
if tilt is not None:
|
||||
part.tilt_deg = float(tilt)
|
||||
if roll is not None:
|
||||
part.roll_deg = float(roll)
|
||||
self.selection = part.id
|
||||
return part
|
||||
|
||||
def move(self, ref: str | None, dx: float = 0.0, dy: float = 0.0, dz: float = 0.0,
|
||||
absolute: bool = False) -> Part:
|
||||
"""Translate a board by (dx, dy, dz), or set its position if absolute."""
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
if absolute:
|
||||
part.position_in = [float(dx), float(dy), float(dz)]
|
||||
else:
|
||||
part.position_in = [part.position_in[0] + dx,
|
||||
part.position_in[1] + dy,
|
||||
part.position_in[2] + dz]
|
||||
self.selection = part.id
|
||||
return part
|
||||
|
||||
def set_length(self, ref: str | None, length_in: float) -> Part:
|
||||
"""Cut a board to a new length."""
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
part.length_in = float(length_in)
|
||||
self.selection = part.id
|
||||
return part
|
||||
|
||||
def copy(self, ref: str | None, dx: float = 0.0, dy: float = 0.0, dz: float = 0.0) -> Part:
|
||||
"""Duplicate a board, offset by (dx, dy, dz)."""
|
||||
self._checkpoint()
|
||||
src = self.resolve(ref)
|
||||
pid = f"p{self._next_part}"
|
||||
self._next_part += 1
|
||||
clone = Part(id=pid, stock=src.stock, length_in=src.length_in,
|
||||
section_in=src.section_in,
|
||||
position_in=[src.position_in[0] + dx, src.position_in[1] + dy,
|
||||
src.position_in[2] + dz],
|
||||
yaw_deg=src.yaw_deg, tilt_deg=src.tilt_deg, roll_deg=src.roll_deg,
|
||||
finishes=list(src.finishes))
|
||||
self.parts.append(clone)
|
||||
self.selection = pid
|
||||
return clone
|
||||
|
||||
def rename(self, ref: str | None, name: str) -> Part:
|
||||
"""Give a board a human-friendly alias, e.g. 'front-left leg'."""
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
part.name = name.strip()
|
||||
self.selection = part.id
|
||||
return part
|
||||
|
||||
def clear(self) -> str:
|
||||
self._checkpoint()
|
||||
self.parts = []
|
||||
self.joints = []
|
||||
self.connections = []
|
||||
self.selection = None
|
||||
self._next_part = 1
|
||||
self._next_joint = 1
|
||||
self._next_feat = 1
|
||||
self._next_conn = 1
|
||||
return "Cleared the scene."
|
||||
|
||||
# ----- joinery features --------------------------------------------
|
||||
def add_feature(self, ref: str | None, kind: str, face: str = "end_b",
|
||||
**dims) -> Feature:
|
||||
kind = kind.lower().strip()
|
||||
if kind not in FEATURE_KINDS:
|
||||
raise SceneError(f"Unknown feature {kind!r}. Known: {', '.join(sorted(FEATURE_KINDS))}")
|
||||
if face not in FACES:
|
||||
raise SceneError(f"Unknown face {face!r}. Faces: {', '.join(FACES)}")
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
fid = f"f{self._next_feat}"
|
||||
self._next_feat += 1
|
||||
allowed = {"along_in", "across_in", "width_in", "height_in", "depth_in",
|
||||
"diameter_in", "rotation_deg"}
|
||||
feat = Feature(id=fid, kind=kind, face=face,
|
||||
**{k: float(v) for k, v in dims.items() if k in allowed and v is not None})
|
||||
part.features.append(feat)
|
||||
self.selection = part.id
|
||||
return feat
|
||||
|
||||
def find_feature(self, fid: str) -> tuple[Part, Feature]:
|
||||
for p in self.parts:
|
||||
for f in p.features:
|
||||
if f.id == fid:
|
||||
return p, f
|
||||
raise SceneError(f"No feature {fid!r}.")
|
||||
|
||||
def edit_feature(self, fid: str, **dims) -> Feature:
|
||||
self._checkpoint()
|
||||
part, feat = self.find_feature(fid)
|
||||
for k, v in dims.items():
|
||||
if v is None:
|
||||
continue
|
||||
if k == "face":
|
||||
feat.face = v
|
||||
elif hasattr(feat, k):
|
||||
setattr(feat, k, float(v))
|
||||
self.selection = part.id
|
||||
return feat
|
||||
|
||||
def delete_feature(self, fid: str) -> str:
|
||||
self._checkpoint()
|
||||
part, feat = self.find_feature(fid)
|
||||
part.features.remove(feat)
|
||||
self.selection = part.id
|
||||
return f"Deleted feature {fid} from {part.id}."
|
||||
|
||||
def feature_owner(self, fid: str) -> Part:
|
||||
return self.find_feature(fid)[0]
|
||||
|
||||
def _seat(self, anchor_fid: str, moving_fid: str):
|
||||
"""Position/orient the moving board so its feature mates with the anchor.
|
||||
Returns (anchor_part, moving_part, anchor_normal). No checkpoint/record."""
|
||||
anchor_part, anchor_feat = self.find_feature(anchor_fid)
|
||||
moving_part, moving_feat = self.find_feature(moving_fid)
|
||||
if anchor_part is moving_part:
|
||||
raise SceneError("Connect features on two different boards.")
|
||||
|
||||
pa, na, ua, va = anchor_part.feature_world_frame(anchor_feat)
|
||||
|
||||
# moving feature's LOCAL frame (in its own board), with its rotation.
|
||||
t, w = moving_part.section_in
|
||||
o, n, u, v = face_frame(moving_feat.face, moving_part.length_in, w, t)
|
||||
off_u = moving_feat.along_in - (moving_part.length_in / 2 if u == (1, 0, 0) else 0.0)
|
||||
fp_l = tuple(o[i] + off_u * u[i] + moving_feat.across_in * v[i] for i in range(3))
|
||||
n_l = n
|
||||
u_l, v_l = _rodrigues(u, n, moving_feat.rotation_deg), _rodrigues(v, n, moving_feat.rotation_deg)
|
||||
|
||||
# Desired world axes for the moving feature: insertion opposite the
|
||||
# anchor's outward normal; cross-axes aligned (v flips to stay right-handed).
|
||||
dN = tuple(-x for x in na)
|
||||
dU, dV = ua, tuple(-x for x in va)
|
||||
|
||||
# R such that R·n_l = dN, R·u_l = dU, R·v_l = dV (R = [dN|dU|dV]·[n_l|u_l|v_l]^T)
|
||||
cols_d, cols_f = (dN, dU, dV), (n_l, u_l, v_l)
|
||||
R = [[sum(cols_d[k][i] * cols_f[k][j] for k in range(3)) for j in range(3)]
|
||||
for i in range(3)]
|
||||
moving_part.yaw_deg, moving_part.tilt_deg, moving_part.roll_deg = matrix_to_ypr(R)
|
||||
|
||||
moved_fp = tuple(sum(R[i][j] * fp_l[j] for j in range(3)) for i in range(3))
|
||||
moving_part.position_in = [pa[i] - moved_fp[i] for i in range(3)]
|
||||
return anchor_part, moving_part, na
|
||||
|
||||
def _group_of(self, pid: str) -> set:
|
||||
for g in self.groups():
|
||||
if pid in g:
|
||||
return set(g)
|
||||
return {pid}
|
||||
|
||||
def _drag_group(self, lead: Part, old_R, old_p, member_ids) -> None:
|
||||
"""Apply the rigid move that `lead` just underwent to the other members of
|
||||
its assembly, so connected boards travel together."""
|
||||
new_R, new_p = lead.rotation_matrix(), lead.position_in
|
||||
Rd = _matmul(new_R, _transpose(old_R)) # delta rotation
|
||||
for pid in member_ids:
|
||||
if pid == lead.id:
|
||||
continue
|
||||
q = self.get_part(pid)
|
||||
q.yaw_deg, q.tilt_deg, q.roll_deg = matrix_to_ypr(_matmul(Rd, q.rotation_matrix()))
|
||||
rel = [q.position_in[i] - old_p[i] for i in range(3)]
|
||||
moved = _mv(Rd, rel)
|
||||
q.position_in = [new_p[i] + moved[i] for i in range(3)]
|
||||
|
||||
def connect(self, anchor_fid: str, moving_fid: str) -> str:
|
||||
"""Seat the moving board into the anchor and record the connection. If the
|
||||
moving board is already part of an assembly, that whole sub-assembly moves
|
||||
with it (rigidly)."""
|
||||
self._checkpoint()
|
||||
moving_part = self.feature_owner(moving_fid)
|
||||
anchor_part = self.feature_owner(anchor_fid)
|
||||
# boards rigidly attached to the moving one (minus the anchor's group)
|
||||
move_with = self._group_of(moving_part.id) - self._group_of(anchor_part.id)
|
||||
old_R, old_p = moving_part.rotation_matrix(), list(moving_part.position_in)
|
||||
|
||||
anchor_part, moving_part, _ = self._seat(anchor_fid, moving_fid)
|
||||
self._drag_group(moving_part, old_R, old_p, move_with)
|
||||
|
||||
existing = next((c for c in self.connections
|
||||
if c.anchor == anchor_fid and c.moving == moving_fid), None)
|
||||
if existing:
|
||||
existing.backed_off_in = 0.0
|
||||
else:
|
||||
self.connections.append(Connection(id=f"c{self._next_conn}",
|
||||
anchor=anchor_fid, moving=moving_fid))
|
||||
self._next_conn += 1
|
||||
self.selection = moving_part.id
|
||||
return f"Connected {moving_part.id} to {anchor_part.id}."
|
||||
|
||||
def _conn_valid(self, c: Connection) -> bool:
|
||||
try:
|
||||
self.find_feature(c.anchor)
|
||||
self.find_feature(c.moving)
|
||||
return True
|
||||
except SceneError:
|
||||
return False
|
||||
|
||||
def assemble(self) -> str:
|
||||
"""Re-fit every connection (seat the moving boards back into place)."""
|
||||
self._checkpoint()
|
||||
n = 0
|
||||
for c in self.connections:
|
||||
if self._conn_valid(c):
|
||||
self._seat(c.anchor, c.moving)
|
||||
c.backed_off_in = 0.0
|
||||
n += 1
|
||||
return f"Re-fitted {n} connection(s)."
|
||||
|
||||
def explode(self, distance: float) -> str:
|
||||
"""Back off each moving board along its joint axis (exploded view)."""
|
||||
self._checkpoint()
|
||||
n = 0
|
||||
for c in self.connections:
|
||||
if not self._conn_valid(c):
|
||||
continue
|
||||
_, mp, na = self._seat(c.anchor, c.moving)
|
||||
mp.position_in = [mp.position_in[i] + na[i] * distance for i in range(3)]
|
||||
c.backed_off_in = distance
|
||||
n += 1
|
||||
return f"Backed off {n} connection(s) by {distance:g} in."
|
||||
|
||||
def disconnect(self, cid: str | None = None, part: str | None = None) -> str:
|
||||
"""Break connection(s): pieces stay in place but become independent."""
|
||||
self._checkpoint()
|
||||
before = len(self.connections)
|
||||
if cid:
|
||||
self.connections = [c for c in self.connections if c.id != cid]
|
||||
elif part is not None:
|
||||
pid = self.resolve(part).id
|
||||
self.connections = [c for c in self.connections
|
||||
if not (self._conn_valid(c)
|
||||
and pid in (self.feature_owner(c.anchor).id,
|
||||
self.feature_owner(c.moving).id))]
|
||||
else:
|
||||
self.connections = []
|
||||
return f"Broke {before - len(self.connections)} connection(s)."
|
||||
|
||||
def groups(self) -> list[list[str]]:
|
||||
"""Connected-component part groups (assemblies) via the connection graph."""
|
||||
parent = {p.id: p.id for p in self.parts}
|
||||
|
||||
def find(x):
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
for c in self.connections:
|
||||
if self._conn_valid(c):
|
||||
parent[find(self.feature_owner(c.anchor).id)] = find(self.feature_owner(c.moving).id)
|
||||
groups: dict[str, list[str]] = {}
|
||||
for p in self.parts:
|
||||
groups.setdefault(find(p.id), []).append(p.id)
|
||||
return list(groups.values())
|
||||
|
||||
def delete(self, ref: str | None) -> str:
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
dead_features = {f.id for f in part.features}
|
||||
self.parts = [p for p in self.parts if p.id != part.id]
|
||||
self.joints = [j for j in self.joints
|
||||
if part.id not in (j.part_a, j.part_b)]
|
||||
self.connections = [c for c in self.connections
|
||||
if not (dead_features & {c.anchor, c.moving})]
|
||||
if self.selection == part.id:
|
||||
self.selection = self.parts[-1].id if self.parts else None
|
||||
return f"Deleted {part.id}."
|
||||
|
||||
# ----- persistence --------------------------------------------------
|
||||
def _raw(self) -> dict:
|
||||
d = asdict(self)
|
||||
d.pop("_undo", None)
|
||||
d.pop("_redo", None)
|
||||
return d
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Scene":
|
||||
parts = []
|
||||
valid = {f.name for f in fields(Part)}
|
||||
feat_fields = {f.name for f in fields(Feature)}
|
||||
for p in data.get("parts", []):
|
||||
p = dict(p)
|
||||
if "rotation_deg" in p and "yaw_deg" not in p: # migrate old scenes
|
||||
p["yaw_deg"] = p.pop("rotation_deg")
|
||||
p["section_in"] = tuple(p["section_in"])
|
||||
p["features"] = [Feature(**{k: v for k, v in f.items() if k in feat_fields})
|
||||
for f in p.get("features", [])]
|
||||
parts.append(Part(**{k: v for k, v in p.items() if k in valid}))
|
||||
joints = [Joint(**j) for j in data.get("joints", [])]
|
||||
conn_fields = {f.name for f in fields(Connection)}
|
||||
connections = [Connection(**{k: v for k, v in c.items() if k in conn_fields})
|
||||
for c in data.get("connections", [])]
|
||||
return cls(
|
||||
version=data.get("version", SCENE_VERSION),
|
||||
units=data.get("units", "inch"),
|
||||
parts=parts,
|
||||
joints=joints,
|
||||
connections=connections,
|
||||
selection=data.get("selection"),
|
||||
_next_part=data.get("_next_part", len(parts) + 1),
|
||||
_next_joint=data.get("_next_joint", len(joints) + 1),
|
||||
_next_feat=data.get("_next_feat", 1),
|
||||
_next_conn=data.get("_next_conn", len(connections) + 1),
|
||||
_undo=data.get("_undo", []),
|
||||
_redo=data.get("_redo", []),
|
||||
)
|
||||
|
||||
def save(self, path: Path | None = None) -> Path:
|
||||
path = Path(path) if path else default_scene_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(self.to_dict(), indent=2))
|
||||
tmp.replace(path) # atomic so the viewport never reads a half-written file
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None) -> "Scene":
|
||||
path = Path(path) if path else default_scene_path()
|
||||
if not path.exists():
|
||||
return cls()
|
||||
return cls.from_dict(json.loads(path.read_text()))
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
"""Parse spoken/typed lengths into inches.
|
||||
|
||||
The AI interpreter is expected to pass reasonably structured values, but people
|
||||
say "6 foot", "2'", '10 inches', '3 ft 6 in', or bare numbers. Everything is
|
||||
normalized to inches (the scene's internal unit).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_FEET = r"(?:feet|foot|ft|')"
|
||||
_INCH = r"(?:inches|inch|in|\")"
|
||||
|
||||
# e.g. "3 ft 6 in", "6 foot", "10 inches", "2'", "72", "-5"
|
||||
_COMBINED = re.compile(
|
||||
rf"^\s*(?P<sign>-)?\s*(?:(?P<ft>[\d.]+)\s*{_FEET})?\s*(?:(?P<inch>[\d.]+)\s*{_INCH})?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BARE = re.compile(r"^\s*(?P<n>-?[\d.]+)\s*$")
|
||||
|
||||
|
||||
def to_inches(value: str | float | int, default_unit: str = "inch") -> float:
|
||||
"""Convert a length expression to inches.
|
||||
|
||||
Bare numbers use ``default_unit`` ('inch' or 'foot'). Raises ValueError on
|
||||
anything unparseable.
|
||||
"""
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value) * (12.0 if default_unit.startswith("f") else 1.0)
|
||||
|
||||
text = str(value).strip()
|
||||
bare = _BARE.match(text)
|
||||
if bare:
|
||||
n = float(bare.group("n"))
|
||||
return n * (12.0 if default_unit.startswith("f") else 1.0)
|
||||
|
||||
m = _COMBINED.match(text)
|
||||
if m and (m.group("ft") or m.group("inch")):
|
||||
ft = float(m.group("ft") or 0)
|
||||
inch = float(m.group("inch") or 0)
|
||||
total = ft * 12.0 + inch
|
||||
return -total if m.group("sign") else total
|
||||
|
||||
raise ValueError(f"Could not parse length: {value!r}")
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
"""Live 3D viewport: watches scene.json and re-renders on every change.
|
||||
|
||||
Run it alongside the voice driver:
|
||||
|
||||
woodshop-view # or: python -m woodshop.viewer
|
||||
|
||||
It polls the scene file's mtime and rebuilds the view whenever an operation
|
||||
tool writes a change, so saying "place a 6 foot 2x4" makes a board appear. Uses
|
||||
lightweight pyvista boxes for instant updates (build123d/geometry.py is used for
|
||||
accurate export, not for the live view).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .scene import Part, Scene, default_scene_path
|
||||
|
||||
# Distinct colors so adjacent boards read as separate pieces.
|
||||
_PALETTE = ["#c8965a", "#a9744f", "#d6b27c", "#8d5524", "#e0c097", "#b5651d"]
|
||||
|
||||
|
||||
def _featured_mesh(part: Part):
|
||||
"""Tessellate the true build123d solid (with joinery booleans) for display."""
|
||||
import pyvista as pv
|
||||
|
||||
from .geometry import part_solid
|
||||
verts, tris = part_solid(part).tessellate(0.02)
|
||||
points = [(v.X, v.Y, v.Z) for v in verts]
|
||||
faces = []
|
||||
for tri in tris:
|
||||
faces += [3, tri[0], tri[1], tri[2]]
|
||||
return pv.PolyData(points, faces)
|
||||
|
||||
|
||||
def _part_mesh(part: Part):
|
||||
import pyvista as pv
|
||||
|
||||
if part.features: # show real joinery (slower; only featured boards)
|
||||
try:
|
||||
return _featured_mesh(part)
|
||||
except Exception:
|
||||
pass # fall back to the plain box if booleans fail
|
||||
|
||||
length = part.length_in
|
||||
thickness, width = part.section_in
|
||||
cube = pv.Cube(center=(length / 2, 0, 0),
|
||||
x_length=length, y_length=width, z_length=thickness)
|
||||
cube.rotate_x(part.roll_deg, point=(0, 0, 0), inplace=True)
|
||||
cube.rotate_y(-part.tilt_deg, point=(0, 0, 0), inplace=True)
|
||||
cube.rotate_z(part.yaw_deg, point=(0, 0, 0), inplace=True)
|
||||
cube.translate(part.position_in, inplace=True)
|
||||
return cube
|
||||
|
||||
|
||||
def _axis_extent(axis, L, w, t):
|
||||
if axis == (1, 0, 0):
|
||||
return L
|
||||
if axis in ((0, 1, 0), (0, -1, 0)):
|
||||
return w
|
||||
return t
|
||||
|
||||
|
||||
def feature_preview_mesh(part, feat):
|
||||
"""A cheap pyvista box/cylinder showing a feature's footprint (no build123d),
|
||||
for the live red preview while adjusting fields."""
|
||||
import pyvista as pv
|
||||
|
||||
from .geometry import _face_frame
|
||||
L = part.length_in
|
||||
t, w = part.section_in
|
||||
o, n, u, v = _face_frame(feat.face, L, w, t)
|
||||
off_u = feat.along_in - (L / 2 if u == (1, 0, 0) else 0.0)
|
||||
fp = tuple(o[i] + off_u * u[i] + feat.across_in * v[i] for i in range(3))
|
||||
|
||||
if feat.kind == "hole":
|
||||
thru = abs(n[0]) * L + abs(n[1]) * w + abs(n[2]) * t + 0.1
|
||||
h = feat.depth_in if feat.depth_in > 0 else thru
|
||||
c = tuple(fp[i] - n[i] * h / 2 for i in range(3))
|
||||
mesh = pv.Cylinder(center=c, direction=n, radius=feat.diameter_in / 2, height=h)
|
||||
elif feat.kind == "chamfer": # can't cheaply preview the bevel — highlight the face
|
||||
ue, ve, thin = _axis_extent(u, L, w, t), _axis_extent(v, L, w, t), 0.08
|
||||
dims = tuple(ue * abs(u[i]) + ve * abs(v[i]) + thin * abs(n[i]) for i in range(3))
|
||||
c = fp
|
||||
mesh = pv.Box(bounds=(c[0] - dims[0] / 2, c[0] + dims[0] / 2,
|
||||
c[1] - dims[1] / 2, c[1] + dims[1] / 2,
|
||||
c[2] - dims[2] / 2, c[2] + dims[2] / 2))
|
||||
else: # tenon (out) / mortise/slot/dado/rabbet (in)
|
||||
d = feat.depth_in
|
||||
dims = tuple(feat.width_in * abs(u[i]) + feat.height_in * abs(v[i]) + d * abs(n[i])
|
||||
for i in range(3))
|
||||
sign = 1 if feat.kind == "tenon" else -1
|
||||
c = tuple(fp[i] + sign * n[i] * d / 2 for i in range(3))
|
||||
mesh = pv.Box(bounds=(c[0] - dims[0] / 2, c[0] + dims[0] / 2,
|
||||
c[1] - dims[1] / 2, c[1] + dims[1] / 2,
|
||||
c[2] - dims[2] / 2, c[2] + dims[2] / 2))
|
||||
|
||||
if feat.rotation_deg and feat.kind not in ("hole", "chamfer"):
|
||||
mesh.rotate_vector(n, feat.rotation_deg, point=fp, inplace=True)
|
||||
mesh.rotate_x(part.roll_deg, point=(0, 0, 0), inplace=True)
|
||||
mesh.rotate_y(-part.tilt_deg, point=(0, 0, 0), inplace=True)
|
||||
mesh.rotate_z(part.yaw_deg, point=(0, 0, 0), inplace=True)
|
||||
mesh.translate(part.position_in, inplace=True)
|
||||
return mesh
|
||||
|
||||
|
||||
def _add_feature_edges(plotter, mesh, selected: bool) -> None:
|
||||
"""Overlay a tessellated solid's real edges (corners/holes/chamfers) so it
|
||||
reads as crisply as a plain board, without the triangle-mesh noise."""
|
||||
try:
|
||||
edges = mesh.extract_feature_edges(
|
||||
feature_angle=20, boundary_edges=True, feature_edges=True,
|
||||
manifold_edges=False, non_manifold_edges=False)
|
||||
if edges.n_points:
|
||||
plotter.add_mesh(edges, color="yellow" if selected else "black",
|
||||
line_width=3 if selected else 2, reset_camera=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _quiet_vtk() -> None:
|
||||
"""Stop VTK from spamming warnings (esp. headless) through Python logging."""
|
||||
try:
|
||||
import vtk
|
||||
vtk.vtkObject.GlobalWarningDisplayOff()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _render(plotter, scene: Scene) -> None:
|
||||
import pyvista as pv
|
||||
|
||||
plotter.clear()
|
||||
plotter.clear_actors()
|
||||
labels, label_pts = [], []
|
||||
for i, part in enumerate(scene.parts):
|
||||
edge = part.id == scene.selection
|
||||
mesh = _part_mesh(part)
|
||||
plotter.add_mesh(
|
||||
mesh,
|
||||
color="#f5d76e" if edge else _PALETTE[i % len(_PALETTE)],
|
||||
show_edges=not part.features, # plain boxes: real quad edges
|
||||
line_width=3 if edge else 1,
|
||||
edge_color="black",
|
||||
smooth_shading=False,
|
||||
)
|
||||
if part.features: # tessellated: overlay only the true edges
|
||||
_add_feature_edges(plotter, mesh, edge)
|
||||
mid = [part.position_in[j] + part.axis_unit()[j] * part.length_in / 2 for j in range(3)]
|
||||
labels.append(part.name or part.id)
|
||||
label_pts.append(mid)
|
||||
|
||||
n = len(scene.parts)
|
||||
if label_pts:
|
||||
plotter.add_point_labels(
|
||||
label_pts, labels, font_size=12, text_color="white",
|
||||
shape_color="#222222", shape_opacity=0.5, point_size=1,
|
||||
name="labels", always_visible=True,
|
||||
)
|
||||
plotter.show_grid(color="#555555", xtitle="X (in)", ytitle="Y (in)", ztitle="Z (in)")
|
||||
plotter.add_text(f"WoodShop — {n} part(s) | selection: {scene.selection or '-'}",
|
||||
font_size=11, color="white", name="hud")
|
||||
plotter.add_axes()
|
||||
|
||||
|
||||
def render_to_file(scene: Scene, path, window_size=(1100, 800)) -> str:
|
||||
"""Render the scene to a PNG (off-screen) — works headless / over SSH."""
|
||||
import pyvista as pv
|
||||
|
||||
_quiet_vtk()
|
||||
pv.OFF_SCREEN = True
|
||||
plotter = pv.Plotter(off_screen=True, window_size=window_size)
|
||||
plotter.set_background("#2b2b2b")
|
||||
plotter.enable_parallel_projection()
|
||||
_render(plotter, scene)
|
||||
plotter.view_isometric()
|
||||
plotter.screenshot(str(path))
|
||||
plotter.close()
|
||||
return str(path)
|
||||
|
||||
|
||||
def run(scene_path: Path | None = None, poll_s: float = 0.3) -> None:
|
||||
import pyvista as pv
|
||||
|
||||
_quiet_vtk()
|
||||
scene_path = Path(scene_path) if scene_path else default_scene_path()
|
||||
plotter = pv.Plotter(title="WoodShop")
|
||||
plotter.set_background("#2b2b2b")
|
||||
plotter.enable_parallel_projection()
|
||||
|
||||
# Let closing the window (X button) or pressing q/Escape end the loop.
|
||||
closed = {"flag": False}
|
||||
def _on_close():
|
||||
closed["flag"] = True
|
||||
for key in ("q", "Escape"):
|
||||
try:
|
||||
plotter.add_key_event(key, _on_close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_mtime = -1.0
|
||||
scene = Scene.load(scene_path) if scene_path.exists() else Scene()
|
||||
_render(plotter, scene)
|
||||
plotter.view_isometric()
|
||||
plotter.show(interactive_update=True, auto_close=False)
|
||||
|
||||
while True:
|
||||
# Stop if the render window has been closed by any means.
|
||||
if closed["flag"] or getattr(plotter, "_closed", False) or plotter.render_window is None:
|
||||
break
|
||||
try:
|
||||
mtime = scene_path.stat().st_mtime if scene_path.exists() else 0.0
|
||||
if mtime != last_mtime:
|
||||
last_mtime = mtime
|
||||
scene = Scene.load(scene_path) if scene_path.exists() else Scene()
|
||||
_render(plotter, scene)
|
||||
plotter.update()
|
||||
time.sleep(poll_s)
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception: # window destroyed mid-update
|
||||
break
|
||||
|
||||
try:
|
||||
plotter.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(prog="woodshop-view", description="Live WoodShop 3D viewport.")
|
||||
ap.add_argument("--scene", help="Path to scene.json")
|
||||
args = ap.parse_args(argv)
|
||||
run(args.scene)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
"""Offscreen tests for the BOM window's drag/drop path (no display needed —
|
||||
QGraphicsScene is pure Qt). Guards the placement-id vs cut-item-id crash."""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from woodshop.cutplan import find_placement # noqa: E402
|
||||
from woodshop.gui.bom_window import BomWindow, _Piece # noqa: E402
|
||||
from woodshop.gui.controller import Controller # noqa: E402
|
||||
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _pieces(w):
|
||||
return sorted((it for it in w.scene.items() if isinstance(it, _Piece)),
|
||||
key=lambda it: it.pos().x())
|
||||
|
||||
|
||||
def test_drop_overlap_reverts_without_crashing(tmp_path):
|
||||
c = Controller(str(tmp_path / "s.json"))
|
||||
c.place("2x4", 30)
|
||||
c.place("2x4", 30) # one stick, two pieces
|
||||
w = BomWindow(c)
|
||||
first, second = _pieces(w)[:2]
|
||||
home = (second.sp_id, second.pos().x(), second.pos().y())
|
||||
second.setPos(0, second.pos().y()) # drop on top of the first -> overlap
|
||||
w._drop_piece(second, home) # must not raise (was StopIteration)
|
||||
assert "revert" in w._status.text().lower()
|
||||
|
||||
|
||||
def test_drop_onto_incompatible_stock_reverts(tmp_path):
|
||||
c = Controller(str(tmp_path / "s.json"))
|
||||
c.place("2x4", 24)
|
||||
c.place("ply-3/4", 24, width_in=24)
|
||||
w = BomWindow(c)
|
||||
lumber = next(it for it in _pieces(w)
|
||||
if not find_placement(w._plan, it.pid)[0].is_sheet)
|
||||
sheet_y = next(y0 for y0, _y1, sp in w._rows if sp.is_sheet)
|
||||
home = (lumber.sp_id, lumber.pos().x(), lumber.pos().y())
|
||||
lumber.setPos(10, sheet_y + 5) # drag the 2x4 onto the plywood sheet
|
||||
w._drop_piece(lumber, home) # must not raise
|
||||
assert "can't go" in w._status.text()
|
||||
|
||||
|
||||
def test_best_of_n_button_keeps_valid_plan(tmp_path):
|
||||
from woodshop.cutplan import validate_cut_plan
|
||||
c = Controller(str(tmp_path / "s.json"))
|
||||
for ln in (50, 46, 40, 30):
|
||||
c.place("2x4", ln)
|
||||
w = BomWindow(c)
|
||||
w._best_of_n() # no locks -> best of 100
|
||||
assert validate_cut_plan(w._plan) == []
|
||||
assert "best" in w._status.text().lower()
|
||||
|
||||
|
||||
def test_valid_move_commits(tmp_path):
|
||||
c = Controller(str(tmp_path / "s.json"))
|
||||
c.place("2x4", 20)
|
||||
c.place("2x4", 20)
|
||||
w = BomWindow(c)
|
||||
second = _pieces(w)[1]
|
||||
home = (second.sp_id, second.pos().x(), second.pos().y())
|
||||
second.setPos(50 * w._px, second.pos().y()) # slide it right, still clear
|
||||
w._drop_piece(second, home)
|
||||
assert "placed" in w._status.text().lower()
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
"""Tests for the cut list / board-feet / shopping estimate."""
|
||||
import pytest
|
||||
|
||||
from woodshop.cutlist import board_feet, cut_rows, nominal_dims, shopping
|
||||
from woodshop.scene import Scene
|
||||
|
||||
|
||||
def test_nominal_dims():
|
||||
assert nominal_dims("2x4") == (2.0, 4.0)
|
||||
assert nominal_dims("4x4") == (4.0, 4.0)
|
||||
|
||||
|
||||
def test_board_feet_uses_nominal():
|
||||
# 2x4 at 96in = (2*4*96)/144 = 5.333 bd-ft
|
||||
assert board_feet("2x4", 96) == pytest.approx(5.3333, abs=1e-3)
|
||||
|
||||
|
||||
def test_cut_rows_groups_and_counts():
|
||||
s = Scene()
|
||||
s.place("2x4", 48)
|
||||
s.place("2x4", 48)
|
||||
s.place("2x4", 29)
|
||||
rows = cut_rows(s)
|
||||
by_len = {r["length_in"]: r for r in rows}
|
||||
assert by_len[48.0]["count"] == 2
|
||||
assert by_len[29.0]["count"] == 1
|
||||
assert by_len[48.0]["board_feet"] == pytest.approx(board_feet("2x4", 48) * 2)
|
||||
|
||||
|
||||
def test_shopping_rounds_up_with_waste():
|
||||
s = Scene()
|
||||
s.place("2x4", 48)
|
||||
s.place("2x4", 48) # 96in total -> with 10% waste = 105.6 -> 2 sticks of 96in
|
||||
assert shopping(s) == {"2x4": 2}
|
||||
|
||||
|
||||
def test_empty_shopping():
|
||||
assert shopping(Scene()) == {}
|
||||
|
||||
|
||||
def test_end_tenon_extends_cut_length():
|
||||
from woodshop.cutlist import cut_length
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
assert cut_length(s.get_part("p1")) == 24
|
||||
s.add_feature("p1", "tenon", face="end_b", depth_in=1.5) # protrudes 1.5"
|
||||
assert cut_length(s.get_part("p1")) == 25.5
|
||||
# the cut row and board-feet reflect the longer piece
|
||||
assert cut_rows(s)[0]["length_in"] == 25.5
|
||||
assert cut_rows(s)[0]["board_feet"] == pytest.approx(board_feet("2x4", 25.5))
|
||||
|
||||
|
||||
def test_plywood_uses_sqft_and_sheets():
|
||||
from woodshop.cutlist import cut_rows, shopping, format_cutlist
|
||||
s = Scene()
|
||||
s.place("ply-3/4", 48, width_in=24) # 48 × 24 = 8 sq ft
|
||||
row = cut_rows(s)[0]
|
||||
assert row["plywood"] and row["sq_ft"] == pytest.approx(8.0)
|
||||
assert shopping(s)["ply-3/4"] == 1 # well under a 32 sq-ft sheet
|
||||
assert "sq ft" in format_cutlist(s) and "sheet" in format_cutlist(s)
|
||||
|
||||
|
||||
def test_cut_feature_does_not_change_cut_length():
|
||||
from woodshop.cutlist import cut_length
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.add_feature("p1", "mortise", face="top", width_in=1, height_in=1, depth_in=0.5)
|
||||
assert cut_length(s.get_part("p1")) == 24 # cuts don't reduce stock you buy
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
"""Phase 0 tests for the CutPlan model."""
|
||||
import json
|
||||
|
||||
from woodshop.cutplan import CutPlan, ShopSettings, build_cut_plan, validate_cut_plan
|
||||
from woodshop.scene import Scene
|
||||
|
||||
|
||||
def test_lumber_plan_packs_and_validates():
|
||||
s = Scene()
|
||||
for _ in range(3):
|
||||
s.place("2x4", 40)
|
||||
plan = build_cut_plan(s)
|
||||
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
|
||||
assert len(sticks) == 2
|
||||
assert sum(len(sp.placements) for sp in sticks) == 3
|
||||
assert plan.score["stock_count"] == 2
|
||||
assert validate_cut_plan(plan) == []
|
||||
|
||||
|
||||
def test_kerf_prevents_two_48_in_one_stick():
|
||||
s = Scene()
|
||||
s.place("2x4", 48)
|
||||
s.place("2x4", 48)
|
||||
assert build_cut_plan(s).score["stock_count"] == 2
|
||||
|
||||
|
||||
def test_tenon_extends_cut_item_length():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.add_feature("p1", "tenon", face="end_b", depth_in=2)
|
||||
item = build_cut_plan(s).items[0]
|
||||
assert item.length_in == 26 and "tenon" in item.note
|
||||
|
||||
|
||||
def test_plywood_plan_and_validate():
|
||||
s = Scene()
|
||||
s.place("ply-3/4", 40, width_in=20)
|
||||
s.place("ply-3/4", 40, width_in=20)
|
||||
plan = build_cut_plan(s)
|
||||
sheets = [sp for sp in plan.stock_pieces if sp.is_sheet]
|
||||
assert len(sheets) == 1 and len(sheets[0].placements) == 2
|
||||
assert validate_cut_plan(plan) == []
|
||||
|
||||
|
||||
def test_oversize_lumber_warns_and_is_unplaced():
|
||||
s = Scene()
|
||||
s.place("2x4", 120) # longer than a 96" stick
|
||||
plan = build_cut_plan(s)
|
||||
assert plan.unplaced and plan.warnings
|
||||
assert validate_cut_plan(plan) == [] # flagged, so still valid
|
||||
|
||||
|
||||
def test_stable_ids_present():
|
||||
s = Scene()
|
||||
s.place("2x4", 40)
|
||||
plan = build_cut_plan(s)
|
||||
assert all(it.id for it in plan.items)
|
||||
assert all(sp.id for sp in plan.stock_pieces)
|
||||
assert all(p.id for sp in plan.stock_pieces for p in sp.placements)
|
||||
|
||||
|
||||
def test_json_roundtrip():
|
||||
s = Scene()
|
||||
s.place("2x4", 40)
|
||||
s.place("ply-3/4", 40, width_in=20)
|
||||
plan = build_cut_plan(s)
|
||||
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
|
||||
assert plan2.settings.kerf_in == plan.settings.kerf_in
|
||||
assert [sp.id for sp in plan2.stock_pieces] == [sp.id for sp in plan.stock_pieces]
|
||||
assert plan2.score["stock_count"] == plan.score["stock_count"]
|
||||
assert validate_cut_plan(plan2) == []
|
||||
|
||||
|
||||
def test_plywood_rotation_fits_panel():
|
||||
s = Scene()
|
||||
s.place("ply-3/4", 30, width_in=60) # 60" wide > 48" sheet — needs rotating
|
||||
plan = build_cut_plan(s) # rotation allowed by default
|
||||
sheets = [sp for sp in plan.stock_pieces if sp.is_sheet]
|
||||
assert len(sheets) == 1
|
||||
p = sheets[0].placements[0]
|
||||
assert p.rotated and p.len_in == 60 and p.wid_in == 30
|
||||
assert validate_cut_plan(plan) == []
|
||||
|
||||
|
||||
def test_rotation_disabled_flags_unfit():
|
||||
s = Scene()
|
||||
s.place("ply-3/4", 30, width_in=60)
|
||||
plan = build_cut_plan(s, settings=ShopSettings(allow_plywood_rotation=False))
|
||||
assert plan.unplaced and plan.warnings
|
||||
|
||||
|
||||
def test_best_cut_plan_is_no_worse():
|
||||
from woodshop.cutplan import _plan_key, best_cut_plan
|
||||
s = Scene()
|
||||
for ln in (50, 46, 30, 30, 20):
|
||||
s.place("2x4", ln)
|
||||
best = best_cut_plan(s)
|
||||
base = build_cut_plan(s, strategy="decreasing")
|
||||
assert _plan_key(best) <= _plan_key(base)
|
||||
assert best.strategy == "optimized"
|
||||
assert validate_cut_plan(best) == []
|
||||
|
||||
|
||||
def test_snap_and_fits():
|
||||
from woodshop.cutplan import placement_fits, snap_x
|
||||
s = Scene()
|
||||
s.place("2x4", 30)
|
||||
s.place("2x4", 30) # both fit one stick
|
||||
plan = build_cut_plan(s)
|
||||
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
|
||||
p1, p2 = stick.placements[0], stick.placements[1]
|
||||
k = plan.settings.kerf_in
|
||||
assert abs(snap_x(stick, p2, 31.0, k) - (p1.x_in + p1.len_in + k)) < 1e-6
|
||||
p2.x_in = 0.0
|
||||
assert not placement_fits(stick, p2, k) # now overlaps p1
|
||||
p2.x_in = p1.len_in + k
|
||||
assert placement_fits(stick, p2, k) # butted clear
|
||||
|
||||
|
||||
def test_relocate_between_sticks():
|
||||
from woodshop.cutplan import relocate
|
||||
s = Scene()
|
||||
for _ in range(3):
|
||||
s.place("2x4", 60) # each needs its own stick
|
||||
plan = build_cut_plan(s)
|
||||
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
|
||||
assert len(sticks) == 3
|
||||
pid = sticks[2].placements[0].id
|
||||
relocate(plan, pid, sticks[0].id, 0.0)
|
||||
assert any(p.id == pid for p in sticks[0].placements)
|
||||
assert all(p.id != pid for p in sticks[2].placements)
|
||||
|
||||
|
||||
def test_rotate_placement_swaps_footprint():
|
||||
from woodshop.cutplan import rotate_placement
|
||||
s = Scene()
|
||||
s.place("ply-3/4", 40, width_in=20)
|
||||
plan = build_cut_plan(s)
|
||||
p = next(sp for sp in plan.stock_pieces if sp.is_sheet).placements[0]
|
||||
L, W, rot = p.len_in, p.wid_in, p.rotated
|
||||
rotate_placement(plan, p.id)
|
||||
assert p.len_in == W and p.wid_in == L and p.rotated != rot
|
||||
|
||||
|
||||
def test_kerf_gap_required_not_just_overlap():
|
||||
from woodshop.cutplan import placement_fits
|
||||
s = Scene()
|
||||
s.place("2x4", 30)
|
||||
s.place("2x4", 30)
|
||||
plan = build_cut_plan(s)
|
||||
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
|
||||
p1, p2 = stick.placements
|
||||
k = plan.settings.kerf_in
|
||||
p2.x_in = p1.len_in + 0.01 # closer than a kerf
|
||||
assert not placement_fits(stick, p2, k)
|
||||
p2.x_in = p1.len_in + k # exactly a kerf apart
|
||||
assert placement_fits(stick, p2, k)
|
||||
|
||||
|
||||
def test_validate_flags_wrong_stock_and_illegal_rotation():
|
||||
from woodshop.cutplan import relocate, rotate_placement
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.place("ply-3/4", 24, width_in=24)
|
||||
plan = build_cut_plan(s)
|
||||
lumber = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
|
||||
sheet = next(sp for sp in plan.stock_pieces if sp.is_sheet)
|
||||
relocate(plan, lumber.placements[0].id, sheet.id, 0.0, 0.0)
|
||||
assert any("stock piece" in p for p in validate_cut_plan(plan))
|
||||
|
||||
plan2 = build_cut_plan(s, settings=ShopSettings(allow_plywood_rotation=False))
|
||||
sh2 = next(sp for sp in plan2.stock_pieces if sp.is_sheet)
|
||||
rotate_placement(plan2, sh2.placements[0].id)
|
||||
assert any("rotation" in p for p in validate_cut_plan(plan2))
|
||||
|
||||
|
||||
def test_recompute_updates_waste_after_move():
|
||||
from woodshop.cutplan import recompute
|
||||
s = Scene()
|
||||
s.place("2x4", 30)
|
||||
s.place("2x4", 30)
|
||||
plan = build_cut_plan(s)
|
||||
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
|
||||
stick.placements[1].x_in = 60.0 # leave a gap after p1
|
||||
recompute(plan)
|
||||
assert any(abs(w.x_in - 30) < 1.0 for w in stick.waste) # gap at ~30 now waste
|
||||
|
||||
|
||||
def test_stable_hash_is_deterministic():
|
||||
from woodshop.cutplan import _stable_hash
|
||||
assert _stable_hash("ci1x") == _stable_hash("ci1x")
|
||||
|
||||
|
||||
def test_reoptimize_preserves_locked_placement():
|
||||
from woodshop.cutplan import reoptimize
|
||||
s = Scene()
|
||||
for ln in (40, 40, 40):
|
||||
s.place("2x4", ln)
|
||||
plan = build_cut_plan(s)
|
||||
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
|
||||
locked = sticks[-1].placements[0]
|
||||
locked.locked = True
|
||||
lx, lid = locked.x_in, locked.id
|
||||
re = reoptimize(s, plan, "decreasing")
|
||||
kept = [p for sp in re.stock_pieces for p in sp.placements if p.id == lid]
|
||||
assert kept and kept[0].locked and abs(kept[0].x_in - lx) < 1e-6
|
||||
placed = {p.item_id for sp in re.stock_pieces for p in sp.placements}
|
||||
assert {it.id for it in re.items} <= placed | set(re.unplaced) # nothing lost
|
||||
assert validate_cut_plan(re) == []
|
||||
|
||||
|
||||
def test_exact_no_worse_than_ffd():
|
||||
s = Scene()
|
||||
for ln in (50, 46, 40, 30, 30, 20):
|
||||
s.place("2x4", ln)
|
||||
ex = build_cut_plan(s, strategy="exact")
|
||||
ffd = build_cut_plan(s, strategy="decreasing")
|
||||
assert ex.score["stock_count"] <= ffd.score["stock_count"]
|
||||
placed = {p.item_id for sp in ex.stock_pieces for p in sp.placements}
|
||||
assert {it.id for it in ex.items} <= placed | set(ex.unplaced)
|
||||
assert validate_cut_plan(ex) == []
|
||||
|
||||
|
||||
def test_exact_handles_oversize():
|
||||
s = Scene()
|
||||
s.place("2x4", 40)
|
||||
s.place("2x4", 120) # bigger than a stick
|
||||
plan = build_cut_plan(s, strategy="exact")
|
||||
assert plan.unplaced and plan.warnings
|
||||
assert validate_cut_plan(plan) == []
|
||||
|
||||
|
||||
def test_guillotine_packs_and_validates():
|
||||
s = Scene()
|
||||
for _ in range(4):
|
||||
s.place("ply-3/4", 30, width_in=20)
|
||||
g = build_cut_plan(s, strategy="guillotine")
|
||||
sheets = [sp for sp in g.stock_pieces if sp.is_sheet]
|
||||
assert sheets and sum(len(sp.placements) for sp in sheets) == 4
|
||||
assert validate_cut_plan(g) == []
|
||||
|
||||
|
||||
def test_guillotine_oversize_panel_unplaced():
|
||||
s = Scene()
|
||||
s.place("ply-3/4", 200, width_in=200) # bigger than a whole sheet
|
||||
g = build_cut_plan(s, strategy="guillotine")
|
||||
assert g.unplaced and g.warnings
|
||||
assert validate_cut_plan(g) == []
|
||||
|
||||
|
||||
def test_best_of_n_no_worse():
|
||||
from woodshop.cutplan import _plan_key, best_cut_plan
|
||||
s = Scene()
|
||||
for ln in (50, 46, 40, 30, 30, 20):
|
||||
s.place("2x4", ln)
|
||||
best = best_cut_plan(s, attempts=50)
|
||||
base = build_cut_plan(s, strategy="decreasing")
|
||||
assert _plan_key(best) <= _plan_key(base)
|
||||
assert validate_cut_plan(best) == []
|
||||
|
||||
|
||||
def test_custom_settings_kerf():
|
||||
s = Scene()
|
||||
s.place("2x4", 48)
|
||||
s.place("2x4", 48)
|
||||
# zero kerf -> both 48" fit in one 96" stick
|
||||
assert build_cut_plan(s, settings=ShopSettings(kerf_in=0.0)).score["stock_count"] == 1
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
"""Tests for the driver's orchestration logic (external tools are mocked)."""
|
||||
import json
|
||||
|
||||
from woodshop import driver
|
||||
from woodshop.cli import normalize_anchor
|
||||
|
||||
|
||||
def test_anchor_aliases():
|
||||
assert normalize_anchor("end") == "end_b"
|
||||
assert normalize_anchor("the end") == "end_b" # falls through to default end_b
|
||||
assert normalize_anchor("start") == "end_a"
|
||||
assert normalize_anchor("NEAR") == "end_a"
|
||||
assert normalize_anchor("") == "end_b"
|
||||
|
||||
|
||||
def test_dispatch_resolves_dollar_symbols(monkeypatch):
|
||||
"""$1/$2 in a multi-op turn resolve to the ids of boards placed this turn."""
|
||||
seen = []
|
||||
|
||||
def fake_run(cmd, stdin=""):
|
||||
if cmd[0] != "pa-execute-tool":
|
||||
return ""
|
||||
name, args = cmd[2], json.loads(cmd[4])
|
||||
seen.append((name, args))
|
||||
if name == "wood-place":
|
||||
n = sum(1 for c in seen if c[0] == "wood-place")
|
||||
return json.dumps({"success": True, "output": f"Placed p{n}: a board.", "error": ""})
|
||||
return json.dumps({"success": True, "output": f"did {name}", "error": ""})
|
||||
|
||||
monkeypatch.setattr(driver, "_run", fake_run)
|
||||
calls = [
|
||||
{"tool": "wood-place", "args": {"stock": "2x4", "length": "2 ft"}},
|
||||
{"tool": "wood-place", "args": {"stock": "2x4", "length": "2 ft"}},
|
||||
{"tool": "wood-join", "args": {"part_b": "$2", "to": "$1", "angle": "90"}},
|
||||
]
|
||||
driver.dispatch(calls, verbose=False)
|
||||
join_args = next(a for n, a in seen if n == "wood-join")
|
||||
assert join_args["part_b"] == "p2"
|
||||
assert join_args["to"] == "p1"
|
||||
|
||||
|
||||
def test_say_pseudo_tool_does_not_dispatch(monkeypatch):
|
||||
calls_made = []
|
||||
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": calls_made.append(cmd) or "")
|
||||
msgs = driver.dispatch([{"tool": "say", "args": {"text": "which end?"}}], verbose=False)
|
||||
assert msgs == ["which end?"]
|
||||
assert calls_made == [] # nothing executed
|
||||
|
||||
|
||||
def test_interpret_tolerates_fenced_json(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
driver, "_run",
|
||||
lambda cmd, stdin="": '```json\n[{"tool": "wood-undo", "args": {}}]\n```'
|
||||
if cmd[:2] != ["pa-load-tools", "--tools"] else "[]",
|
||||
)
|
||||
calls = driver.interpret("undo that", schemas="[]")
|
||||
assert calls == [{"tool": "wood-undo", "args": {}}]
|
||||
|
||||
|
||||
def test_summarize_rolls_up_many_ops():
|
||||
calls = ([{"tool": "wood-place", "args": {}}] * 8
|
||||
+ [{"tool": "wood-join", "args": {}}] * 2
|
||||
+ [{"tool": "wood-stand", "args": {}}] * 4)
|
||||
summary = driver.summarize(calls, [""] * len(calls))
|
||||
assert "placed 8" in summary
|
||||
assert "joined 2" in summary
|
||||
assert "stood up 4" in summary
|
||||
assert len(summary) < 80 # short enough to speak
|
||||
|
||||
|
||||
def test_summarize_speaks_queries_verbatim():
|
||||
calls = [{"tool": "wood-cutlist", "args": {}}]
|
||||
messages = ["CUT LIST\n 2 x 2x4 ..."]
|
||||
assert driver.summarize(calls, messages).startswith("CUT LIST")
|
||||
|
||||
|
||||
def test_summarize_speaks_clarification():
|
||||
calls = [{"tool": "say", "args": {"text": "which end?"}}]
|
||||
assert driver.summarize(calls, ["which end?"]) == "which end?"
|
||||
|
||||
|
||||
def test_interpret_handles_garbage(monkeypatch):
|
||||
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": "I'm not sure what you mean")
|
||||
calls = driver.interpret("blah", schemas="[]")
|
||||
assert calls[0]["tool"] == "say"
|
||||
|
||||
|
||||
def test_extract_calls_ignores_trailing_brackets():
|
||||
"""A greedy [.*] would swallow the trailing '[note]' and fail to parse."""
|
||||
raw = '[{"tool": "wood-undo", "args": {}}]\n\nLet me know [if that helps].'
|
||||
assert driver._extract_calls(raw) == [{"tool": "wood-undo", "args": {}}]
|
||||
|
||||
|
||||
def test_extract_calls_strips_fences_and_handles_object():
|
||||
assert driver._extract_calls('```json\n{"tool": "wood-clear", "args": {}}\n```') == \
|
||||
[{"tool": "wood-clear", "args": {}}]
|
||||
|
||||
|
||||
def test_extract_calls_returns_none_on_garbage():
|
||||
assert driver._extract_calls("no json here") is None
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
"""Geometry tests that exercise the build123d boolean features."""
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("build123d")
|
||||
|
||||
from woodshop.geometry import part_solid # noqa: E402
|
||||
from woodshop.scene import Scene # noqa: E402
|
||||
|
||||
|
||||
def test_hole_reduces_volume():
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
base = part_solid(s.get_part("p1")).volume
|
||||
s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=1.0, depth_in=0) # through
|
||||
assert part_solid(s.get_part("p1")).volume < base
|
||||
|
||||
|
||||
def test_mortise_reduces_volume():
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
base = part_solid(s.get_part("p1")).volume
|
||||
s.add_feature("p1", "mortise", face="top", along_in=6, width_in=1, height_in=2, depth_in=0.75)
|
||||
assert part_solid(s.get_part("p1")).volume < base
|
||||
|
||||
|
||||
def test_tenon_adds_volume():
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
base = part_solid(s.get_part("p1")).volume
|
||||
s.add_feature("p1", "tenon", face="end_b", width_in=1.5, height_in=0.75, depth_in=1.5)
|
||||
assert part_solid(s.get_part("p1")).volume > base
|
||||
|
||||
|
||||
def test_chamfer_reduces_volume():
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
base = part_solid(s.get_part("p1")).volume
|
||||
s.add_feature("p1", "chamfer", face="end_b", width_in=0.5)
|
||||
assert part_solid(s.get_part("p1")).volume < base
|
||||
|
||||
|
||||
def test_oversized_chamfer_falls_back():
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
# absurd chamfer size: clamped/caught, board stays valid (no crash)
|
||||
s.add_feature("p1", "chamfer", face="end_b", width_in=99)
|
||||
assert part_solid(s.get_part("p1")).volume > 0
|
||||
|
||||
|
||||
def test_rotated_box_feature_still_cuts():
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
base = part_solid(s.get_part("p1")).volume
|
||||
s.add_feature("p1", "mortise", face="top", width_in=2, height_in=0.5,
|
||||
depth_in=0.5, rotation_deg=90)
|
||||
assert part_solid(s.get_part("p1")).volume < base
|
||||
|
||||
|
||||
def test_featured_part_tessellates():
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=0.5, depth_in=0)
|
||||
verts, tris = part_solid(s.get_part("p1")).tessellate(0.05)
|
||||
assert len(verts) > 8 and len(tris) > 12
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
"""Tests for the GUI controller's in-process command execution (no display)."""
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
from PySide6.QtCore import QCoreApplication # noqa: E402
|
||||
|
||||
from woodshop import driver # noqa: E402
|
||||
from woodshop.gui.controller import Controller # noqa: E402
|
||||
|
||||
_app = QCoreApplication.instance() or QCoreApplication([])
|
||||
|
||||
|
||||
def _controller(tmp_path):
|
||||
return Controller(str(tmp_path / "scene.json"))
|
||||
|
||||
|
||||
def test_execute_calls_with_symbols(tmp_path):
|
||||
"""The controller's executor applies wood-* calls in-process, with $N."""
|
||||
c = _controller(tmp_path)
|
||||
calls = [
|
||||
{"tool": "wood-place", "args": {"stock": "2x4", "length": "4 ft"}},
|
||||
{"tool": "wood-place", "args": {"stock": "2x4", "length": "2 ft"}},
|
||||
{"tool": "wood-stand", "args": {"part": "$2"}},
|
||||
{"tool": "wood-join", "args": {"part_b": "$2", "to": "$1", "angle": "0"}},
|
||||
]
|
||||
driver.dispatch(calls, verbose=False, executor=c.execute_call)
|
||||
assert [p.id for p in c.scene.parts] == ["p1", "p2"]
|
||||
assert c.scene.get_part("p2").is_vertical
|
||||
assert len(c.scene.joints) == 1
|
||||
|
||||
|
||||
def test_button_ops_and_persistence(tmp_path):
|
||||
c = _controller(tmp_path)
|
||||
c.place("2x4", 48)
|
||||
c.stand() # acts on selection (p1)
|
||||
assert c.scene.get_part("p1").is_vertical
|
||||
c.duplicate() # p2
|
||||
assert len(c.scene.parts) == 2
|
||||
c.delete() # deletes selection p2
|
||||
assert [p.id for p in c.scene.parts] == ["p1"]
|
||||
# changes are persisted to disk
|
||||
from woodshop.scene import Scene
|
||||
assert len(Scene.load(c.scene_path).parts) == 1
|
||||
|
||||
|
||||
def test_select_and_undo_redo(tmp_path):
|
||||
c = _controller(tmp_path)
|
||||
c.place("2x4", 24)
|
||||
c.place("2x4", 36)
|
||||
c.select("p1")
|
||||
assert c.selected_id == "p1"
|
||||
c.undo() # removes p2
|
||||
assert len(c.scene.parts) == 1
|
||||
c.redo()
|
||||
assert len(c.scene.parts) == 2
|
||||
|
||||
|
||||
def test_toggle_multiselect(tmp_path):
|
||||
c = _controller(tmp_path)
|
||||
c.place("2x4", 24)
|
||||
c.place("2x4", 24)
|
||||
c.select("p1")
|
||||
c.toggle("p2")
|
||||
assert set(c.selected) == {"p1", "p2"}
|
||||
c.toggle("p1") # ctrl-click again removes it
|
||||
assert c.selected == ["p2"]
|
||||
|
||||
|
||||
def test_group_move_is_single_undo(tmp_path):
|
||||
c = _controller(tmp_path)
|
||||
for _ in range(3):
|
||||
c.place("2x4", 24)
|
||||
c.set_selected(["p1", "p2", "p3"])
|
||||
c.move_selected(dy=4) # "move these 4 inches in +y"
|
||||
assert all(p.position_in[1] == 4 for p in c.scene.parts)
|
||||
c.undo() # one undo reverts the whole group
|
||||
assert all(p.position_in[1] == 0 for p in c.scene.parts)
|
||||
|
||||
|
||||
def test_feature_preview_then_apply(tmp_path):
|
||||
c = _controller(tmp_path)
|
||||
c.place("2x4", 12)
|
||||
c.add_feature("mortise") # active feature with defaults
|
||||
orig = c.active_feature_obj().depth_in
|
||||
c.set_preview(depth_in=orig + 0.5) # preview only — model unchanged
|
||||
assert c.preview is not None
|
||||
assert c.active_feature_obj().depth_in == orig
|
||||
c.apply_preview() # commit
|
||||
assert c.preview is None
|
||||
assert c.active_feature_obj().depth_in == orig + 0.5
|
||||
|
||||
|
||||
def test_feature_preview_mesh_builds():
|
||||
pytest.importorskip("pyvista")
|
||||
from woodshop.scene import Scene
|
||||
from woodshop.viewer import feature_preview_mesh
|
||||
s = Scene(); s.place("2x4", 12)
|
||||
feat = s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=0.5)
|
||||
assert feature_preview_mesh(s.get_part("p1"), feat).n_points > 0
|
||||
|
||||
|
||||
def test_fit_mortise_to_tenon(tmp_path):
|
||||
c = _controller(tmp_path)
|
||||
c.place("2x4", 24)
|
||||
c.add_feature("tenon") # f1 on p1, active
|
||||
c.scene.edit_feature("f1", width_in=1.0, height_in=0.75, depth_in=1.5)
|
||||
c.place("2x4", 24)
|
||||
c.add_feature("mortise") # f2 on p2, now active
|
||||
c.fit_feature("f1") # fit the mortise to the tenon
|
||||
_, m = c.scene.find_feature("f2")
|
||||
assert m.width_in == 1.0 + 1 / 32 # pocket = tongue + clearance
|
||||
assert m.height_in == 0.75 + 1 / 32
|
||||
assert m.depth_in == 1.5 + 1 / 32
|
||||
|
||||
|
||||
def test_highlight_feature(tmp_path):
|
||||
c = _controller(tmp_path)
|
||||
c.place("2x4", 12)
|
||||
c.add_feature("mortise") # f1
|
||||
c.highlight_feature("f1")
|
||||
assert c.preview is not None and c.preview_kind == "highlight"
|
||||
assert c.preview[1].id == "f1"
|
||||
c.highlight_feature(None)
|
||||
assert c.preview is None
|
||||
|
||||
|
||||
def test_break_feature_connection(tmp_path):
|
||||
c = _controller(tmp_path)
|
||||
c.place("2x4", 24); c.add_feature("mortise") # f1 on p1
|
||||
c.place("2x4", 12); c.add_feature("tenon") # f2 on p2
|
||||
c.scene.connect("f1", "f2")
|
||||
assert c.feature_connection_ids("f1") == ["c1"]
|
||||
c.break_feature_connection("f1")
|
||||
assert c.scene.connections == []
|
||||
assert c.feature_connection_ids("f1") == []
|
||||
|
||||
|
||||
def test_unknown_tool_is_safe(tmp_path):
|
||||
c = _controller(tmp_path)
|
||||
assert "unknown" in c.execute_call("wood-bogus", {}).lower()
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
"""Tests for deterministic build-step generation."""
|
||||
from woodshop.instructions import build_steps, format_steps, polish_prompt
|
||||
from woodshop.scene import Scene
|
||||
|
||||
|
||||
def _scene():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.rename("p1", "leg")
|
||||
s.add_feature("p1", "tenon", face="end_b", depth_in=1) # f1
|
||||
s.place("2x4", 48)
|
||||
s.add_feature("p2", "mortise", face="top", along_in=24,
|
||||
width_in=1.5, height_in=1, depth_in=1) # f2
|
||||
s.connect("f2", "f1") # seat the joint
|
||||
return s
|
||||
|
||||
|
||||
def test_steps_cover_the_build_phases():
|
||||
sections = build_steps(_scene())
|
||||
titles = [t for t, _ in sections]
|
||||
assert "Gather stock" in titles
|
||||
assert any("Cut pieces" in t for t in titles)
|
||||
assert any("joinery" in t.lower() for t in titles)
|
||||
assert any("glue" in t.lower() for t in titles) # assembly step (has connections)
|
||||
assert titles[-1] == "Finish"
|
||||
|
||||
|
||||
def test_steps_carry_real_data():
|
||||
text = format_steps(build_steps(_scene()))
|
||||
assert "leg" in text # named part appears
|
||||
assert "tenon" in text and "mortise" in text # joinery listed
|
||||
|
||||
|
||||
def test_polish_prompt_guards_numbers():
|
||||
p = polish_prompt(build_steps(_scene()))
|
||||
assert "do not invent" in p.lower()
|
||||
assert "Gather stock" in p
|
||||
|
||||
|
||||
def test_empty_scene_still_formats():
|
||||
assert format_steps(build_steps(Scene()))
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
"""Tests for rule-based jig suggestions."""
|
||||
from woodshop.jigs import format_jigs, suggest_jigs
|
||||
from woodshop.scene import Scene
|
||||
|
||||
|
||||
def test_repeated_crosscuts_suggest_stop_block():
|
||||
s = Scene()
|
||||
for _ in range(10): # the canonical example: 10 identical cuts
|
||||
s.place("2x4", 6.5)
|
||||
sb = [j for j in suggest_jigs(s) if j.kind == "stop-block"]
|
||||
assert sb and sb[0].count == 10
|
||||
assert "6.5" in sb[0].title
|
||||
|
||||
|
||||
def test_below_threshold_suggests_nothing():
|
||||
s = Scene()
|
||||
s.place("2x4", 6.5)
|
||||
s.place("2x4", 6.5) # only 2 < default min_repeats=3
|
||||
assert suggest_jigs(s) == []
|
||||
|
||||
|
||||
def test_repeated_holes_suggest_drill_template():
|
||||
s = Scene()
|
||||
for _ in range(4):
|
||||
p = s.place("2x4", 24)
|
||||
s.add_feature(p.id, "hole", face="top", diameter_in=0.375)
|
||||
assert any(j.kind == "drill-template" and j.count == 4 for j in suggest_jigs(s))
|
||||
|
||||
|
||||
def test_repeated_mortises_suggest_template():
|
||||
s = Scene()
|
||||
for _ in range(3):
|
||||
p = s.place("2x4", 24)
|
||||
s.add_feature(p.id, "mortise", face="top", width_in=1.5, height_in=1, depth_in=1)
|
||||
assert any(j.kind == "mortise-template" for j in suggest_jigs(s))
|
||||
|
||||
|
||||
def test_holes_at_same_position_suggest_template():
|
||||
s = Scene()
|
||||
for _ in range(3):
|
||||
p = s.place("2x4", 24)
|
||||
s.add_feature(p.id, "hole", face="top", along_in=3, diameter_in=0.375)
|
||||
assert any(j.kind == "drill-template" for j in suggest_jigs(s))
|
||||
|
||||
|
||||
def test_holes_at_different_positions_no_template():
|
||||
s = Scene()
|
||||
for along in (3, 9, 15): # same diameter, different spots
|
||||
p = s.place("2x4", 24)
|
||||
s.add_feature(p.id, "hole", face="top", along_in=along, diameter_in=0.375)
|
||||
assert not any(j.kind == "drill-template" for j in suggest_jigs(s))
|
||||
|
||||
|
||||
def test_format_empty():
|
||||
assert "No repeated" in format_jigs([])
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"""Tests for the cutting-stock nesting (lumber 1D, plywood 2D)."""
|
||||
from woodshop.layout import nest_lumber, nest_plywood, stock_counts
|
||||
from woodshop.scene import Scene
|
||||
|
||||
|
||||
def test_lumber_packs_into_sticks():
|
||||
s = Scene()
|
||||
for _ in range(3):
|
||||
s.place("2x4", 40) # three 40" pieces -> two fit in a 96" stick (80+kerf)
|
||||
sticks = nest_lumber(s)["2x4"]
|
||||
assert len(sticks) == 2 # 2 in the first stick, 1 in the second
|
||||
assert sum(len(st["pieces"]) for st in sticks) == 3
|
||||
assert all(st["used"] <= 96 + 1e-6 for st in sticks)
|
||||
|
||||
|
||||
def test_lumber_offcut_reported():
|
||||
s = Scene()
|
||||
s.place("2x4", 90)
|
||||
stick = nest_lumber(s)["2x4"][0]
|
||||
assert stick["offcut"] == 6.0 # 96 - 90
|
||||
|
||||
|
||||
def test_plywood_packs_panels_on_sheet():
|
||||
s = Scene()
|
||||
s.place("ply-3/4", 40, width_in=20) # two panels easily fit on one 48x96 sheet
|
||||
s.place("ply-3/4", 40, width_in=20)
|
||||
sheets = nest_plywood(s)["ply-3/4"]
|
||||
assert len(sheets) == 1
|
||||
assert len(sheets[0]["placements"]) == 2
|
||||
|
||||
|
||||
def test_oversize_panel_gets_its_own_sheet():
|
||||
s = Scene()
|
||||
s.place("ply-3/4", 96, width_in=48) # a full sheet
|
||||
s.place("ply-3/4", 96, width_in=48) # another full sheet
|
||||
assert len(nest_plywood(s)["ply-3/4"]) == 2
|
||||
|
||||
|
||||
def test_stock_counts_mixes_lumber_and_plywood():
|
||||
s = Scene()
|
||||
s.place("2x4", 40)
|
||||
s.place("ply-1/2", 24, width_in=24)
|
||||
counts = stock_counts(s)
|
||||
assert counts["2x4"] == 1
|
||||
assert counts["ply-1/2"] == 1
|
||||
|
|
@ -1,435 +0,0 @@
|
|||
"""Tests for the scene model and operations (no heavy 3D deps required)."""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from woodshop.lumber import actual_section, normalize_stock
|
||||
from woodshop.scene import Scene, SceneError
|
||||
from woodshop.units import to_inches
|
||||
|
||||
|
||||
# ----- lumber ----------------------------------------------------------
|
||||
def test_nominal_to_actual():
|
||||
assert actual_section("2x4") == (1.5, 3.5)
|
||||
assert actual_section("4x4") == (3.5, 3.5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [("2 x 4", "2x4"), ("2X4", "2x4"), ("2by4", "2x4")])
|
||||
def test_normalize_stock(raw, expected):
|
||||
assert normalize_stock(raw) == expected
|
||||
|
||||
|
||||
def test_unknown_stock_lists_options():
|
||||
with pytest.raises(KeyError, match="Known stock"):
|
||||
actual_section("9x9")
|
||||
|
||||
|
||||
# ----- units -----------------------------------------------------------
|
||||
@pytest.mark.parametrize("value,unit,inches", [
|
||||
("6 ft", "inch", 72), ("6 foot", "inch", 72), ("10 inches", "inch", 10),
|
||||
("3 ft 6 in", "inch", 42), ("2'", "inch", 24), ("72", "inch", 72),
|
||||
("6", "foot", 72), (6, "foot", 72),
|
||||
])
|
||||
def test_to_inches(value, unit, inches):
|
||||
assert to_inches(value, default_unit=unit) == inches
|
||||
|
||||
|
||||
def test_to_inches_bad():
|
||||
with pytest.raises(ValueError):
|
||||
to_inches("a bunch")
|
||||
|
||||
|
||||
# ----- operations ------------------------------------------------------
|
||||
def test_place_sets_section_and_selection():
|
||||
s = Scene()
|
||||
p = s.place("2x4", 72)
|
||||
assert p.id == "p1"
|
||||
assert p.section_in == (1.5, 3.5)
|
||||
assert s.selection == "p1"
|
||||
|
||||
|
||||
def test_the_example_sentence():
|
||||
"""'place a 6 foot 2x4, sand it, attach a 2 foot 2x4 at 90 deg, 10 in from end.'"""
|
||||
s = Scene()
|
||||
s.place("2x4", to_inches("6 ft")) # p1
|
||||
s.finish("it") # sand the selection
|
||||
s.place("2x4", to_inches("2 ft")) # p2 (now selected)
|
||||
s.join("p1", "p2", angle_deg=90, offset_in=10, anchor="end_b")
|
||||
|
||||
p1, p2 = s.get_part("p1"), s.get_part("p2")
|
||||
assert "sanded" in p1.finishes
|
||||
# attach point is 10in back from p1's far end (72 - 10 = 62 along +X)
|
||||
assert p2.position_in[0] == pytest.approx(62.0)
|
||||
# butt joint: p2's end sits flush on p1's side face (a 2x4 is 3.5" wide ->
|
||||
# 1.75" from centerline), in the same horizontal plane (z = 0).
|
||||
assert p2.position_in[1] == pytest.approx(1.75)
|
||||
assert p2.position_in[2] == pytest.approx(0.0, abs=1e-9)
|
||||
assert p2.yaw_deg == pytest.approx(90.0)
|
||||
# p2 now runs along +Y
|
||||
ux, uy, uz = p2.axis_unit()
|
||||
assert ux == pytest.approx(0.0, abs=1e-9)
|
||||
assert uy == pytest.approx(1.0)
|
||||
assert uz == pytest.approx(0.0, abs=1e-9)
|
||||
assert len(s.joints) == 1
|
||||
|
||||
|
||||
def test_stand_makes_board_vertical():
|
||||
s = Scene()
|
||||
s.place("2x4", 30)
|
||||
s.stand("it")
|
||||
p = s.get_part("p1")
|
||||
assert p.is_vertical
|
||||
ux, uy, uz = p.axis_unit()
|
||||
assert uz == pytest.approx(1.0) # length axis points straight up
|
||||
assert (ux, uy) == pytest.approx((0.0, 0.0), abs=1e-9)
|
||||
assert p.end_point()[2] == pytest.approx(30.0) # top is 30in up
|
||||
|
||||
|
||||
def test_butt_joint_meets_surface_not_centerline():
|
||||
"""B's end should sit on A's face, with no interpenetration past A's centerline."""
|
||||
s = Scene()
|
||||
s.place("2x4", 48) # p1 along +X, flat
|
||||
s.place("2x4", 12) # p2
|
||||
s.join("p1", "p2", angle_deg=90, offset_in=24, anchor="end_a")
|
||||
p2 = s.get_part("p2")
|
||||
# p2 runs along +Y starting at p1's +Y face (1.75 from centerline), not at
|
||||
# p1's centerline (which would be y=0).
|
||||
assert p2.position_in[1] == pytest.approx(1.75)
|
||||
# its far end is 1.75 + 12 out, fully clear of p1's body.
|
||||
assert p2.end_point()[1] == pytest.approx(13.75)
|
||||
|
||||
|
||||
def test_flush_aligns_tops_for_different_thicknesses():
|
||||
"""A thinner board joined to a thicker one should sit with TOPS level, not
|
||||
centers level (flush-by-default)."""
|
||||
s = Scene()
|
||||
s.place("2x4", 48) # p1: thickness 1.5 -> top face at z=0.75
|
||||
s.place("1x8", 12) # p2: thickness 0.75
|
||||
s.join("p1", "p2", angle_deg=90, offset_in=24, anchor="end_a")
|
||||
p2 = s.get_part("p2")
|
||||
# p2's top (z + 0.375) is flush with p1's top (0.75) -> p2 center at 0.375,
|
||||
# NOT centered on p1 (which would leave p2 at z=0).
|
||||
assert p2.position_in[2] == pytest.approx(0.375)
|
||||
|
||||
|
||||
def test_join_preserves_vertical_tilt():
|
||||
"""A stood-up leg stays vertical when attached to a horizontal apron."""
|
||||
s = Scene()
|
||||
s.place("2x4", 48) # p1 apron
|
||||
s.place("2x4", 29) # p2 leg
|
||||
s.stand("p2")
|
||||
s.join("p1", "p2", angle_deg=0, offset_in=0, anchor="end_a")
|
||||
leg = s.get_part("p2")
|
||||
assert leg.is_vertical
|
||||
# base sits on the apron's top face (z = t_a/2 = 0.75) since the leg is vertical
|
||||
assert leg.position_in[2] == pytest.approx(0.75)
|
||||
|
||||
|
||||
def test_move_relative_and_absolute():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.move("it", dx=5, dy=2, dz=1)
|
||||
assert s.get_part("p1").position_in == [5.0, 2.0, 1.0]
|
||||
s.move("it", dx=10, dy=0, dz=0, absolute=True)
|
||||
assert s.get_part("p1").position_in == [10.0, 0.0, 0.0]
|
||||
|
||||
|
||||
def test_copy_and_set_length_and_rename():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.rename("p1", "front rail")
|
||||
assert s.get_part("front rail").id == "p1" # resolvable by alias
|
||||
clone = s.copy("p1", dy=10)
|
||||
assert clone.id == "p2"
|
||||
assert clone.position_in[1] == 10.0
|
||||
s.set_length("p2", 36)
|
||||
assert s.get_part("p2").length_in == 36.0
|
||||
|
||||
|
||||
def test_select_by_id_and_name():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.place("2x4", 24)
|
||||
s.rename("p1", "front rail")
|
||||
assert s.select("front rail").id == "p1"
|
||||
assert s.selection == "p1"
|
||||
assert s.select("p2").id == "p2"
|
||||
|
||||
|
||||
def test_redo_after_undo():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.place("2x4", 36)
|
||||
assert len(s.parts) == 2
|
||||
s.undo()
|
||||
assert len(s.parts) == 1
|
||||
s.redo()
|
||||
assert len(s.parts) == 2
|
||||
assert s.get_part("p2").length_in == 36
|
||||
|
||||
|
||||
def test_new_action_clears_redo():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.place("2x4", 36)
|
||||
s.undo() # redo now has the p2 placement
|
||||
s.place("2x6", 12) # a new action should invalidate redo
|
||||
import pytest as _pt
|
||||
with _pt.raises(SceneError, match="Nothing to redo"):
|
||||
s.redo()
|
||||
|
||||
|
||||
def test_batch_is_one_undo():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.place("2x4", 24)
|
||||
with s.batch():
|
||||
s.move("p1", dx=5)
|
||||
s.move("p2", dx=5)
|
||||
assert s.get_part("p1").position_in[0] == 5
|
||||
s.undo() # single undo reverts both moves
|
||||
assert s.get_part("p1").position_in[0] == 0
|
||||
assert s.get_part("p2").position_in[0] == 0
|
||||
|
||||
|
||||
def test_add_edit_delete_feature():
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
f = s.add_feature("p1", "mortise", face="top", width_in=1, height_in=1, depth_in=0.5)
|
||||
assert f.id == "f1" and f.is_cut
|
||||
assert s.get_part("p1").features[0].kind == "mortise"
|
||||
s.edit_feature("f1", depth_in=0.75)
|
||||
assert s.find_feature("f1")[1].depth_in == 0.75
|
||||
s.delete_feature("f1")
|
||||
assert s.get_part("p1").features == []
|
||||
|
||||
|
||||
def test_tenon_is_additive():
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
assert not s.add_feature("p1", "tenon", face="end_b", depth_in=1).is_cut
|
||||
|
||||
|
||||
def test_unknown_feature_kind_errors():
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
with pytest.raises(SceneError, match="Unknown feature"):
|
||||
s.add_feature("p1", "dovetailzzz")
|
||||
|
||||
|
||||
def test_feature_roundtrip(tmp_path):
|
||||
s = Scene()
|
||||
s.place("2x4", 12)
|
||||
s.add_feature("p1", "hole", face="top", along_in=3, diameter_in=0.5)
|
||||
loaded = Scene.load(s.save(tmp_path / "s.json"))
|
||||
feat = loaded.get_part("p1").features[0]
|
||||
assert feat.kind == "hole" and feat.diameter_in == 0.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ypr", [(30, 0, 0), (0, 40, 0), (0, 0, 55), (35, 20, -15), (120, 30, -45)])
|
||||
def test_matrix_to_ypr_roundtrip(ypr):
|
||||
from woodshop.scene import matrix_to_ypr
|
||||
s = Scene()
|
||||
p = s.place("2x4", 12)
|
||||
p.yaw_deg, p.tilt_deg, p.roll_deg = ypr
|
||||
assert matrix_to_ypr(p.rotation_matrix()) == pytest.approx(ypr, abs=1e-6)
|
||||
|
||||
|
||||
def test_connect_seats_tenon_in_mortise():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.add_feature("p1", "mortise", face="top", along_in=12, width_in=1.5, height_in=1, depth_in=1)
|
||||
s.place("2x4", 12)
|
||||
s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1)
|
||||
s.connect("f1", "f2") # move p2 so its tenon seats into p1's mortise
|
||||
pa, na, _, _ = s.get_part("p1").feature_world_frame(s.find_feature("f1")[1])
|
||||
pb, nb, _, _ = s.get_part("p2").feature_world_frame(s.find_feature("f2")[1])
|
||||
assert pb == pytest.approx(pa, abs=1e-6) # faces meet
|
||||
assert nb == pytest.approx(tuple(-x for x in na), abs=1e-6) # tenon points into mortise
|
||||
|
||||
|
||||
def _two_connected():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.add_feature("p1", "mortise", face="top", along_in=12, width_in=1.5, height_in=1, depth_in=1)
|
||||
s.place("2x4", 12)
|
||||
s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1)
|
||||
s.connect("f1", "f2")
|
||||
return s
|
||||
|
||||
|
||||
def test_connect_records_and_groups():
|
||||
s = _two_connected()
|
||||
assert len(s.connections) == 1
|
||||
groups = [g for g in s.groups() if len(g) > 1]
|
||||
assert groups and set(groups[0]) == {"p1", "p2"}
|
||||
|
||||
|
||||
def test_explode_then_assemble_roundtrip():
|
||||
s = _two_connected()
|
||||
seated = list(s.get_part("p2").position_in)
|
||||
s.explode(5)
|
||||
assert s.get_part("p2").position_in != seated
|
||||
assert s.connections[0].backed_off_in == 5
|
||||
s.assemble()
|
||||
assert s.get_part("p2").position_in == pytest.approx(seated)
|
||||
assert s.connections[0].backed_off_in == 0
|
||||
|
||||
|
||||
def test_disconnect_keeps_position_and_ungroups():
|
||||
s = _two_connected()
|
||||
pos = list(s.get_part("p2").position_in)
|
||||
s.disconnect(cid="c1")
|
||||
assert s.connections == []
|
||||
assert s.get_part("p2").position_in == pos # pieces stay put
|
||||
assert all(len(g) == 1 for g in s.groups()) # no longer one assembly
|
||||
|
||||
|
||||
def test_delete_drops_connections():
|
||||
s = _two_connected()
|
||||
s.delete("p2")
|
||||
assert s.connections == []
|
||||
|
||||
|
||||
def test_connecting_drags_connected_subassembly():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.add_feature("p1", "mortise", face="top", along_in=12, width_in=1.5, height_in=1, depth_in=1) # f1 (A)
|
||||
s.place("2x4", 12)
|
||||
s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1) # f2 (B end)
|
||||
s.add_feature("p2", "mortise", face="top", along_in=6, width_in=1.5, height_in=1, depth_in=1) # f3 (B top)
|
||||
s.place("2x4", 8)
|
||||
s.add_feature("p3", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1) # f4 (C)
|
||||
s.connect("f3", "f4") # C seats into B; B stays, C moves
|
||||
|
||||
def dist(a, b):
|
||||
return math.dist(s.get_part(a).position_in, s.get_part(b).position_in)
|
||||
|
||||
d_bc = dist("p2", "p3")
|
||||
c_before = list(s.get_part("p3").position_in)
|
||||
s.connect("f1", "f2") # B seats into A — C should ride along
|
||||
assert dist("p2", "p3") == pytest.approx(d_bc, abs=1e-6) # B–C kept rigid
|
||||
assert s.get_part("p3").position_in != c_before # C actually moved
|
||||
|
||||
|
||||
def test_connect_needs_two_boards():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.add_feature("p1", "tenon", face="end_a")
|
||||
s.add_feature("p1", "mortise", face="top")
|
||||
with pytest.raises(SceneError, match="two different boards"):
|
||||
s.connect("f1", "f2")
|
||||
|
||||
|
||||
def test_bbox_axis_aligned():
|
||||
s = Scene()
|
||||
p = s.place("2x4", 24) # 1.5 x 3.5 section
|
||||
lo, hi = p.bbox()
|
||||
assert lo == pytest.approx((0, -1.75, -0.75))
|
||||
assert hi == pytest.approx((24, 1.75, 0.75))
|
||||
|
||||
|
||||
def test_spatial_summary_flags_overlap():
|
||||
from woodshop.scene import spatial_summary
|
||||
s = Scene()
|
||||
s.place("2x4", 24) # p1 at origin
|
||||
s.place("2x4", 24) # p2 at origin -> overlaps p1
|
||||
summ = spatial_summary(s)
|
||||
assert "p1" in summ and "p2" in summ
|
||||
assert "p1&p2" in summ # interpenetration flagged
|
||||
s.move("p2", dy=10) # slide clear
|
||||
assert "p1&p2" not in spatial_summary(s)
|
||||
|
||||
|
||||
def test_plywood_normalize_and_place():
|
||||
from woodshop.lumber import normalize_stock, is_plywood, plywood_thickness
|
||||
assert normalize_stock("3/4 plywood") == "ply-3/4"
|
||||
assert normalize_stock("plywood") == "ply-3/4"
|
||||
assert is_plywood("ply-1/2") and plywood_thickness("ply-1/2") == 0.5
|
||||
s = Scene()
|
||||
p = s.place("3/4 plywood", 48, width_in=24)
|
||||
assert p.stock == "ply-3/4"
|
||||
assert p.section_in == (0.75, 24.0)
|
||||
|
||||
|
||||
def test_plywood_requires_width():
|
||||
s = Scene()
|
||||
with pytest.raises(SceneError, match="width"):
|
||||
s.place("ply-1/2", 48)
|
||||
|
||||
|
||||
def test_clear():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.place("2x4", 24)
|
||||
s.clear()
|
||||
assert s.parts == [] and s.selection is None
|
||||
|
||||
|
||||
def test_migrate_old_rotation_field(tmp_path):
|
||||
"""Scenes saved with the old rotation_deg field still load."""
|
||||
import json
|
||||
old = {"version": 1, "parts": [{"id": "p1", "stock": "2x4", "length_in": 24,
|
||||
"section_in": [1.5, 3.5], "position_in": [0, 0, 0], "rotation_deg": 45}]}
|
||||
path = tmp_path / "old.json"
|
||||
path.write_text(json.dumps(old))
|
||||
s = Scene.load(path)
|
||||
assert s.get_part("p1").yaw_deg == 45
|
||||
|
||||
|
||||
def test_slugify():
|
||||
from woodshop.scene import slugify
|
||||
assert slugify("Coffee Table!") == "coffee-table"
|
||||
assert slugify(" My Bench ") == "my-bench"
|
||||
|
||||
|
||||
def test_project_save_open_list(tmp_path, monkeypatch):
|
||||
import woodshop.scene as scene_mod
|
||||
monkeypatch.setattr(scene_mod, "_data_dir", lambda: tmp_path)
|
||||
s = Scene()
|
||||
s.place("2x4", 48)
|
||||
s.place("2x4", 24)
|
||||
s.save(scene_mod.project_path("coffee table"))
|
||||
assert scene_mod.list_projects() == ["coffee-table"]
|
||||
reopened = Scene.load(scene_mod.project_path("Coffee Table")) # name normalizes
|
||||
assert len(reopened.parts) == 2
|
||||
|
||||
|
||||
def test_resolve_it_without_selection_errors():
|
||||
s = Scene()
|
||||
with pytest.raises(SceneError, match="selected"):
|
||||
s.finish("it")
|
||||
|
||||
|
||||
def test_undo_restores_previous_state():
|
||||
s = Scene()
|
||||
s.place("2x4", 72)
|
||||
s.place("2x4", 24)
|
||||
assert len(s.parts) == 2
|
||||
s.undo()
|
||||
assert len(s.parts) == 1
|
||||
assert s.selection == "p1"
|
||||
|
||||
|
||||
def test_delete_reassigns_selection_and_drops_joints():
|
||||
s = Scene()
|
||||
s.place("2x4", 72)
|
||||
s.place("2x4", 24)
|
||||
s.join("p1", "p2")
|
||||
s.delete("p2")
|
||||
assert [p.id for p in s.parts] == ["p1"]
|
||||
assert s.joints == []
|
||||
assert s.selection == "p1"
|
||||
|
||||
|
||||
def test_roundtrip_serialization(tmp_path):
|
||||
s = Scene()
|
||||
s.place("2x4", 72)
|
||||
s.place("2x4", 24)
|
||||
s.join("p1", "p2", angle_deg=90, offset_in=10)
|
||||
path = s.save(tmp_path / "scene.json")
|
||||
loaded = Scene.load(path)
|
||||
assert [p.id for p in loaded.parts] == ["p1", "p2"]
|
||||
assert loaded.parts[0].section_in == (1.5, 3.5)
|
||||
assert loaded.joints[0].angle_deg == 90
|
||||
assert loaded.selection == "p2"
|
||||
Loading…
Reference in New Issue