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: Cut rounded edge/corner/face profiles into a cuboid (BOSL2 masks2d/masks3d).
# 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
from pybosl2._native import native
from pybosl2.points import Point

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

from pybosl2._helpers import frag_count as _frag_count
from pybosl2._helpers import polar_to_xy as _polar_to_xy
from pybosl2._helpers import quantup

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

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


[docs] def mask2d_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. fa: Arc smoothness overrides. fs: Arc smoothness overrides. Returns: A :class:`~pybosl2.path2d.Path2D` of the 2-D cutter cross-section. """ from pybosl2.path2d import Path2D if radius is None: assert diameter is not None, "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] 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. fa: Arc smoothness overrides. fs: Arc smoothness overrides. 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 = ( radius1 if radius1 is not None else ( radius if radius is not None else (diameter1 / 2 if diameter1 is not None else (diameter / 2 if diameter is not None else 1.0)) ) ) rad2 = ( radius2 if radius2 is not None else ( radius if radius is not None else (diameter2 / 2 if diameter2 is not None else (diameter / 2 if diameter is not None else 1.0)) ) ) if rad1 < rad2: cross = mask2d_roundover(rad2, excess=excess, fn=fn, fa=fa, fs=fs) shape = _opolygon(cross).linear_extrude(height=length, center=True, scale=rad1 / rad2) return shape.rotate(180, [1, 0, 0]) # type: ignore[no-any-return] cross = mask2d_roundover(rad1, excess=excess, fn=fn, fa=fa, fs=fs) scale = rad2 / rad1 if rad1 else 1.0 return _opolygon(cross).linear_extrude(height=length, center=True, scale=scale) # type: ignore[no-any-return]
[docs] def chamfer_edge_mask(length: float = 1.0, chamfer: float = 1.0, excess: float = 0.1) -> "Bosl2Solid": """Return a standalone 3-D edge-chamfer cutter of length *length*. A diamond bar (square prism rotated 45°) centered on its own Z axis. 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: A :class:`~pybosl2.shapes3d.Bosl2Solid` cutter. """ diamond = [[chamfer, 0.0], [0.0, chamfer], [-chamfer, 0.0], [0.0, -chamfer]] return _opolygon(diamond).linear_extrude(height=length + excess, center=True) # type: ignore[no-any-return]
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: "Bosl2Solid", size: tuple[float, float, float], vec: Point, ) -> "Bosl2Solid": """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, ) -> "Bosl2Solid": 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, children: "Bosl2Solid | None" = None, size: tuple[float, float, float] | None = None, anchor: Anchor | Point = CENTER, center: Point | None = None, return_cutter: bool = False, ) -> "Bosl2Solid | 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. children: The pre-built 3-D edge cutter. 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. """ assert size is not None, "size= (the box's size) must be given" assert children is not None, "children= (the edge cutter) must be given" edge_set = resolve_edges(edges, except_edges or []) cutter: "Bosl2Solid | None" = None for axis in range(3): for i in range(4): if edge_set[axis][i] > 0: piece = _orient_mask_along_edge(children, 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, children: "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, ) -> "Bosl2Solid | 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. children: The 2-D mask cross-section :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 assert size is not None, "size= (the box's size) must be given" assert children is not None, "children= (the 2-D mask path) must be given" edge_set = resolve_edges(edges, except_edges or []) cutter: "Bosl2Solid | 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(children, 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) -> list[int]: if isinstance(v, Anchor): return v.to_corner_set() if isinstance(v, str): raise ValueError(f"Legacy string corner selection is not allowed: {v!r}") arr = np.asarray(v, dtype=int) return [ 1 if arr[0] == 0 or arr[0] == c[0] and arr[1] == 0 or arr[1] == c[1] and arr[2] == 0 or arr[2] == c[2] 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 ValueError(f"Legacy string corner selection is not allowed: {v!r}") if isinstance(except_, str): raise ValueError(f"Legacy string corner selection is not allowed: {except_!r}") if isinstance(v, Anchor) or (isinstance(v, list) and len(v) > 0 and not isinstance(v[0], list)): v = [v] # type: ignore[assignment] if isinstance(except_, Anchor) or ( isinstance(except_, list) and len(except_) > 0 and not isinstance(except_[0], list) ): 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": assert radius > 0 # Standard cutter: box-shaped negative roundover. Built by placing a negative sphere # (or rather, the positive chunk to subtract) in the corner of a size-sized box. # We do this by taking a box at the corner, and subtracting a sphere. from pybosl2.shapes3d import cuboid, sphere # Create the block representing the corner volume: size is 2*radius block = cuboid([2 * radius, 2 * radius, 2 * radius]) # And a sphere at the inner corner sph = sphere(radius=radius, fn=fn, fa=fa, fs=fs) # The cutter is block - sphere, placed so the sphere's center is at the inner corner # (i.e. at distance `radius` from the corner along each axis, moving inward). # If the corner vector is `c` (elements ±1): the box's outer corner is at `[c[0]*r, c[1]*r, c[2]*r]` # and the sphere is at `[0, 0, 0]`. # Shift block to `[-c[0]*r, -c[1]*r, -c[2]*r]`. # Standard way in pybosl2/BOSL2: cutter's outer corner matches the body's corner. offset = Point([-corner_vec[0] * radius, -corner_vec[1] * radius, -corner_vec[2] * radius]) cutter = block.translate(offset) - sph # Translate to the actual corner of the size-sized body (which is at `size/2 * corner_vec`) corner_pt = Point([size[i] / 2 * corner_vec[i] for i in range(3)]) return cutter.translate(corner_pt)
[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, children: "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, ) -> "Bosl2Solid | 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. children: Accepted for call-site compatibility; unused. 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. fa: Arc smoothness overrides. fs: Arc smoothness overrides. return_cutter: If True, returns the generated cutter shape instead of cutting it. """ _ = (children, convexity) if radius is None: assert diameter is not None, "corner_profile(): must give radius or diameter" radius = diameter / 2 rad = float(radius) assert size is not None, "size= (the box's size) must be given" corner_set = _corners(corners, except_corners or []) cutter: "Bosl2Solid | 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, children: "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, ) -> "Bosl2Solid | 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. children: The 2-D mask cross-section :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. fa: Arc smoothness overrides. fs: Arc smoothness overrides. return_cutter: If True, returns the generated cutter shape instead of cutting it. """ if radius is None: assert diameter is not None, "face_profile(): must give radius or diameter" radius = diameter / 2 rad = float(radius) mask = children if children is not None else mask2d_roundover(rad, fn=fn, fa=fa, fs=fs) if return_cutter: edge_c = edge_profile( body, faces, children=mask, 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, children=mask, 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] def mask2d_chamfer( x: float, y: float | None = None, excess: float = 0.01, ) -> "Path2D": """Return the 2-D L-shaped cutter cross-section for chamfering a 90-degree edge. Args: x: Chamfer width (X direction). y: Chamfer height (Y direction). Defaults to `x`. excess: Amount the flat sides extend past the origin, for a clean cut (default 0.01). """ from pybosl2.path2d import Path2D y_val = x if y is None else y pts = [ [x, -excess], [-excess, -excess], [-excess, y_val], [0.0, y_val], [x, 0.0], ] return Path2D(pts, closed=True)
[docs] def mask2d_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. fa: Arc smoothness overrides. fs: Arc smoothness overrides. """ 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] def mask2d_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. fa: Arc smoothness overrides. fs: Arc smoothness overrides. """ 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] def mask2d_step( width: float, height: float, excess: float = 0.01, ) -> "Path2D": """Return the 2-D cutter cross-section for cutting a step profile in a corner. Args: width: Step width. height: Step height. excess: Amount the flat sides extend past the origin, for a clean cut (default 0.01). """ from pybosl2.path2d import Path2D pts = [ [width, -excess], [-excess, -excess], [-excess, height], [0.0, height], [0.0, 0.0], [width, 0.0], ] return Path2D(pts, closed=True)
[docs] def mask2d_groove( width: float, depth: float, 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. Args: width: Groove width. depth: Groove depth. 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 overrides. fa: Arc smoothness overrides. fs: Arc smoothness overrides. """ 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] def mask3d_roundover( r: float, size: tuple[float, float, float], corners: Anchor = Anchor.ALL, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Bosl2Solid": """3-D cutter shape for rounding corners and edges of a box of the given size. Args: r: Rounding radius. size: Bounding box size (X, Y, Z). corners: Corners to select. fn: Arc smoothness overrides. fa: Arc smoothness overrides. fs: Arc smoothness overrides. """ from pybosl2.shapes3d import cuboid 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 ValueError("mask3d_roundover(): failed to generate cutter") return cutter
[docs] def mask3d_chamfer( chamfer: float, size: tuple[float, float, float], corners: Anchor = Anchor.ALL, ) -> "Bosl2Solid": """3-D cutter shape for chamfering corners and edges of a box of the given size. Args: chamfer: Chamfer distance. size: Bounding box size (X, Y, Z). corners: Corners to select. """ from pybosl2.shapes3d import cuboid body = cuboid(size) mask = mask2d_chamfer(chamfer) cutter = corner_profile( body, corners=corners, radius=chamfer, size=size, children=mask, return_cutter=True, ) if cutter is None: raise ValueError("mask3d_chamfer(): failed to generate cutter") return cutter
[docs] def mask3d_groove( width: float, depth: float, length: float, chamfer: float = 0.0, ) -> "Bosl2Solid": """3-D cutter shape representing a slot or groove of the given width, depth, and length. Args: width: Groove width. depth: Groove depth. length: Groove length. chamfer: Groove chamfer offset. """ g2d = mask2d_groove(width, depth, chamfer=chamfer) # Extrude along Z: return g2d.linear_extrude(height=length, center=True) # type: ignore[return-value]