diff --git a/scripts/gen_wood_tools.py b/scripts/gen_wood_tools.py index 63c53e5..54f4cf0 100644 --- a/scripts/gen_wood_tools.py +++ b/scripts/gen_wood_tools.py @@ -193,6 +193,7 @@ TOOLS = { {"flag": "--rotation", "variable": "rotation", "default": "", "description": "Rotate the feature about its face normal, degrees"}, {"flag": "--miter", "variable": "miter", "default": "", "description": "kind=miter: angle across the width in degrees (45 for a frame corner)"}, {"flag": "--bevel", "variable": "bevel", "default": "", "description": "kind=miter: angle through the thickness in degrees (tilted blade)"}, + {"flag": "--miter-pivot", "variable": "miter_pivot", "default": "", "description": "kind=miter: 'edge' (full-width, default) or 'center' (corner notch — add a +angle and a -angle from center to bring an end to a point, e.g. a picket)"}, ], "code": code( 'cmd = ws + ["feature", kind]\n' @@ -200,7 +201,8 @@ TOOLS = { '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), ("--miter", miter), ("--bevel", bevel)]:\n' + ' ("--rotation", rotation), ("--miter", miter), ("--bevel", bevel),\n' + ' ("--miter-pivot", miter_pivot)]:\n' ' if val != "": cmd += [flag, str(val)]' ), }, diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py index 23ba91e..90ace31 100644 --- a/src/woodshop/cli.py +++ b/src/woodshop/cli.py @@ -177,11 +177,17 @@ def cmd_feature(scene: Scene, args) -> str: depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter), rotation_deg=_optdeg(args.rotation), miter_deg=_optdeg(getattr(args, "miter", None)), - bevel_deg=_optdeg(getattr(args, "bevel", None))) + bevel_deg=_optdeg(getattr(args, "bevel", None)), + from_center=_miter_pivot(getattr(args, "miter_pivot", None))) part = scene.find_feature(feat.id)[0] return f"Added {feat.kind} ({feat.id}) to {part.id} on {feat.face}." +def _miter_pivot(value): + """'center'/'edge' -> bool from_center, or None to leave unchanged.""" + return None if not value else (str(value).lower().startswith("c")) + + def cmd_feature_edit(scene: Scene, args) -> str: feat = scene.edit_feature( args.fid, face=args.face, @@ -190,7 +196,8 @@ def cmd_feature_edit(scene: Scene, args) -> str: depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter), rotation_deg=_optdeg(args.rotation), miter_deg=_optdeg(getattr(args, "miter", None)), - bevel_deg=_optdeg(getattr(args, "bevel", None))) + bevel_deg=_optdeg(getattr(args, "bevel", None)), + from_center=_miter_pivot(getattr(args, "miter_pivot", None))) return f"Updated feature {feat.id}." @@ -392,6 +399,9 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--rotation", help="rotate the feature about its face normal (deg)") parser.add_argument("--miter", help="miter angle across the width, deg (kind=miter)") parser.add_argument("--bevel", help="bevel angle through the thickness, deg (kind=miter)") + parser.add_argument("--miter-pivot", dest="miter_pivot", choices=["edge", "center"], + help="miter pivot: 'edge' (full-width cut, default) or 'center' " + "(corner notch — two make a point)") sp = sub.add_parser("feature", help="Add a joinery feature (tenon/mortise/hole/slot/miter)") sp.add_argument("kind", help="tenon | mortise | hole | slot | dado | rabbet | chamfer | miter") diff --git a/src/woodshop/geometry.py b/src/woodshop/geometry.py index dbb1a45..d49aee2 100644 --- a/src/woodshop/geometry.py +++ b/src/woodshop/geometry.py @@ -101,10 +101,12 @@ def miter_cutter(feat: Feature, L: float, w: float, t: float): else: # end_b (default) block = Pos(L + big / 2, 0, 0) * Box(big, big, big) # covers x > L endx = L - # pivot at the edge that stays full length (opposite the angle's sign) - py = (-w / 2 if feat.miter_deg >= 0 else w / 2) if feat.miter_deg else 0.0 - pz = (-t / 2 if feat.bevel_deg >= 0 else t / 2) if feat.bevel_deg else 0.0 - pivot = (endx, py, pz) + if getattr(feat, "from_center", False): + pivot = (endx, 0.0, 0.0) # pivot at the end CENTRE -> a corner notch + else: # pivot at the edge that stays full length + py = (-w / 2 if feat.miter_deg >= 0 else w / 2) if feat.miter_deg else 0.0 + pz = (-t / 2 if feat.bevel_deg >= 0 else t / 2) if feat.bevel_deg else 0.0 + pivot = (endx, py, pz) return (Pos(*pivot) * Rot(Z=feat.miter_deg, Y=feat.bevel_deg) * Pos(*[-c for c in pivot]) * block) diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index 4c7e305..de9355e 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -52,7 +52,8 @@ TOOL_CMD = { 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")), miter=_opt(a.get("miter")), bevel=_opt(a.get("bevel")))), + rotation=_opt(a.get("rotation")), miter=_opt(a.get("miter")), bevel=_opt(a.get("bevel")), + miter_pivot=_opt(a.get("miter_pivot")))), "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"])), @@ -393,7 +394,8 @@ class Controller(QObject): 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, - miter_deg=pending.miter_deg, bevel_deg=pending.bevel_deg) + miter_deg=pending.miter_deg, bevel_deg=pending.bevel_deg, + from_center=pending.from_center) self._commit() # re-tessellates with the new geometry self.preview_changed.emit() # clear the ghost diff --git a/src/woodshop/gui/feature_panel.py b/src/woodshop/gui/feature_panel.py index c21cec4..114450c 100644 --- a/src/woodshop/gui/feature_panel.py +++ b/src/woodshop/gui/feature_panel.py @@ -87,6 +87,11 @@ class FeaturePanel(QWidget): self._spins[key] = sp form.addRow(label, sp) form.insertRow(0, "Face", self.face) + self.center_cb = QCheckBox("From centre (corner notch — two make a point)") + self.center_cb.setToolTip("Pivot the miter at the end's centre so it notches a corner; " + "add a +angle and a −angle to bring the end to a point (picket)") + self.center_cb.toggled.connect(self._preview) + form.addRow("", self.center_cb) root.addLayout(form) self.fit_btn = QPushButton("Fit to mate…") @@ -163,10 +168,13 @@ class FeaturePanel(QWidget): for key, sp in self._spins.items(): sp.setValue(getattr(feat, key)) self._set_row_visible(sp, key in show) + self.center_cb.setChecked(getattr(feat, "from_center", False)) + self._set_row_visible(self.center_cb, feat.kind == "miter") else: self.face.setEnabled(False) for sp in self._spins.values(): self._set_row_visible(sp, True) + self._set_row_visible(self.center_cb, False) 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 "") @@ -201,6 +209,7 @@ class FeaturePanel(QWidget): if self._loading or not self.c.active_feature: return dims = {key: sp.value() for key, sp in self._spins.items()} + dims["from_center"] = self.center_cb.isChecked() self.c.set_preview(face=self.face.currentText(), **dims) def _fit(self) -> None: diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 2aa70d4..f314ed4 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -170,6 +170,7 @@ class Feature: rotation_deg: float = 0.0 # rotation of the feature about its face normal miter_deg: float = 0.0 # 'miter' end cut: angle across the width (0 = square) bevel_deg: float = 0.0 # 'miter' end cut: angle through the thickness + from_center: bool = False # miter pivots at the end CENTRE (corner notch) vs an edge @property def is_cut(self) -> bool: @@ -610,6 +611,8 @@ class Scene: "diameter_in", "rotation_deg", "miter_deg", "bevel_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}) + if dims.get("from_center") is not None: + feat.from_center = bool(dims["from_center"]) if kind == "miter" and feat.miter_deg == 0.0 and feat.bevel_deg == 0.0: feat.miter_deg = 45.0 # a sensible default a user can edit part.features.append(feat) @@ -633,6 +636,8 @@ class Scene: if feat.kind in END_KINDS and v not in END_FACES: continue # a miter only lives on an end feat.face = v + elif k == "from_center": + feat.from_center = bool(v) elif hasattr(feat, k): setattr(feat, k, float(v)) self.selection = part.id diff --git a/src/woodshop/viewer.py b/src/woodshop/viewer.py index 7c14ce7..a3bd0d4 100644 --- a/src/woodshop/viewer.py +++ b/src/woodshop/viewer.py @@ -130,7 +130,10 @@ def feature_preview_mesh(part, feat): 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)) - elif feat.kind == "chamfer": # can't cheaply preview the bevel — highlight the face + elif feat.kind == "chamfer": # show the bevel slivers it removes; else the face + removed = _chamfer_removed_mesh(part, feat) + if removed is not None: + return removed 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 @@ -156,6 +159,30 @@ def feature_preview_mesh(part, feat): return mesh +def _chamfer_removed_mesh(part: Part, feat): + """The bevel slivers a chamfer removes (board − chamfered board), placed in + world space — so the preview/highlight shows the real bevel, not a face slab. + None if the chamfer is a no-op (e.g. over-sized and skipped).""" + try: + from build123d import Box, Pos + + from .geometry import _apply_chamfer + L = part.length_in + t, w = part.section_in + box = Pos(L / 2, 0, 0) * Box(L, w, t) + removed = box - _apply_chamfer(box, feat, L, w, t) + mesh = _solid_to_polydata(removed) + if mesh.n_points == 0: + return None + except Exception: + return None + 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.""" diff --git a/tests/test_miter.py b/tests/test_miter.py index 93aa4e5..9a5a90f 100644 --- a/tests/test_miter.py +++ b/tests/test_miter.py @@ -108,3 +108,52 @@ def test_edit_feature_keeps_miter_on_an_end(): f = s.add_feature("p1", "miter", miter_deg=45) s.edit_feature(f.id, face="top") # invalid for a miter — should be ignored assert f.face in ("end_a", "end_b") + + +def test_center_miter_notches_a_corner(): + """A from-center 45° miter removes a corner triangle (~half the width), + unlike the full-width edge cut.""" + pytest.importorskip("build123d") + from woodshop.geometry import miter_cutter + from build123d import Box, Pos + s = Scene() + s.place("2x4", 24) + t, w = s.get_part("p1").section_in + f = s.add_feature("p1", "miter", miter_deg=45, from_center=True) + assert f.from_center is True + board = Pos(12, 0, 0) * Box(24, w, t) + wedge = board & miter_cutter(f, 24, w, t) + b = wedge.bounding_box() + assert (b.max.Y - b.min.Y) < 0.75 * w # only a corner, not full width + + +def test_two_center_miters_make_a_point(): + """+45 and −45 from centre on the same end bring it to a point (picket).""" + pytest.importorskip("build123d") + from woodshop.geometry import part_solid + s = Scene() + s.place("2x4", 24) + full = part_solid(s.get_part("p1")).volume + s.add_feature("p1", "miter", miter_deg=45, from_center=True) + s.add_feature("p1", "miter", miter_deg=-45, from_center=True) + pointed = part_solid(s.get_part("p1")).volume + assert pointed < full # both corners gone -> a point + + +def test_from_center_roundtrips(): + s = Scene() + s.place("2x4", 24) + s.add_feature("p1", "miter", miter_deg=45, from_center=True) + s2 = Scene.from_dict(json.loads(json.dumps(s.to_dict()))) + assert s2.get_part("p1").features[0].from_center is True + + +def test_chamfer_preview_shows_removed_bevel(): + pytest.importorskip("pyvista") + pytest.importorskip("build123d") + from woodshop.viewer import feature_preview_mesh + s = Scene() + s.place("2x4", 24) + f = s.add_feature("p1", "chamfer", face="top", width_in=0.25) + mesh = feature_preview_mesh(s.get_part("p1"), f) + assert mesh is not None and mesh.n_points > 0 # the bevel slivers, not a slab