# 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: bosl2/masking.py
# Pure-Python port of the pieces of BOSL2's masks2d.scad/masks3d.scad/
# attachments.scad "edge/corner/face profile" masking system that this
# project uses: cutting a rounded profile along selected edges, corners,
# or whole faces of a cuboid. No osuse()/BOSL2 runtime dependency.
#
# BOSL2's real edge_mask()/edge_profile()/corner_profile() work through
# its generic $parent_geom attachment-introspection system (so they can
# run on any attachable shape) and position each edge mask using a
# hardcoded Euler-angle table. This port only supports cuboid parents
# (the only case this project uses -- every `body` passed in is a
# shapes3d.cuboid() result), and takes the cuboid's `size=` explicitly
# as a parameter instead of introspecting it. Edge/corner positioning is
# derived directly from first principles (build an orthonormal local
# frame at each edge/corner from the same EDGE_OFFSETS/CORNER_OFFSETS
# vectors bosl2/shapes3d.py's cuboid() rounding already uses) rather
# than replicating BOSL2's Euler-angle table.
#
# corner_profile()'s local mask is also simplified: BOSL2 builds the
# corner fillet by revolving the 2-D edge-rounding profile 90 degrees
# around each axis. For mask2d_roundover() specifically (the only 2-D
# mask this project ever uses), that construction is mathematically
# equivalent to "a cube octant minus a sphere of the same radius" --
# the same rounded-corner geometry cuboid()'s own `rounding=`
# minkowski(cube, sphere) construction produces -- so that's what's
# implemented here, without needing a general rotate_extrude of an
# arbitrary 2-D profile.
#
# edge_profile_asym() is used in this project only with its default
# corner_type="none" and no flip=, in which case BOSL2's own
# implementation degrades to calling edge_profile() per edge, so it's
# just an alias here.
#
# FileSummary: Cut rounded edge/corner/face profiles into a cuboid (BOSL2 masks2d/masks3d/attachments.scad).
# FileGroup: BOSL2
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING
from bosl2._native import native
if TYPE_CHECKING:
from openscad import PyOpenSCAD # noqa: F401
from .constants import CENTER
from .shapes2d import _frag_count, _polar_to_xy
from .shapes3d import EDGE_OFFSETS, _anchor_offset_box3, _edges, _quantup
if TYPE_CHECKING: # real stub-typed imports for the checker (identical to pre-lazy)
from pythonscad import cube as _ocube
from pythonscad import polygon as _opolygon
from pythonscad import sphere as _osphere
else:
_ocube = native("cube")
_opolygon = native("polygon")
_osphere = native("sphere")
CORNER_OFFSETS = [[xa, ya, za] for za in (-1, 1) for ya in (-1, 1) for xa in (-1, 1)]
[docs]
def mask2d_roundover(
radius: float | None = None,
inset: float | list[float] = 0,
excess: float = 0.01,
diameter: float | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> list[list[float]]:
"""The 2-D L-shaped cutter cross-section for rounding a 90-degree edge/corner to radius *radius*.
The origin is the sharp corner to be rounded; the shape extends along +X and +Y (with a
small `excess` skirt past 0 on each) into the material being cut, with a quarter-circle
bite of radius *radius* taken out of the far corner.
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/fa/fs: arc smoothness overrides
"""
if radius is None:
assert diameter is not None, "mask2d_roundover(): must give radius or diameter"
radius = diameter / 2
rad = radius
inset_l = list(inset) if isinstance(inset, (list, tuple)) else [inset, inset]
steps = max(1, int(_quantup(_frag_count(rad, fn, fa, fs), 4) // 4))
step = 90.0 / steps
path = [
[rad + inset_l[0], -excess],
[-excess, -excess],
[-excess, rad + inset_l[1]],
]
for i in range(steps + 1):
p = _polar_to_xy(rad, 180 + i * step)
path.append([rad + inset_l[0] + p[0], rad + inset_l[1] + p[1]])
return path
[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,
) -> PyOpenSCAD:
"""A standalone 3-D edge-rounding cutter of length *length*, for manual positioning (matching
this project's existing `.rotate(...).translate(...)` usage rather than going through
edge_mask()). Uses the same local-frame convention as mask2d_roundover(): origin at the
sharp edge, +X/+Y extending into the material, centered along its own Z axis over length *length*.
Args:
length/height: length of the cutter along its axis (default 1)
radius: rounding radius (both ends)
radius1/radius2: rounding radius at each end, for a tapered cutter
diameter/diameter1/diameter2: rounding diameter (both ends) / each end
excess: amount the flat sides extend past the origin (default 0.1)
fn/fa/fs: arc smoothness overrides
"""
length = length if length is not None else (height if height is not None else 1)
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))
)
)
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))
)
)
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])
cross = mask2d_roundover(rad1, excess=excess, fn=fn, fa=fa, fs=fs)
scale = rad2 / rad1 if rad1 else 1
return _opolygon(cross).linear_extrude(height=length, center=True, scale=scale)
[docs]
def chamfer_edge_mask(length: float = 1, chamfer: float = 1, excess: float = 0.1) -> PyOpenSCAD:
"""A standalone 3-D edge-chamfer cutter of length *length* (BOSL2 chamfer_edge_mask()).
A diamond bar (a square prism rotated 45 degrees, with its vertices *chamfer* out along each
axis -- i.e. ``cylinder(radius=chamfer, $fn=4)``), centered on its own Z axis. Position it along a
sharp edge and subtract it to bevel the edge by *chamfer*.
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)
"""
diamond = [[chamfer, 0], [0, chamfer], [-chamfer, 0], [0, -chamfer]]
return _opolygon(diamond).linear_extrude(height=length + excess, center=True)
def _pick_axes(vec: Sequence[float]) -> 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, vec[a1], vec[a2]
def _orient_mask_along_edge(shape: PyOpenSCAD, size: Sequence[float], vec: Sequence[float]) -> PyOpenSCAD:
"""Reorient/position an already-built edge cutter (local +X/+Y into the solid, centered
along its own local Z which runs along the edge) 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],
[lx[1], ly[1], lz[1], 0],
[lx[2], ly[2], lz[2], 0],
[0, 0, 0, 1],
]
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: Sequence[Sequence[float]],
length: float,
size: Sequence[float],
vec: Sequence[float],
) -> PyOpenSCAD:
shape = _opolygon(mask_path).linear_extrude(height=length, center=True)
return _orient_mask_along_edge(shape, size, vec)
[docs]
def edge_mask(
body: PyOpenSCAD,
edges: str | list = "ALL",
except_edges: list | None = None,
children: PyOpenSCAD | None = None,
size: Sequence[float] | None = None,
anchor: Sequence[float] = CENTER,
center: Sequence[float] | None = None,
) -> PyOpenSCAD:
"""Cut an already-built 3-D edge cutter (e.g. from rounding_edge_mask()) 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 (BOSL2's `except=` synonym)
children: the pre-built 3-D edge cutter (BOSL2's `_children=`)
size: the box's [x,y,z] size (BOSL2 gets this from $parent_geom)
anchor: the anchor *body* was built with (default CENTER); used only to locate the
box center when `center` isn't given
center: the box center in body's current frame; when given (e.g. from a native
bbox query) it's used directly and `anchor` is ignored
"""
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 = _edges(edges, except_edges or [])
cutter = None
for axis in range(3):
for i in range(4):
if edge_set[axis][i] > 0:
piece = _orient_mask_along_edge(children, size, EDGE_OFFSETS[axis][i])
cutter = piece if cutter is None else (cutter | piece)
if cutter is None:
return body
cutter = cutter.translate(center if center is not None else _anchor_offset_box3(size, anchor))
return body - cutter
[docs]
def edge_profile(
body: PyOpenSCAD,
edges: str | list = "ALL",
except_edges: list | None = None,
children: Sequence[Sequence[float]] | None = None,
size: Sequence[float] | None = None,
convexity: int = 10,
anchor: Sequence[float] = CENTER,
center: Sequence[float] | None = None,
) -> PyOpenSCAD:
"""Cut a 2-D mask profile (e.g. from mask2d_roundover()), extruded along the edge's own
length, 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 path (BOSL2's `_children=`)
size: the box's [x,y,z] size
convexity: accepted for signature compatibility; unused (no rotate_extrude needed here)
anchor: the anchor *body* was built with (default CENTER); used only when `center`
isn't given
center: the box center in body's current frame; when given it's used directly
"""
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 = _edges(edges, except_edges or [])
cutter = 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, vec)
cutter = piece if cutter is None else (cutter | piece)
if cutter is None:
return body
cutter = cutter.translate(center if center is not None else _anchor_offset_box3(size, anchor))
return body - cutter
# edge_profile_asym() with corner_type="none" and no flip= (the only way this project calls it)
# degrades to edge_profile() per edge -- same result as calling edge_profile() directly.
edge_profile_asym = edge_profile
def _corner_set(v) -> list[int]:
if isinstance(v, str):
if v == "ALL":
return [1] * 8
if v == "NONE":
return [0] * 8
raise ValueError(f'{v} must be "ALL", "NONE", or a vector')
return [1 if all(v[i] == 0 or v[i] == c[i] for i in range(3)) else 0 for c in CORNER_OFFSETS]
def _corners(v, except_: list | None = None) -> list[int]:
if except_ is None:
except_ = []
if isinstance(v, str) or (isinstance(v, list) and len(v) > 0 and not isinstance(v[0], list)):
v = [v]
if isinstance(except_, str) 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)
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: Sequence[float],
corner_vec: Sequence[float],
radius: float,
fn=None,
fa=None,
fs=None,
) -> PyOpenSCAD:
cube_center = [corner_vec[i] * (size[i] / 2 - radius / 2) for i in range(3)]
sphere_center = [corner_vec[i] * (size[i] / 2 - radius) for i in range(3)]
cube_shape = _ocube([radius, radius, radius], center=True).translate(cube_center)
sphere_shape = _osphere(radius=radius, fn=fn, fa=fa, fs=fs).translate(sphere_center)
return cube_shape - sphere_shape
[docs]
def corner_profile(
body: PyOpenSCAD,
corners: str | list = "ALL",
except_corners: list | None = None,
radius: float | None = None,
diameter: float | None = None,
size: Sequence[float] | None = None,
children: Sequence[Sequence[float]] | None = None,
convexity: int = 10,
anchor: Sequence[float] = CENTER,
center: Sequence[float] | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> PyOpenSCAD:
"""Round each selected corner of the box-shaped *body* to radius *radius* (cube-octant-minus-sphere).
Args:
body: the box solid to cut
corners: corners to mask -- "ALL"/"NONE", a face vector (all corners on that face),
or a corner vector (default "ALL")
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 with BOSL2's `_children=`; unused
(this port always uses the cube-minus-sphere construction, which is only
exactly equivalent to mask2d_roundover(), the only 2-D mask this project uses)
convexity: accepted for signature compatibility; unused
anchor: the anchor *body* was built with (default CENTER); used only when `center`
isn't given
center: the box center in body's current frame; when given it's used directly
fn/fa/fs: arc smoothness overrides
"""
if radius is None:
assert diameter is not None, "corner_profile(): must give radius or diameter"
radius = diameter / 2
rad = radius
assert size is not None, "size= (the box's size) must be given"
corner_set = _corners(corners, except_corners or [])
cutter = 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 body
cutter = cutter.translate(center if center is not None else _anchor_offset_box3(size, anchor))
return body - cutter
[docs]
def face_profile(
body: PyOpenSCAD,
faces: str | list = "ALL",
radius: float | None = None,
diameter: float | None = None,
size: Sequence[float] | None = None,
children: Sequence[Sequence[float]] | None = None,
convexity: int = 10,
anchor: Sequence[float] = CENTER,
center: Sequence[float] | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> PyOpenSCAD:
"""Round all edges and corners bounding the given face(s) of the box-shaped *body* to radius *radius*.
Equivalent to edge_profile(faces) followed by corner_profile(faces, radius).
Args:
body: the box solid to cut
faces: face(s) to round the border of, e.g. TOP, or "ALL" (default "ALL")
radius: rounding radius
diameter: rounding diameter (alternative to radius)
size: the box's [x,y,z] size
children: the 2-D mask cross-section path used for the edges (BOSL2's `_children=`);
defaults to mask2d_roundover(radius) if not given
convexity: accepted for signature compatibility; unused
anchor: the anchor *body* was built with (default CENTER); used only when `center`
isn't given
center: the box center in body's current frame; when given it's used directly
fn/fa/fs: arc smoothness overrides
"""
if radius is None:
assert diameter is not None, "face_profile(): must give radius or diameter"
radius = diameter / 2
rad = radius
mask = children if children is not None else mask2d_roundover(rad, fn=fn, fa=fa, fs=fs)
body = edge_profile(
body,
faces,
children=mask,
size=size,
convexity=convexity,
anchor=anchor,
center=center,
)
return corner_profile(
body,
faces,
radius=rad,
size=size,
convexity=convexity,
anchor=anchor,
center=center,
fn=fn,
fa=fa,
fs=fs,
)