Add auto-assembly (Make connection) + feature rotation
"Make connection" checkbox in the Fit dialog moves/orients the other board so its tenon seats into the mortise (faces meet, insertion axes aligned, cross-axes matched): - scene.connect(anchor, moving): builds the moving feature's desired world frame from Part.feature_world_frame, solves R = [dN|dU|dV]·[n|u|v]^T, decomposes to yaw/tilt/roll via matrix_to_ypr (inverse of local_frame's Rz·Ry(-tilt)·Rx(roll)), and positions so the contact points coincide. Verified: tenon-board stands and seats into a top mortise; Euler round-trip exact. - Feature.rotation_deg: spin a feature about its face normal (geometry rotates the cut/add solid; preview + connect honor it) so cross-sections line up. - Shared face_frame/rotation math moved to scene.py (geometry imports it). - CLI `connect`, `--rotation` on features; voice `wood-connect`; GUI rotation field + "Make connection" checkbox. 22 wood-* tools. 79 tests pass (ypr round-trip, connect seats tenon, rotated feature cuts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f829a2c50
commit
e35020382d
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -111,8 +111,16 @@ pytest # 25 tests
|
|||
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`). Not yet:
|
||||
countersinks, and click-a-face-to-place in the GUI.
|
||||
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`. Not
|
||||
yet: countersinks, click-a-face-to-place.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -175,16 +175,26 @@ TOOLS = {
|
|||
{"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'
|
||||
' ("--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-feature-delete": {
|
||||
"description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.",
|
||||
"arguments": [
|
||||
|
|
|
|||
|
|
@ -148,12 +148,17 @@ 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))
|
||||
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}."
|
||||
|
||||
|
|
@ -163,10 +168,15 @@ def cmd_feature_edit(scene: Scene, args) -> str:
|
|||
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))
|
||||
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_feature_delete(scene: Scene, args) -> str:
|
||||
return scene.delete_feature(args.fid)
|
||||
|
||||
|
|
@ -312,6 +322,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
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")
|
||||
|
|
@ -332,6 +343,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
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)
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -12,20 +12,7 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
from .scene import Feature, Part, Scene
|
||||
|
||||
|
||||
# Each face of the LOCAL board (X in [0,L], Y in [-w/2,w/2], Z in [-t/2,t/2]):
|
||||
# (origin on the face, outward normal, in-plane u axis, in-plane v axis).
|
||||
def _face_frame(face: str, L: float, w: float, t: float):
|
||||
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]
|
||||
from .scene import Feature, Part, Scene, face_frame as _face_frame
|
||||
|
||||
|
||||
def _orient_z_to(solid, n):
|
||||
|
|
@ -62,10 +49,13 @@ def _feature_solid_local(feat: Feature, L: float, w: float, t: float):
|
|||
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])
|
||||
box = Box(sx, sy, sz)
|
||||
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))
|
||||
return Pos(*c) * box, (feat.kind != "tenon")
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -49,8 +49,10 @@ TOOL_CMD = {
|
|||
"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")))),
|
||||
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-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"])),
|
||||
|
|
@ -285,6 +287,18 @@ class Controller(QObject):
|
|||
self.scene.edit_feature(feat.id, **dims)
|
||||
self._commit(f"Fitted {feat.id} to {target_fid}.")
|
||||
|
||||
def make_connection(self, target_fid: str) -> None:
|
||||
"""Move/orient the target's board so its feature seats into the active one."""
|
||||
feat = self.active_feature_obj()
|
||||
if not feat:
|
||||
return
|
||||
try:
|
||||
msg = self.scene.connect(feat.id, target_fid)
|
||||
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."""
|
||||
|
|
@ -314,7 +328,7 @@ class Controller(QObject):
|
|||
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)
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ 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 (QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox,
|
||||
QFormLayout, QGridLayout, QHBoxLayout, QLabel,
|
||||
QListWidget, QListWidgetItem, QMessageBox,
|
||||
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
||||
QDoubleSpinBox, QFormLayout, QGridLayout, QHBoxLayout,
|
||||
QLabel, QListWidget, QListWidgetItem, QMessageBox,
|
||||
QPushButton, QVBoxLayout, QWidget)
|
||||
|
||||
from ..scene import FACES
|
||||
|
|
@ -51,10 +51,15 @@ class FeaturePanel(QWidget):
|
|||
("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(); sp.setRange(-48, 96); sp.setSingleStep(0.25); sp.setSuffix(" in")
|
||||
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
|
||||
|
|
@ -156,12 +161,17 @@ class FeaturePanel(QWidget):
|
|||
lst.addItem(item)
|
||||
lst.setCurrentRow(0)
|
||||
lay.addWidget(lst)
|
||||
connect_cb = QCheckBox("Make connection (move && orient the other board to seat the joint)")
|
||||
lay.addWidget(connect_cb)
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
bb.accepted.connect(dlg.accept)
|
||||
bb.rejected.connect(dlg.reject)
|
||||
lay.addWidget(bb)
|
||||
if dlg.exec() and lst.currentItem():
|
||||
self.c.fit_feature(lst.currentItem().data(Qt.UserRole))
|
||||
target = lst.currentItem().data(Qt.UserRole)
|
||||
self.c.fit_feature(target) # size to match
|
||||
if connect_cb.isChecked():
|
||||
self.c.make_connection(target) # then assemble
|
||||
|
||||
|
||||
_HINTS = {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,43 @@ def _rot_z(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"
|
||||
|
||||
|
|
@ -115,6 +152,7 @@ class Feature:
|
|||
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:
|
||||
|
|
@ -153,6 +191,27 @@ class Part:
|
|||
"""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 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
|
||||
|
|
@ -423,7 +482,8 @@ class Scene:
|
|||
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"}
|
||||
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)
|
||||
|
|
@ -457,6 +517,41 @@ class Scene:
|
|||
self.selection = part.id
|
||||
return f"Deleted feature {fid} from {part.id}."
|
||||
|
||||
def connect(self, anchor_fid: str, moving_fid: str) -> str:
|
||||
"""Move/orient the board owning ``moving_fid`` so its feature mates with
|
||||
``anchor_fid`` (tenon seated into mortise: faces meet, axes aligned)."""
|
||||
anchor_part, _ = 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.")
|
||||
self._checkpoint()
|
||||
|
||||
pa, na, ua, va = anchor_part.feature_world_frame(self.find_feature(anchor_fid)[1])
|
||||
|
||||
# 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)]
|
||||
self.selection = moving_part.id
|
||||
return f"Connected {moving_part.id} to {anchor_part.id}."
|
||||
|
||||
def delete(self, ref: str | None) -> str:
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ def feature_preview_mesh(part, feat):
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,15 @@ def test_oversized_chamfer_falls_back():
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -226,6 +226,37 @@ def test_feature_roundtrip(tmp_path):
|
|||
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 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_clear():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
|
|
|
|||
Loading…
Reference in New Issue