Add PoC core: scene model, operations, geometry, viewer
Voice-driven woodworking modeler core (the woodshop-specific half; voice/AI plumbing will reuse existing CmdForge tools). - scene.py: Part/Joint/Scene model, place/join/sand/delete/undo, JSON persistence (atomic), selection + undo stack - lumber.py: nominal->actual dimensional lumber table - units.py: parse "6 ft" / "3 ft 6 in" / "10 inches" to inches - cli.py: `woodshop` CLI (place/join/sand/delete/undo/export/status) - geometry.py: build123d solids + STL/STEP export - viewer.py: live pyvista viewport watching scene.json - tests: 20 passing, including the canonical example sentence - pyproject: woodshop + woodshop-view entry points, [viewer] extra Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
70591ad6fe
commit
a688623caf
|
|
@ -10,7 +10,16 @@ readme = "README.md"
|
|||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
woodshop = "woodshop.cli:main"
|
||||
woodshop-view = "woodshop.viewer:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Heavy 3D stack (OpenCASCADE etc.) — only needed to run the live viewport.
|
||||
viewer = [
|
||||
"build123d>=0.6",
|
||||
"pyvista>=0.43",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
"""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"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
"""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)
|
||||
part = scene.place(args.stock, length)
|
||||
return f"Placed {part.id}: a {_fmt_len(length)} {part.stock}."
|
||||
|
||||
|
||||
def cmd_join(scene: Scene, args) -> str:
|
||||
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=args.anchor)
|
||||
where = f" {_fmt_len(offset)} from {'the start' if args.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_undo(scene: Scene, args) -> str:
|
||||
return scene.undo()
|
||||
|
||||
|
||||
def cmd_reset(scene: Scene, args) -> str:
|
||||
scene.__dict__.update(Scene().__dict__)
|
||||
return "Cleared the scene."
|
||||
|
||||
|
||||
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_status(scene: Scene, args) -> str:
|
||||
lines = [f"{len(scene.parts)} part(s), {len(scene.joints)} joint(s); "
|
||||
f"selection: {scene.selection or 'none'}"]
|
||||
for p in scene.parts:
|
||||
fin = f" [{', '.join(p.finishes)}]" if p.finishes else ""
|
||||
lines.append(f" {p.id}: {_fmt_len(p.length_in)} {p.stock}{fin}")
|
||||
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)")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
sp = sub.add_parser("place", help="Place a new board")
|
||||
sp.add_argument("stock", help="Nominal stock, e.g. 2x4")
|
||||
sp.add_argument("length", help="Length, e.g. '6 ft' or '72'")
|
||||
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", choices=["end_a", "end_b"], default="end_b",
|
||||
help="Measure offset from start (end_a) or far end (end_b)")
|
||||
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("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)
|
||||
|
||||
sub.add_parser("undo", help="Undo the last operation").set_defaults(func=cmd_undo)
|
||||
sub.add_parser("reset", help="Clear the scene").set_defaults(func=cmd_reset)
|
||||
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)
|
||||
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"):
|
||||
scene.save(args.scene)
|
||||
print(message)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"""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 Part, Scene
|
||||
|
||||
|
||||
def part_solid(part: Part):
|
||||
from build123d import Box, Pos, Rot
|
||||
|
||||
length = part.length_in
|
||||
thickness, width = part.section_in
|
||||
box = Box(length, width, thickness) # X=length, Y=width, Z=thickness
|
||||
box = Pos(length / 2, 0, 0) * box # move start (end_a) to origin
|
||||
box = Rot(0, 0, part.rotation_deg) * box # orient in the XY plane
|
||||
box = Pos(*part.position_in) * box # place in the scene
|
||||
return box
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"""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),
|
||||
}
|
||||
|
||||
|
||||
def normalize_stock(stock: str) -> str:
|
||||
"""Canonicalize a spoken/typed stock name, e.g. '2 x 4' or '2X4' -> '2x4'."""
|
||||
return stock.strip().lower().replace(" ", "").replace("by", "x")
|
||||
|
||||
|
||||
def actual_section(stock: str) -> tuple[float, float]:
|
||||
"""Return the (thickness, width) in inches for a nominal stock name.
|
||||
|
||||
Raises KeyError with the list of known stock if unknown.
|
||||
"""
|
||||
key = normalize_stock(stock)
|
||||
if key not in NOMINAL_TO_ACTUAL:
|
||||
known = ", ".join(sorted(NOMINAL_TO_ACTUAL))
|
||||
raise KeyError(f"Unknown stock {stock!r}. Known stock: {known}")
|
||||
return NOMINAL_TO_ACTUAL[key]
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
"""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 dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
|
||||
from .lumber import actual_section, normalize_stock
|
||||
|
||||
SCENE_VERSION = 1
|
||||
|
||||
|
||||
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()
|
||||
base = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser()
|
||||
return base / "woodshop" / "scene.json"
|
||||
|
||||
|
||||
@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])
|
||||
rotation_deg: float = 0.0 # about Z, in the XY plane
|
||||
finishes: list[str] = field(default_factory=list)
|
||||
|
||||
def axis_unit(self) -> tuple[float, float]:
|
||||
a = math.radians(self.rotation_deg)
|
||||
return (math.cos(a), math.sin(a))
|
||||
|
||||
def end_point(self) -> list[float]:
|
||||
"""The far end (end_b) of the board in world space."""
|
||||
ux, uy = self.axis_unit()
|
||||
return [
|
||||
self.position_in[0] + ux * self.length_in,
|
||||
self.position_in[1] + uy * self.length_in,
|
||||
self.position_in[2],
|
||||
]
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
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)
|
||||
selection: str | None = None
|
||||
_next_part: int = 1
|
||||
_next_joint: int = 1
|
||||
_undo: 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:
|
||||
return p
|
||||
raise SceneError(f"No part {ref!r}. Parts: {[p.id for p in self.parts] or 'none'}")
|
||||
|
||||
def resolve(self, ref: str | None) -> Part:
|
||||
"""Resolve a part reference, defaulting to the current selection."""
|
||||
if ref in (None, "", "it", "selection", "current"):
|
||||
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 ---------------------------------------------------------
|
||||
def _checkpoint(self) -> None:
|
||||
self._undo.append(json.dumps(self._raw(), sort_keys=True))
|
||||
del self._undo[:-50] # keep the last 50 steps
|
||||
|
||||
def undo(self) -> str:
|
||||
if not self._undo:
|
||||
raise SceneError("Nothing to undo.")
|
||||
snapshot = json.loads(self._undo.pop())
|
||||
restored = Scene.from_dict(snapshot)
|
||||
restored._undo = self._undo
|
||||
self.__dict__.update(restored.__dict__)
|
||||
return "Undid last operation."
|
||||
|
||||
# ----- operations ---------------------------------------------------
|
||||
def place(self, stock: str, length_in: float) -> Part:
|
||||
self._checkpoint()
|
||||
stock = normalize_stock(stock)
|
||||
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)
|
||||
|
||||
# Distance measured along A from its start.
|
||||
along = offset_in if anchor == "end_a" else max(a.length_in - offset_in, 0.0)
|
||||
ux, uy = a.axis_unit()
|
||||
attach = [a.position_in[0] + ux * along,
|
||||
a.position_in[1] + uy * along,
|
||||
a.position_in[2]]
|
||||
b.position_in = attach
|
||||
b.rotation_deg = a.rotation_deg + angle_deg
|
||||
|
||||
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 delete(self, ref: str | None) -> str:
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
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)]
|
||||
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)
|
||||
return d
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Scene":
|
||||
parts = [Part(**{**p, "section_in": tuple(p["section_in"])})
|
||||
for p in data.get("parts", [])]
|
||||
joints = [Joint(**j) for j in data.get("joints", [])]
|
||||
return cls(
|
||||
version=data.get("version", SCENE_VERSION),
|
||||
units=data.get("units", "inch"),
|
||||
parts=parts,
|
||||
joints=joints,
|
||||
selection=data.get("selection"),
|
||||
_next_part=data.get("_next_part", len(parts) + 1),
|
||||
_next_joint=data.get("_next_joint", len(joints) + 1),
|
||||
_undo=data.get("_undo", []),
|
||||
)
|
||||
|
||||
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()))
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"""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"
|
||||
_COMBINED = re.compile(
|
||||
rf"^\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)
|
||||
return ft * 12.0 + inch
|
||||
|
||||
raise ValueError(f"Could not parse length: {value!r}")
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"""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 _part_mesh(part: Part):
|
||||
import pyvista as pv
|
||||
|
||||
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_z(part.rotation_deg, point=(0, 0, 0), inplace=True)
|
||||
cube.translate(part.position_in, inplace=True)
|
||||
return cube
|
||||
|
||||
|
||||
def _render(plotter, scene: Scene) -> None:
|
||||
plotter.clear()
|
||||
for i, part in enumerate(scene.parts):
|
||||
edge = part.id == scene.selection
|
||||
plotter.add_mesh(
|
||||
_part_mesh(part),
|
||||
color=_PALETTE[i % len(_PALETTE)],
|
||||
show_edges=True,
|
||||
line_width=3 if edge else 1,
|
||||
edge_color="yellow" if edge else "black",
|
||||
)
|
||||
n = len(scene.parts)
|
||||
plotter.add_text(f"{n} part(s) | selection: {scene.selection or '-'}",
|
||||
font_size=10, name="hud")
|
||||
if n:
|
||||
plotter.add_axes()
|
||||
|
||||
|
||||
def run(scene_path: Path | None = None, poll_s: float = 0.3) -> None:
|
||||
import pyvista as pv
|
||||
|
||||
scene_path = Path(scene_path) if scene_path else default_scene_path()
|
||||
plotter = pv.Plotter(title="WoodShop")
|
||||
plotter.set_background("#2b2b2b")
|
||||
|
||||
last_mtime = -1.0
|
||||
scene = Scene.load(scene_path) if scene_path.exists() else Scene()
|
||||
_render(plotter, scene)
|
||||
plotter.show(interactive_update=True, auto_close=False)
|
||||
|
||||
while True:
|
||||
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 closed
|
||||
break
|
||||
|
||||
|
||||
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())
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"""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)
|
||||
assert p2.rotation_deg == pytest.approx(90.0)
|
||||
# p2 now runs along +Y
|
||||
ux, uy = p2.axis_unit()
|
||||
assert ux == pytest.approx(0.0, abs=1e-9)
|
||||
assert uy == pytest.approx(1.0)
|
||||
assert len(s.joints) == 1
|
||||
|
||||
|
||||
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