Miter: adjustable hinge offset instead of a center toggle
Replaces the from_center boolean with miter_offset_in / bevel_offset_in: how far to move the cut's hinge IN from the edge. 0 = full edge-to-edge cut; half the board size = the centre (corner notch); two centred cuts (+angle/−angle) make a point (picket); intermediate/over values give asymmetric & partial cuts — much more range than the old edge/centre toggle. - Feature.miter_offset_in/bevel_offset_in (replaces from_center); geometry pivot = edge + inward·offset (per width/thickness); serialization additive. - Joinery panel: Miter offset / Bevel offset spin fields (miter only), checkbox removed. CLI feature --miter-offset/--bevel-offset; wood-feature tool args (regenerated); controller passthrough; apply_preview carries the offsets. - tests updated: offset=half-width notches a corner, two make a point, offset roundtrips. 247 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f1eb7e8c29
commit
8019aac299
|
|
@ -193,7 +193,8 @@ 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)"},
|
||||
{"flag": "--miter-offset", "variable": "miter_offset", "default": "", "description": "kind=miter: move the cut's hinge in from the edge, e.g. '1.75 in' (0=full edge cut; half the board width=centre; add a +angle and a -angle both at half-width to bring an end to a point, e.g. a picket)"},
|
||||
{"flag": "--bevel-offset", "variable": "bevel_offset", "default": "", "description": "kind=miter: move the bevel's hinge in from the edge (0=full cut)"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = ws + ["feature", kind]\n'
|
||||
|
|
@ -202,7 +203,7 @@ TOOLS = {
|
|||
'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'
|
||||
' ("--miter-pivot", miter_pivot)]:\n'
|
||||
' ("--miter-offset", miter_offset), ("--bevel-offset", bevel_offset)]:\n'
|
||||
' if val != "": cmd += [flag, str(val)]'
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -178,16 +178,12 @@ def cmd_feature(scene: Scene, args) -> str:
|
|||
rotation_deg=_optdeg(args.rotation),
|
||||
miter_deg=_optdeg(getattr(args, "miter", None)),
|
||||
bevel_deg=_optdeg(getattr(args, "bevel", None)),
|
||||
from_center=_miter_pivot(getattr(args, "miter_pivot", None)))
|
||||
miter_offset_in=_optlen(getattr(args, "miter_offset", None)),
|
||||
bevel_offset_in=_optlen(getattr(args, "bevel_offset", 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,
|
||||
|
|
@ -197,7 +193,8 @@ def cmd_feature_edit(scene: Scene, args) -> str:
|
|||
rotation_deg=_optdeg(args.rotation),
|
||||
miter_deg=_optdeg(getattr(args, "miter", None)),
|
||||
bevel_deg=_optdeg(getattr(args, "bevel", None)),
|
||||
from_center=_miter_pivot(getattr(args, "miter_pivot", None)))
|
||||
miter_offset_in=_optlen(getattr(args, "miter_offset", None)),
|
||||
bevel_offset_in=_optlen(getattr(args, "bevel_offset", None)))
|
||||
return f"Updated feature {feat.id}."
|
||||
|
||||
|
||||
|
|
@ -399,9 +396,11 @@ 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)")
|
||||
parser.add_argument("--miter-offset", dest="miter_offset",
|
||||
help="move the miter hinge in from the edge, e.g. '1.75 in' "
|
||||
"(0=full cut, half the width=centre; two centred make a point)")
|
||||
parser.add_argument("--bevel-offset", dest="bevel_offset",
|
||||
help="move the bevel hinge in from the edge (0=full cut)")
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -101,12 +101,18 @@ 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
|
||||
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)
|
||||
# The cut hinges at an edge by default; an offset moves that hinge inward
|
||||
# across the width/thickness (offset = half the size -> the centre; two such
|
||||
# cuts bring an end to a point). 0 -> full edge-to-edge cut.
|
||||
def _pivot(angle, offset, half):
|
||||
if not angle:
|
||||
return 0.0
|
||||
edge = -half if angle >= 0 else half # the edge that stays full length
|
||||
toward = 1 if angle >= 0 else -1 # inward direction from that edge
|
||||
return edge + toward * offset
|
||||
pivot = (endx,
|
||||
_pivot(feat.miter_deg, getattr(feat, "miter_offset_in", 0.0), w / 2),
|
||||
_pivot(feat.bevel_deg, getattr(feat, "bevel_offset_in", 0.0), t / 2))
|
||||
return (Pos(*pivot) * Rot(Z=feat.miter_deg, Y=feat.bevel_deg)
|
||||
* Pos(*[-c for c in pivot]) * block)
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ TOOL_CMD = {
|
|||
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")),
|
||||
miter_pivot=_opt(a.get("miter_pivot")))),
|
||||
miter_offset=_opt(a.get("miter_offset")), bevel_offset=_opt(a.get("bevel_offset")))),
|
||||
"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"])),
|
||||
|
|
@ -395,7 +395,7 @@ class Controller(QObject):
|
|||
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,
|
||||
from_center=pending.from_center)
|
||||
miter_offset_in=pending.miter_offset_in, bevel_offset_in=pending.bevel_offset_in)
|
||||
self._commit() # re-tessellates with the new geometry
|
||||
self.preview_changed.emit() # clear the ghost
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ KIND_FIELDS = {
|
|||
"mortise": _BOX, "slot": _BOX, "dado": _BOX, "rabbet": _BOX,
|
||||
"hole": ["along_in", "across_in", "diameter_in", "depth_in"],
|
||||
"chamfer": ["width_in"],
|
||||
"miter": ["miter_deg", "bevel_deg"],
|
||||
"miter": ["miter_deg", "bevel_deg", "miter_offset_in", "bevel_offset_in"],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -72,6 +72,10 @@ class FeaturePanel(QWidget):
|
|||
("rotation_deg", "Rotate", "Spin the feature about its face normal to line up the cross-section"),
|
||||
("miter_deg", "Miter", "Miter end cut: angle across the width (0 = square end)"),
|
||||
("bevel_deg", "Bevel", "Miter end cut: angle through the thickness (tilted blade)"),
|
||||
("miter_offset_in", "Miter offset", "Move the miter's hinge in from the edge "
|
||||
"(0 = full cut; half the width = centre; two centred cuts make a point)"),
|
||||
("bevel_offset_in", "Bevel offset", "Move the bevel's hinge in from the edge "
|
||||
"(0 = full cut; half the thickness = centre)"),
|
||||
]
|
||||
self._spins = {}
|
||||
for key, label, tip in self._fields:
|
||||
|
|
@ -87,11 +91,6 @@ 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…")
|
||||
|
|
@ -168,13 +167,10 @@ 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 "")
|
||||
|
|
@ -209,7 +205,6 @@ 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:
|
||||
|
|
@ -270,8 +265,9 @@ _HINTS = {
|
|||
"Depth = how deep.",
|
||||
"chamfer": "Chamfer — bevels the edges around the chosen Face; Width = bevel size "
|
||||
"(the red preview highlights the face).",
|
||||
"miter": "Miter — angles the chosen END across the FULL width. Miter = angle "
|
||||
"across the width (45° = frame corner); Bevel = angle through the "
|
||||
"thickness (tilted blade); both = compound. The angle's sign flips which "
|
||||
"way it leans. (Other fields don't apply to a miter.)",
|
||||
"miter": "Miter — angles the chosen END. Miter = angle across the width (45° = "
|
||||
"frame corner); Bevel = through the thickness; both = compound. Offsets "
|
||||
"move the hinge in from the edge: 0 = full cut, half the size = centre — "
|
||||
"set the miter offset to half the width and add a +angle and a −angle to "
|
||||
"bring the end to a point (picket). Sign flips the lean.",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,7 +170,8 @@ 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
|
||||
miter_offset_in: float = 0.0 # move the cut's hinge in from the edge across the width
|
||||
bevel_offset_in: float = 0.0 # ... and through the thickness (0 = full edge cut)
|
||||
|
||||
@property
|
||||
def is_cut(self) -> bool:
|
||||
|
|
@ -608,11 +609,10 @@ class Scene:
|
|||
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", "miter_deg", "bevel_deg"}
|
||||
"diameter_in", "rotation_deg", "miter_deg", "bevel_deg",
|
||||
"miter_offset_in", "bevel_offset_in"}
|
||||
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)
|
||||
|
|
@ -636,8 +636,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -110,42 +110,42 @@ def test_edit_feature_keeps_miter_on_an_end():
|
|||
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."""
|
||||
def test_offset_to_centre_notches_a_corner():
|
||||
"""A 45° miter hinged at the CENTRE (offset = half width) removes only a
|
||||
corner, unlike the full-width edge cut (offset 0)."""
|
||||
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
|
||||
f = s.add_feature("p1", "miter", miter_deg=45, miter_offset_in=w / 2)
|
||||
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)."""
|
||||
def test_two_centre_offset_miters_make_a_point():
|
||||
"""+45 and −45 hinged at centre on the same end bring it to a point."""
|
||||
pytest.importorskip("build123d")
|
||||
from woodshop.geometry import part_solid
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
half = s.get_part("p1").section_in[1] / 2
|
||||
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)
|
||||
s.add_feature("p1", "miter", miter_deg=45, miter_offset_in=half)
|
||||
s.add_feature("p1", "miter", miter_deg=-45, miter_offset_in=half)
|
||||
pointed = part_solid(s.get_part("p1")).volume
|
||||
assert pointed < full # both corners gone -> a point
|
||||
|
||||
|
||||
def test_from_center_roundtrips():
|
||||
def test_offset_roundtrips():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.add_feature("p1", "miter", miter_deg=45, from_center=True)
|
||||
s.add_feature("p1", "miter", miter_deg=45, miter_offset_in=1.75)
|
||||
s2 = Scene.from_dict(json.loads(json.dumps(s.to_dict())))
|
||||
assert s2.get_part("p1").features[0].from_center is True
|
||||
assert s2.get_part("p1").features[0].miter_offset_in == 1.75
|
||||
|
||||
|
||||
def test_chamfer_preview_shows_removed_bevel():
|
||||
|
|
|
|||
Loading…
Reference in New Issue