Source code for pybosl2.masking

# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause

# LibFile: pybosl2/masking.py
#    Pure-Python port of BOSL2's edge/corner/face profile masking system.
#    Cuts rounded/chamfered profiles along selected edges, corners, or whole
#    faces of a cuboid.  Only cuboid parents are supported; edge and corner
#    positioning is derived from first principles using the same offset vectors
#    as cuboid()'s own rounding.
#
# FileSummary: Masking
# DocCategory: Foundational
# FileGroup: BOSL2

"""Cut rounded edge/corner/face profiles into a cuboid (BOSL2 masks2d/masks3d)."""

from __future__ import annotations

import math
from typing import TYPE_CHECKING

import numpy as np

from pybosl2._edges_lang import CORNER_OFFSETS, Anchor, EdgeAtom, _is_plain_vector
from pybosl2._native import native
from pybosl2.points import Point

if TYPE_CHECKING:
    from pybosl2._backend import Solid
    from pybosl2.path2d import Path2D
    from pybosl2.shapes3d.base import Bosl2Solid

from pybosl2._helpers import frag_count as _frag_count
from pybosl2._helpers import pick_radius as _pick_radius
from pybosl2._helpers import polar_to_xy as _polar_to_xy
from pybosl2._helpers import quantup
from pybosl2.exceptions import Bosl2ValueError

from ._edges_lang import EDGE_OFFSETS
from ._edges_lang import edges as resolve_edges
from .constants import CENTER
from .shapes3d.base import _anchor_offset_box3

__all__ = [
    "Mask2D",
    "Mask3D",
    "chamfer_edge_mask",
    "corner_profile",
    "edge_mask",
    "edge_profile",
    "face_profile",
    "mask2d_chamfer",
    "mask2d_cove",
    "mask2d_groove",
    "mask2d_roundover",
    "mask2d_step",
    "mask2d_tear",
    "mask3d_chamfer",
    "mask3d_groove",
    "mask3d_roundover",
    "rounding_edge_mask",
]

_ocube = native("cube")
_opolygon = native("polygon")
_osphere = native("sphere")


[docs] def rounding_edge_mask( length: float | None = None, radius: float | None = None, radius1: float | None = None, radius2: float | None = None, diameter: float | None = None, diameter1: float | None = None, diameter2: float | None = None, height: float | None = None, excess: float = 0.1, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Bosl2Solid": """Return a standalone 3-D edge-rounding cutter of length *length*, for manual positioning. Args: length: Length of the cutter along its axis (default 1). height: Length of the cutter along its axis (default 1). radius: Rounding radius (both ends). radius1: Rounding radius at the first end, for a tapered cutter. radius2: Rounding radius at the second end, for a tapered cutter. diameter: Rounding diameter (both ends). diameter1: Rounding diameter at the first end. diameter2: Rounding diameter at the second end. excess: Amount the flat sides extend past the origin (default 0.1). fn: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fs=...)`` value applies. Returns: A :class:`~pybosl2.shapes3d.Bosl2Solid` cutter. """ length = length if length is not None else (height if height is not None else 1.0) rad1 = _pick_radius( radius1=radius1, diameter1=diameter1, radius=radius, diameter=diameter, dflt=1.0, ) rad2 = _pick_radius( radius2=radius2, diameter2=diameter2, radius=radius, diameter=diameter, dflt=1.0, ) from pybosl2.shapes3d import Bosl2Solid # Wrapped, for the same reason _extrude_mask_along_edge() wraps: the native polygon()/ # linear_extrude() pair hands back a bare native handle, which has no bounds() and no backend # tag, and will not compose with a Bosl2Solid on the right of a boolean. if rad1 < rad2: cross = mask2d_roundover(rad2, excess=excess, fn=fn, fa=fa, fs=fs) shape = Bosl2Solid(_opolygon(cross).linear_extrude(height=length, center=True, scale=rad1 / rad2)) return shape.rotate(180, [1, 0, 0]) cross = mask2d_roundover(rad1, excess=excess, fn=fn, fa=fa, fs=fs) scale = rad2 / rad1 if rad1 else 1.0 return Bosl2Solid(_opolygon(cross).linear_extrude(height=length, center=True, scale=scale))
[docs] def chamfer_edge_mask(length: float = 1.0, chamfer: float = 1.0, excess: float = 0.1) -> "Solid": """Return a standalone 3-D edge-chamfer cutter of length *length*, on the active backend. A diamond bar centred on its own Z axis: a square prism of side ``chamfer * sqrt(2)``, turned 45 degrees, so it reaches *chamfer* along each axis. It is built as a turned prism rather than an extruded diamond polygon so that it works on either backend -- ``polygon().linear_extrude()`` is a CSG-only construction, and this cutter is what `cubetruss` and `tripod_mounts` chamfer with, so it was the thing keeping them CSG-only (TASKS T14). The two forms were checked to give the same solid and the same cut. Args: length: Length of the cutter along its axis (default 1). chamfer: Chamfer size (the diamond's half-diagonal along each axis, default 1). excess: Extra length past *length* so the cut clears the surface (default 0.1). Returns: The cutter, built by whichever backend is active. """ from pybosl2.solid import cuboid side = chamfer * math.sqrt(2) return cuboid([side, side, length + excess]).rotate(45, [0, 0, 1])
def _pick_axes(vec: Point) -> tuple[int, int, int, float, float]: """For an edge vector (one axis 0, two axes ±1), return ``(run_axis, a1, a2, s1, s2)``.""" run_axis = next(i for i in range(3) if vec[i] == 0) nz = [i for i in range(3) if vec[i] != 0] a1, a2 = nz return run_axis, a1, a2, float(vec[a1]), float(vec[a2]) def _orient_mask_along_edge( shape: "Solid", size: tuple[float, float, float], vec: Point, ) -> "Solid": """Reorient an already-built edge cutter onto the cuboid edge given by *vec*.""" run_axis, a1, a2, s1, s2 = _pick_axes(vec) lx = [0.0, 0.0, 0.0] lx[a1] = -s1 ly = [0.0, 0.0, 0.0] ly[a2] = -s2 lz = [0.0, 0.0, 0.0] lz[run_axis] = 1.0 m = [ [lx[0], ly[0], lz[0], 0.0], [lx[1], ly[1], lz[1], 0.0], [lx[2], ly[2], lz[2], 0.0], [0.0, 0.0, 0.0, 1.0], ] center = [0.0, 0.0, 0.0] center[a1] = s1 * size[a1] / 2 center[a2] = s2 * size[a2] / 2 return shape.multmatrix(m).translate(center) def _extrude_mask_along_edge( mask_path: "Path2D", length: float, size: tuple[float, float, float], vec: Point, ) -> "Solid": from pybosl2.shapes3d import Bosl2Solid # Wrapped, not the bare native handle the native polygon()/linear_extrude() pair hands back: # edge_profile()'s cutter is combined with corner_profile()'s (a real Bosl2Solid) in # face_profile(), and the native ``|`` rejects a wrapper on its right-hand side. shape = Bosl2Solid(_opolygon(mask_path).linear_extrude(height=length, center=True)) return _orient_mask_along_edge(shape, size, vec)
[docs] def edge_mask( body: "Bosl2Solid", edges: EdgeAtom | list[EdgeAtom] = Anchor.ALL, except_edges: list[EdgeAtom] | None = None, mask: "Solid | None" = None, size: tuple[float, float, float] | None = None, anchor: Anchor | Point = CENTER, center: Point | None = None, return_cutter: bool = False, ) -> "Solid | None": """Cut a 3-D edge cutter along each selected edge of the box-shaped *body*. Args: body: The box solid to cut. edges: Edges to mask — an :class:`EdgePlane`, a string, a vector, or a list thereof (default ``"ALL"``). except_edges: Edges to explicitly not mask. mask: The 3-D edge cutter to apply. size: The box's ``(x, y, z)`` size. anchor: The anchor *body* was built with (default ``CENTER``). center: The box center in body's current frame. return_cutter: If True, returns the generated cutter shape instead of cutting it. """ if not (size is not None): raise Bosl2ValueError("size= (the box's size) must be given") if not (mask is not None): raise Bosl2ValueError("mask= (the edge cutter) must be given") edge_set = resolve_edges(edges, except_edges or []) cutter: "Solid | None" = None for axis in range(3): for i in range(4): if edge_set[axis][i] > 0: piece = _orient_mask_along_edge(mask, size, Point(EDGE_OFFSETS[axis][i])) cutter = piece if cutter is None else (cutter | piece) if cutter is None: return None if return_cutter else body cutter = cutter.translate(list(center) if center is not None else _anchor_offset_box3(size, anchor)) if return_cutter: return cutter return body - cutter
[docs] def edge_profile( body: "Bosl2Solid", edges: EdgeAtom | list[EdgeAtom] = Anchor.ALL, except_edges: list[EdgeAtom] | None = None, mask: "Path2D | None" = None, size: tuple[float, float, float] | None = None, convexity: int = 10, anchor: Anchor | Point = CENTER, center: Point | None = None, return_cutter: bool = False, ) -> "Solid | None": """Cut a 2-D mask profile extruded along each selected edge of the box-shaped *body*. Args: body: The box solid to cut. edges: Edges to mask (default ``"ALL"``). except_edges: Edges to explicitly not mask. mask: The 2-D mask cross-section, as a :class:`~pybosl2.path2d.Path2D`. size: The box's ``(x, y, z)`` size. convexity: Accepted for signature compatibility; unused. anchor: The anchor *body* was built with (default ``CENTER``). center: The box center in body's current frame. return_cutter: If True, returns the generated cutter shape instead of cutting it. """ _ = convexity if not (size is not None): raise Bosl2ValueError("size= (the box's size) must be given") if not (mask is not None): raise Bosl2ValueError("mask= (the 2-D mask path) must be given") edge_set = resolve_edges(edges, except_edges or []) cutter: "Solid | None" = None for axis in range(3): for i in range(4): if edge_set[axis][i] > 0: vec = EDGE_OFFSETS[axis][i] length = size[axis] + 0.1 piece = _extrude_mask_along_edge(mask, length, size, Point(vec)) cutter = piece if cutter is None else (cutter | piece) if cutter is None: return None if return_cutter else body cutter = cutter.translate(list(center) if center is not None else _anchor_offset_box3(size, anchor)) if return_cutter: return cutter return body - cutter
def _corner_set(v: list[int] | Anchor | Point) -> list[int]: """Resolve one corner selector to an 8-long 0/1 corner array. A corner is selected when, on EVERY axis, the selector is either 0 (don't care) or matches that corner's sign -- BOSL2's ``all([for (i=[0:2]) !v[i] || (v[i]==v2[i])])``. Note this is an AND over per-axis ORs: written as a flat ``a or b and c or d`` chain Python's precedence turns it into an OR of the axes instead, which selects every corner that agrees on any one axis (e.g. ``[-1,-1,-1]`` would also pick up ``[1,1,-1]``). """ if isinstance(v, Anchor): return v.to_corner_set() if isinstance(v, str): # pragma: no cover # defensive: _corners(), the only caller, rejects the string form for both its arguments # before it gets here. raise Bosl2ValueError(f"Legacy string corner selection is not allowed: {v!r}") arr = np.asarray(v, dtype=int) return [1 if all(arr[i] == 0 or arr[i] == c[i] for i in range(3)) else 0 for c in CORNER_OFFSETS] def _corners( v: Anchor | list[int] | list[list[int]] | list[Anchor], except_: list | None = None, # type: ignore[type-arg] ) -> list[int]: if except_ is None: except_ = [] if isinstance(v, str): raise Bosl2ValueError(f"Legacy string corner selection is not allowed: {v!r}") if isinstance(except_, str): raise Bosl2ValueError(f"Legacy string corner selection is not allowed: {except_!r}") # Wrap a SINGLE selector; leave a list of selectors alone. This has to use the same test # the edge language uses, not "is v[0] a list": `Anchor.BOTTOM + Anchor.FRONT + Anchor.LEFT` # is a Point, so `[that]` looked like a bare selector here and got wrapped a second time, # and the bare Point looked like a list of three scalar selectors. if isinstance(v, Anchor) or _is_plain_vector(v): v = [v] # type: ignore[assignment] if isinstance(except_, Anchor) or _is_plain_vector(except_): except_ = [except_] summed = [0] * 8 for x in v: cs = _corner_set(x) # type: ignore[arg-type] summed = [summed[i] + cs[i] for i in range(8)] normed = [1 if s > 0 else 0 for s in summed] if not except_: return normed exc = [0] * 8 for x in except_: cs = _corner_set(x) exc = [exc[i] + cs[i] for i in range(8)] return [1 if (normed[i] - (1 if exc[i] > 0 else 0)) > 0 else 0 for i in range(8)] def _corner_cutter( size: tuple[float, float, float], corner_vec: list[float], radius: float, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Bosl2Solid": if radius <= 0: raise Bosl2ValueError(f"corner_profile(): radius/diameter must be positive, got {radius}.") # The cutter is the material a fillet leaves behind: the radius-sided block filling the very # corner, minus the sphere the rounded surface follows. The sphere sits at the *inner* point, # one radius in from the corner along each axis. # # This used to build a 2*radius block and put the sphere on the body's corner instead, which # inverted the cut: subtracting it scooped out the inside of the solid and left the corner # standing. Nothing caught it because the only tests asserted `result is not None`. from pybosl2.shapes3d import cuboid, sphere corner_pt = [size[i] / 2 * corner_vec[i] for i in range(3)] inner_pt = [corner_pt[i] - corner_vec[i] * radius for i in range(3)] block = cuboid([radius, radius, radius]).translate(Point([(corner_pt[i] + inner_pt[i]) / 2 for i in range(3)])) sph = sphere(radius=radius, fn=fn, fa=fa, fs=fs).translate(Point(inner_pt)) return block - sph def _corner_chamfer_cutter( size: tuple[float, float, float], corner_vec: list[float], chamfer: float, ) -> "Bosl2Solid": """Return the material a corner chamfer of size *chamfer* removes from a *size* box. The kept surface is the one ``cuboid(size, chamfer=chamfer)`` produces: each of the three edges meeting at this corner is cut by its own 45 degree plane, and the three planes meet at a point. So the cutter is the corner block intersected with the union of the three edge chamfer bars -- everything inside the block that at least one of the three planes shaves off. """ if chamfer <= 0: raise Bosl2ValueError(f"Mask3D.chamfer(): chamfer must be positive, got {chamfer}.") from pybosl2.shapes3d import cuboid corner_pt = [size[i] / 2 * corner_vec[i] for i in range(3)] inner_pt = [corner_pt[i] - corner_vec[i] * chamfer for i in range(3)] block = cuboid([chamfer, chamfer, chamfer]).translate(Point([(corner_pt[i] + inner_pt[i]) / 2 for i in range(3)])) wedges: "Solid | None" = None for run_axis in range(3): edge_vec = list(corner_vec) edge_vec[run_axis] = 0.0 bar = chamfer_edge_mask(length=size[run_axis], chamfer=chamfer) bar = _orient_mask_along_edge(bar, size, Point(edge_vec)) wedges = bar if wedges is None else (wedges | bar) assert wedges is not None, "three edges meet at every corner" return block & wedges
[docs] def corner_profile( body: "Bosl2Solid", corners: Anchor = Anchor.ALL, except_corners: list[Anchor] | None = None, radius: float | None = None, diameter: float | None = None, size: tuple[float, float, float] | None = None, mask: "Path2D | None" = None, convexity: int = 10, anchor: Anchor | Point = CENTER, center: Point | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, return_cutter: bool = False, ) -> "Solid | None": """Round each selected corner of the box-shaped *body* to radius *radius*. Args: body: The box solid to cut. corners: Corners to mask — ``"ALL"``/``"NONE"``, a face vector, or a corner vector. except_corners: Corners to explicitly not mask. radius: Rounding radius. diameter: Rounding diameter (alternative to *radius*). size: The box's ``(x, y, z)`` size. mask: Accepted for call-site compatibility; unused -- corner_profile always rounds. convexity: Accepted for signature compatibility; unused. anchor: The anchor *body* was built with (default ``CENTER``). center: The box center in body's current frame. fn: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fs=...)`` value applies. return_cutter: If True, returns the generated cutter shape instead of cutting it. """ _ = (mask, convexity) if radius is None: if not (diameter is not None): raise Bosl2ValueError("corner_profile(): must give radius or diameter") radius = diameter / 2 rad = float(radius) if not (size is not None): raise Bosl2ValueError("size= (the box's size) must be given") corner_set = _corners(corners, except_corners or []) cutter: "Solid | None" = None for idx, sel in enumerate(corner_set): if sel: piece = _corner_cutter(size, CORNER_OFFSETS[idx], rad, fn, fa, fs) cutter = piece if cutter is None else (cutter | piece) if cutter is None: return None if return_cutter else body cutter = cutter.translate(list(center) if center is not None else _anchor_offset_box3(size, anchor)) if return_cutter: return cutter return body - cutter
[docs] def face_profile( body: "Bosl2Solid", faces: Anchor | list[Anchor] = Anchor.ALL, radius: float | None = None, diameter: float | None = None, size: tuple[float, float, float] | None = None, mask: "Path2D | None" = None, convexity: int = 10, anchor: Anchor | Point = CENTER, center: Point | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, return_cutter: bool = False, ) -> "Solid | None": """Round all edges and corners bounding the given face(s) of the box-shaped *body*. Args: body: The box solid to cut. faces: Face(s) to round, e.g. ``TOP``, or ``"ALL"`` (default). radius: Rounding radius. diameter: Rounding diameter (alternative to *radius*). size: The box's ``(x, y, z)`` size. mask: The 2-D mask cross-section, as a :class:`~pybosl2.path2d.Path2D`; defaults to ``Mask2D.roundover(radius)``. convexity: Accepted for signature compatibility; unused. anchor: The anchor *body* was built with (default ``CENTER``). center: The box center in body's current frame. fn: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fs=...)`` value applies. return_cutter: If True, returns the generated cutter shape instead of cutting it. """ if radius is None: if not (diameter is not None): raise Bosl2ValueError("face_profile(): must give radius or diameter") radius = diameter / 2 rad = float(radius) profile = mask if mask is not None else Mask2D.roundover(rad, fn=fn, fa=fa, fs=fs) if return_cutter: edge_c = edge_profile( body, faces, mask=profile, size=size, convexity=convexity, anchor=anchor, center=center, return_cutter=True, ) corner_c = corner_profile( body, faces, # type: ignore[arg-type] radius=rad, size=size, convexity=convexity, anchor=anchor, center=center, fn=fn, fa=fa, fs=fs, return_cutter=True, ) if edge_c is None: return corner_c if corner_c is None: return edge_c return edge_c | corner_c body = edge_profile(body, faces, mask=profile, size=size, convexity=convexity, anchor=anchor, center=center) # type: ignore[assignment] return corner_profile( body, faces, # type: ignore[arg-type] radius=rad, size=size, convexity=convexity, anchor=anchor, center=center, fn=fn, fa=fa, fs=fs, )
[docs] class Mask2D: """The 2-D cutter cross-sections (BOSL2's ``mask2d_*`` family), as factories returning a Path2D. Each returns the profile you sweep along an edge to cut it -- pass one as the *mask* of :meth:`~pybosl2.shapes3d.base.Bosl2Solid.edge_profile` / :meth:`~pybosl2.shapes3d.base.Bosl2Solid.corner_profile`, or extrude it yourself. The BOSL2 spellings (``mask2d_roundover`` and friends) remain as aliases of these. Examples: .. pythonscad-example:: from pybosl2 import Anchor, Mask2D, cuboid cuboid([30, 30, 20]).edge_profile(edges=[Anchor.TOP], mask=Mask2D.roundover(4)).show() """
[docs] @staticmethod def roundover( radius: float | None = None, inset: float | tuple[float, float] = 0.0, excess: float = 0.01, diameter: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Path2D": """Return the 2-D L-shaped cutter cross-section for rounding a 90-degree edge/corner to radius *radius*. Args: radius: Rounding radius. inset: Scalar or ``(x, y)`` inset of the rounding center from the corner (default 0). excess: Amount the flat sides extend past the origin, for a clean boolean cut (default 0.01). diameter: Rounding diameter (alternative to *radius*). fn: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fs=...)`` value applies. Returns: A :class:`~pybosl2.path2d.Path2D` of the 2-D cutter cross-section. """ from pybosl2.path2d import Path2D if radius is None: if not (diameter is not None): raise Bosl2ValueError("Mask2D.roundover(): must give radius or diameter") radius = diameter / 2 rad = float(radius) inset_x, inset_y = inset if isinstance(inset, tuple) else (float(inset), float(inset)) steps = max(1, int(quantup(_frag_count(rad, fn, fa, fs), 4) // 4)) step = 90.0 / steps path = [ [rad + inset_x, -excess], [-excess, -excess], [-excess, rad + inset_y], ] for i in range(steps + 1): p = _polar_to_xy(rad, 180 + i * step) path.append([rad + inset_x + p[0], rad + inset_y + p[1]]) return Path2D(path, closed=True)
[docs] @staticmethod def chamfer( width: float, height: float | None = None, excess: float = 0.01, ) -> "Path2D": """Return the 2-D L-shaped cutter cross-section for chamfering a 90-degree edge. A symmetric chamfer needs one number; give *height* only for an asymmetric one. These were spelled ``x`` and ``y``, which named the axes rather than the thing being described (SPEC S-26c). Args: width: Chamfer width, measured back along the first face. height: Chamfer height, measured back along the second face (default: *width*, a symmetric 45-degree chamfer). excess: Amount the flat sides extend past the origin, for a clean cut (default 0.01). Returns: A :class:`~pybosl2.path2d.Path2D` of the 2-D cutter cross-section. Raises: Bosl2ValueError: If *width* or a given *height* is not positive. Examples: .. pythonscad-example:: from pybosl2 import Anchor, Mask2D, cuboid cuboid([30, 30, 20]).edge_profile(edges=[Anchor.TOP], mask=Mask2D.chamfer(4)).show() """ from pybosl2.path2d import Path2D if not (width > 0): raise Bosl2ValueError(f"Mask2D.chamfer(): width must be positive, got {width}.") y_val = width if height is None else float(height) if not (y_val > 0): raise Bosl2ValueError(f"Mask2D.chamfer(): height must be positive, got {height}.") pts = [ [width, -excess], [-excess, -excess], [-excess, y_val], [0.0, y_val], [width, 0.0], ] return Path2D(pts, closed=True)
[docs] @staticmethod def cove( radius: float, excess: float = 0.01, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Path2D": """Return the 2-D L-shaped cutter cross-section for a concave corner fillet (cove). Args: radius: Cove radius. excess: Amount the flat sides extend past the origin, for a clean cut (default 0.01). fn: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fs=...)`` value applies. """ from pybosl2.path2d import Path2D steps = max(1, int(quantup(_frag_count(radius, fn, fa, fs), 4) // 4)) path = [ [radius, -excess], [-excess, -excess], [-excess, radius], [0.0, radius], ] for i in range(steps + 1): ang = math.radians(180.0 + (90.0 * i / steps)) path.append( [ radius + radius * math.cos(ang), radius + radius * math.sin(ang), ] ) path.append([radius, 0.0]) return Path2D(path, closed=True)
[docs] @staticmethod def tear( r: float, maxgap: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Path2D": """Return the 2-D L-shaped cutter cross-section with a teardrop-shaped profile. Args: r: Radius of the teardrop circle. maxgap: Maximum gap height (unused, kept for compatibility). fn: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Arc smoothness overrides. Omitted, the ambient ``use_defaults(fs=...)`` value applies. """ from pybosl2.path2d import Path2D _ = maxgap excess = 0.01 path = [ [r, -excess], [-excess, -excess], [-excess, r], ] steps = max(1, int(quantup(_frag_count(r, fn, fa, fs), 4) // 4)) for i in range(steps + 1): ang = math.radians(180.0 + (135.0 * i / steps)) path.append( [ r + r * math.cos(ang), r + r * math.sin(ang), ] ) tip = [r * (1.0 - math.sqrt(2.0)), r * (1.0 - math.sqrt(2.0))] path.append(tip) return Path2D(path, closed=True)
[docs] @staticmethod def step( width: float, height: float | None = None, excess: float = 0.01, ) -> "Path2D": """Return the 2-D cutter cross-section for cutting a step profile in a corner. A square step needs one number; give *height* only for a rectangular one. It used to require both, which SPEC D-2 allows only with a written justification and there is none: a step as deep as it is wide is the ordinary case. Args: width: Step width. The one thing no default can invent. height: Step height (default: *width*, a square step). excess: Amount the flat sides extend past the origin, for a clean cut (default 0.01). Returns: A :class:`~pybosl2.path2d.Path2D` of the 2-D cutter cross-section. Raises: Bosl2ValueError: If *width* or a given *height* is not positive. Examples: .. pythonscad-example:: from pybosl2 import Anchor, Mask2D, cuboid cuboid([30, 30, 20]).edge_profile(edges=[Anchor.TOP], mask=Mask2D.step(4)).show() """ if not (width > 0): raise Bosl2ValueError(f"Mask2D.step(): width must be positive, got {width}.") height = width if height is None else float(height) if not (height > 0): raise Bosl2ValueError(f"Mask2D.step(): height must be positive, got {height}.") from pybosl2.path2d import Path2D # The rectangular notch this cuts out of the corner, extended by `excess` on the two outer # sides so the boolean is clean. # # This traced (width, -excess) -> (-excess, -excess) -> (-excess, height) -> (0, height) # -> (0, 0) -> (width, 0), which returns along the notch's *own* edges and so encloses only # an L-shaped sliver `excess` thick: a 4 x 4 step enclosed 0.08 mm^2 instead of 16, and cut # nothing at all. Nothing caught it because the test asserted the point count and not the # area (PLAN X-8). pts = [ [-excess, -excess], [width, -excess], [width, height], [-excess, height], ] return Path2D(pts, closed=True)
[docs] @staticmethod def groove( width: float, depth: float | None = None, chamfer: float = 0.0, round_radius: float = 0.0, excess: float = 0.01, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Path2D": """Return the 2-D cutter cross-section for cutting a slot or groove. Only *width* is required (SPEC D-2, P-3): half the width is the depth that reads as a groove rather than a slot, so that is what it derives when you do not say. Args: width: Groove width. The one thing no default can invent. depth: Groove depth (default: half the width). chamfer: Groove chamfer offset (unused, kept for compatibility). round_radius: Groove corner rounding radius (unused, kept for compatibility). excess: Amount the flat sides extend past the origin, for a clean cut (default 0.01). fn: Arc smoothness override -- fixed fragment count. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Arc smoothness override -- minimum fragment angle. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Arc smoothness override -- minimum fragment size. Omitted, the ambient ``use_defaults(fs=...)`` value applies. Returns: A :class:`~pybosl2.path2d.Path2D` of the 2-D cutter cross-section. Raises: Bosl2ValueError: If *width* or a given *depth* is not positive. Examples: .. pythonscad-example:: from pybosl2 import Anchor, Mask2D, cuboid cuboid([30, 30, 20]).edge_profile(edges=[Anchor.TOP], mask=Mask2D.groove(4)).show() """ if not (width > 0): raise Bosl2ValueError(f"Mask2D.groove(): width must be positive, got {width}.") depth = width / 2 if depth is None else float(depth) if not (depth > 0): raise Bosl2ValueError(f"Mask2D.groove(): depth must be positive, got {depth}.") from pybosl2.path2d import Path2D _ = (chamfer, round_radius, fn, fa, fs) half_w = width / 2.0 pts = [ [half_w + excess, -excess], [-half_w - excess, -excess], [-half_w - excess, depth + excess], [-half_w, depth + excess], [-half_w, 0.0], [half_w, 0.0], [half_w, depth + excess], [half_w + excess, depth + excess], ] return Path2D(pts, closed=True)
[docs] class Mask3D: """The ready-made 3-D cutter solids (BOSL2's ``mask3d_*`` family), as factories. Unlike :class:`Mask2D`, these are whole solids: subtract one from your shape to cut every selected edge or corner at once. The BOSL2 spellings remain as aliases of these. Examples: .. pythonscad-example:: from pybosl2 import Mask3D, cuboid (cuboid([30, 30, 30]) - Mask3D.roundover(4, size=(30, 30, 30))).show() """
[docs] @staticmethod def roundover( radius: float | None = None, *, size: tuple[float, float, float], diameter: float | None = None, corners: Anchor = Anchor.ALL, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Solid": """Return the 3-D cutter that rounds the corners and edges of a box of the given size. `size` is the box being cut, so it is only ever needed when you build the cutter yourself. Reaching for :meth:`~pybosl2.shapes3d.base.CsgSolid.round_edges` instead is both shorter and safer -- the solid already knows its own box and fills this in (SPEC S-26a, S-26b):: solid.round_edges(Anchor.TOP, radius=3) Args: radius: Rounding radius. size: Size of the box being cut, ``(x, y, z)``. Keyword-only, because it describes the *parent*, not the treatment. diameter: Rounding diameter (alternative to *radius*; giving both is an error). corners: Corners to select. fn: Arc smoothness override -- fixed fragment count. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Arc smoothness override -- minimum fragment angle. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Arc smoothness override -- minimum fragment size. Omitted, the ambient ``use_defaults(fs=...)`` value applies. Returns: The cutter solid; subtract it from the box to round it. Raises: Bosl2ValueError: If neither radius nor diameter is given, if both are, or if *corners* selects nothing. Examples: .. pythonscad-example:: from pybosl2 import Mask3D, cuboid (cuboid([30, 30, 30]) - Mask3D.roundover(4, size=(30, 30, 30))).show() """ from pybosl2.shapes3d import cuboid r = _pick_radius(radius=radius, diameter=diameter) if r is None: raise Bosl2ValueError("Mask3D.roundover(): give radius= or diameter=.") body = cuboid(size) cutter = corner_profile( body, corners=corners, radius=r, size=size, fn=fn, fa=fa, fs=fs, return_cutter=True, ) if cutter is None: raise Bosl2ValueError( "Mask3D.roundover(): corners= selected no corners, so there is nothing to round; " "pass an Anchor naming at least one corner." ) return cutter
[docs] @staticmethod def chamfer( chamfer: float, *, size: tuple[float, float, float], corners: Anchor = Anchor.ALL, ) -> "Solid": """Return the 3-D cutter that chamfers the corners and edges of a box of the given size. As with :meth:`roundover`, `size` describes the box being cut; :meth:`~pybosl2.shapes3d.base.CsgSolid.chamfer_edges` fills it in for you (SPEC S-26a). Args: chamfer: Chamfer distance. size: Size of the box being cut, ``(x, y, z)``. Keyword-only -- it describes the *parent*, not the treatment. corners: Corners to select. Returns: The cutter solid; subtract it from the box to chamfer it. Raises: Bosl2ValueError: If *corners* selects nothing. Examples: .. pythonscad-example:: from pybosl2 import Mask3D, cuboid (cuboid([30, 30, 30]) - Mask3D.chamfer(4, size=(30, 30, 30))).show() """ # NOT corner_profile(mask=Mask2D.chamfer(...)): corner_profile ignores mask= and # always rounds, which used to make this factory return the roundover cutter verbatim. cutter: "Solid | None" = None for idx, sel in enumerate(_corners(corners, [])): if sel: piece = _corner_chamfer_cutter(size, CORNER_OFFSETS[idx], chamfer) cutter = piece if cutter is None else (cutter | piece) if cutter is None: raise Bosl2ValueError( "Mask3D.chamfer(): corners= selected no corners, so there is nothing to chamfer; " "pass an Anchor naming at least one corner." ) return cutter
[docs] @staticmethod def groove( width: float, *, depth: float | None = None, length: float | None = None, chamfer: float = 0.0, size: tuple[float, float, float] | None = None, ) -> "Solid": """Return the 3-D cutter for a slot or groove of the given width. Only *width* is required (SPEC D-2): a groove's depth follows from its width unless you say otherwise -- half the width is the proportion that reads as a groove rather than a slot -- and its length is however long the thing being grooved is, which *size* supplies when you pass it and :meth:`~pybosl2.shapes3d.base.CsgSolid.groove_edges` supplies for you (SPEC P-3, S-26a). This took three required positionals, which SPEC D-2 says is never acceptable. Args: width: Groove width. The one thing no default can invent. depth: Groove depth (default: half the width). length: Groove length (default: the longest side of *size*, or ten times the width if no size is given either). chamfer: Groove chamfer offset. size: Size of the thing being grooved, ``(x, y, z)``, used to derive *length*. Returns: The cutter solid, extruded along Z and centred. Raises: Bosl2ValueError: If *width* is not positive, or a given *depth* is not positive. Examples: .. pythonscad-example:: from pybosl2 import Mask3D, cuboid (cuboid([40, 40, 12]) - Mask3D.groove(6, length=60)).show() """ if not (width > 0): raise Bosl2ValueError(f"Mask3D.groove(): width must be positive, got {width}.") cut_depth = width / 2 if depth is None else float(depth) if not (cut_depth > 0): raise Bosl2ValueError(f"Mask3D.groove(): depth must be positive, got {depth}.") if length is not None: cut_length = float(length) elif size is not None: cut_length = max(float(v) for v in size) else: cut_length = width * 10 g2d = Mask2D.groove(width, cut_depth, chamfer=chamfer) return g2d.linear_extrude(height=cut_length, center=True)
# The BOSL2 spellings, kept as aliases of the factories above (SPEC P-6). New code should use the # classes -- these stay for one release. mask2d_roundover = Mask2D.roundover mask2d_chamfer = Mask2D.chamfer mask2d_cove = Mask2D.cove mask2d_tear = Mask2D.tear mask2d_step = Mask2D.step mask2d_groove = Mask2D.groove mask3d_roundover = Mask3D.roundover mask3d_chamfer = Mask3D.chamfer mask3d_groove = Mask3D.groove