Source code for bosl2.rounding

# 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/rounding.py
#    Pure-Python port of the path-rounding core of BOSL2's rounding.scad: :func:`round_corners`
#    (round every corner of a path -- ``"circle"``, ``"smooth"`` or ``"chamfer"``, sized by
#    ``radius``/``cut``/``joint``/``width``) and :func:`smooth_path` (fit a continuous-curvature
#    bezier through a path). Both work on 2-D and 3-D paths and are exposed as methods on
#    :class:`~bosl2.paths.Path` and :class:`~bosl2.paths.Path3D`.
#
#    ``round_corners`` and ``smooth_path`` are pinned point-for-point to the real BOSL2 output in
#    tests/test_bosl2_reorient.py. The smooth/chamfer corners reuse the toolkit's
#    :class:`~bosl2.beziers.Bezier`; the circle corners reuse :func:`~bosl2.shapes2d.arc` (2-D) or a
#    slerp arc (3-D).
#
#    NOT ported (a large follow-up): ``path_join``, and the 3-D generators ``offset_stroke`` /
#    ``offset_sweep`` (+ the ``os_*`` profiles) / ``convex_offset_extrude`` / ``rounded_prism`` /
#    ``join_prism`` / ``prism_connector`` / ``attach_prism`` / ``bent_cutout_mask``.
#
# FileSummary: Path rounding: round_corners (circle/smooth/chamfer) and smooth_path.
# FileGroup: BOSL2

from __future__ import annotations

import math

import numpy as np

from bosl2._helpers import is_num
from bosl2.comparisons import approx
from bosl2.vectors import unit

__all__ = ["round_corners", "smooth_path", "Roundable"]


# ---------------------------------------------------------------------------
# Section: corner builders
# ---------------------------------------------------------------------------


def _vector_angle3(a, b, c) -> float:
    """The angle in degrees at vertex *b* of the corner a-b-c (2-D or 3-D)."""
    va = np.asarray(a, dtype=float) - np.asarray(b, dtype=float)
    vc = np.asarray(c, dtype=float) - np.asarray(b, dtype=float)
    cosv = float(np.dot(va, vc)) / (float(np.linalg.norm(va)) * float(np.linalg.norm(vc)))
    return math.degrees(math.acos(max(-1.0, min(1.0, cosv))))


def _smooth_bez_fill(points, k):
    p0, p1, p2 = (np.asarray(p, dtype=float) for p in points)
    return [p0, p1 + (p0 - p1) * k, p1, p1 + (p2 - p1) * k, p2]


def _bezcorner(points, parm, fn=0, fs=2.0):
    """A continuous-curvature (bezier) corner (BOSL2 _bezcorner())."""
    from bosl2.beziers import Bezier

    if isinstance(parm, (list, tuple, np.ndarray)):
        d, k = float(parm[0]), float(parm[1])
        p1 = np.asarray(points[1], dtype=float)
        prev = unit(np.asarray(points[0], dtype=float) - p1)
        nxt = unit(np.asarray(points[2], dtype=float) - p1)
        ctrl = [p1 + d * prev, p1 + k * d * prev, p1, p1 + k * d * nxt, p1 + d * nxt]
    else:
        ctrl = _smooth_bez_fill(points, float(parm))
    bez = Bezier([[float(c) for c in p] for p in ctrl])
    sides = max(3, fn if fn and fn > 0 else math.ceil(bez.length() / fs))
    return [[float(c) for c in p] for p in bez.curve(sides, endpoint=True)]


def _chamfcorner(points, parm):
    """A straight chamfer across a corner (BOSL2 _chamfcorner())."""
    diameter = float(parm[0])
    p1 = np.asarray(points[1], dtype=float)
    prev = unit(np.asarray(points[0], dtype=float) - p1)
    nxt = unit(np.asarray(points[2], dtype=float) - p1)
    return [list(p1 + prev * diameter), list(p1 + nxt * diameter)]


def _arc3d(center, start, end, n):
    """
    *n* points along the short arc from *start* to *end* about *center* (slerp, any dimension).
    """
    c = np.asarray(center, dtype=float)
    v0, v1 = np.asarray(start, dtype=float) - c, np.asarray(end, dtype=float) - c
    angle = math.acos(
        max(
            -1.0,
            min(1.0, float(np.dot(v0, v1)) / (np.linalg.norm(v0) * np.linalg.norm(v1))),
        )
    )
    if angle < 1e-12:
        return [
            list(np.asarray(start, dtype=float)),
            list(np.asarray(end, dtype=float)),
        ]
    s = math.sin(angle)
    return [list(c + (math.sin((1 - t) * angle) * v0 + math.sin(t * angle) * v1) / s) for t in np.linspace(0, 1, n)]


def _circlecorner(points, parm, fn=None, fa=None, fs=None):
    """A circular-arc corner (BOSL2 _circlecorner())."""
    from bosl2.shapes2d import _frag_count, arc

    angle = _vector_angle3(points[0], points[1], points[2]) / 2
    d, radius = float(parm[0]), float(parm[1])
    p1 = np.asarray(points[1], dtype=float)
    prev = unit(np.asarray(points[0], dtype=float) - p1)
    nxt = unit(np.asarray(points[2], dtype=float) - p1)
    start, end = p1 + prev * d, p1 + nxt * d
    if approx(angle, 90):
        return [list(start), list(end)]
    center = radius / math.sin(math.radians(angle)) * unit(prev + nxt) + p1
    sides = max(3, math.ceil((90 - angle) / 180 * _frag_count(radius, fn, fa, fs)))
    if len(points[1]) == 2:
        return [
            [float(c) for c in p]
            for p in arc(
                sides,
                center=[float(center[0]), float(center[1])],
                points=[
                    [float(start[0]), float(start[1])],
                    [float(end[0]), float(end[1])],
                ],
            )
        ]
    return _arc3d(center, start, end, sides)


# ---------------------------------------------------------------------------
# Section: round_corners
# ---------------------------------------------------------------------------


[docs] def round_corners( path, method: str = "circle", radius: float | None = None, cut=None, joint=None, width: float | None = None, k: float | None = None, closed: bool = True, fn: int | None = None, fa: float | None = None, fs: float | None = None, ): """Round every corner of *path* (BOSL2 round_corners()). *method* is ``"circle"`` (a constant-radius arc), ``"smooth"`` (a continuous-curvature bezier), or ``"chamfer"`` (a straight bevel). Size the roundover with exactly one of *radius*/*radius* (circle only), *cut* (depth toward the corner), *joint* (distance back from the corner along each edge), or *width* (chamfer only) -- each a scalar or a per-corner list. *k* (smooth only, 0..1) tunes how tight the curvature match is. Works on 2-D and 3-D paths. Returns: A :class:`~bosl2.paths.Path` (2-D) or :class:`~bosl2.paths.Path3D` (3-D). Examples: A rounded, smoothed and chamfered square (three copies): .. pythonscad-example:: sq = [[0, 0], [40, 0], [40, 40], [0, 40]] round_corners(sq, method="smooth", joint=10).polygon().linear_extrude(height=4).show() """ from bosl2.paths import Path, Path3D assert method in ("circle", "smooth", "chamfer"), 'method must be "circle", "smooth" or "chamfer".' given = [ (m, v) for m, v in ( ("radius", radius), ("cut", cut), ("joint", joint), ("width", width), ) if v is not None ] assert len(given) == 1, "Must give exactly one of radius, cut, joint or width." measure, size = given[0] pts = [[float(c) for c in p] for p in path] sides = len(pts) assert sides > 2, f"Path has length {sides}. Length must be 3 or more." assert method == "circle" or measure != "radius", 'radius is allowed only with method="circle".' assert method == "chamfer" or measure != "width", 'width is allowed only with method="chamfer".' if is_num(size): parm = [float(size)] * sides # type: ignore[arg-type] elif len(size) < sides: # type: ignore[arg-type] parm = [0.0] + [float(v) for v in size] + [0.0] # type: ignore[union-attr] else: parm = [float(v) for v in size] # type: ignore[union-attr] if k is None: kv = [0.5] * sides elif is_num(k): assert method == "smooth", 'k is only allowed with method="smooth".' kv = [float(k)] * sides # type: ignore[arg-type] else: assert method == "smooth", 'k is only allowed with method="smooth".' kv = ([0.0] + [float(v) for v in k] + [0.0]) if len(k) < sides else [float(v) for v in k] # type: ignore[arg-type] assert all(v >= 0 for v in parm), f"{measure} must be nonnegative." assert all(0 <= v <= 1 for v in kv), "k must be in [0, 1]." # dk[i] = [joint distance, shape param] per corner (chamfer has just [distance]) dk = [] for i in range(sides): p0, p1, p2 = pts[(i - 1) % sides], pts[i], pts[(i + 1) % sides] if (not closed and (i == 0 or i == sides - 1)) or parm[i] == 0: dk.append([0.0]) continue assert not (approx(p0, p1) or approx(p1, p2)), f"Repeated point in path at index {i} with nonzero rounding." angle = _vector_angle3(p0, p1, p2) / 2 assert not approx(angle, 0), f"Path turns back on itself at index {i} with nonzero rounding." ar = math.radians(angle) if method == "chamfer": dk.append( [ parm[i] if measure == "joint" else parm[i] / math.cos(ar) if measure == "cut" else parm[i] / math.sin(ar) / 2 ] ) # width elif method == "smooth": dk.append( [parm[i], kv[i]] if measure == "joint" else [8 * parm[i] / math.cos(ar) / (1 + 4 * kv[i]), kv[i]] ) # cut elif measure == "radius": dk.append([parm[i] / math.tan(ar), parm[i]]) elif measure == "joint": dk.append([parm[i], parm[i] * math.tan(ar)]) else: # circle + cut if approx(angle, 90): dk.append([math.inf]) else: cr = parm[i] / (1 / math.sin(ar) - 1) dk.append([cr / math.tan(ar), cr]) lengths = [ float(np.linalg.norm(np.asarray(pts[i % sides]) - np.asarray(pts[(i - 1) % sides]))) for i in range(sides + 1) ] scale = [] for i in range(sides): if closed or (i != 0 and i != sides - 1): a = lengths[i] / (dk[(i - 1) % sides][0] + dk[i][0]) if (dk[(i - 1) % sides][0] + dk[i][0]) else math.inf b = ( lengths[i + 1] / (dk[i][0] + dk[(i + 1) % sides][0]) if (dk[i][0] + dk[(i + 1) % sides][0]) else math.inf ) scale.append(min(a, b)) assert not scale or min(scale) >= 1 - 1e-9, "Roundovers are too big for the path (they overlap); reduce the sizes." out = [] for i in range(sides): corner = [pts[(i - 1) % sides], pts[i], pts[(i + 1) % sides]] if dk[i][0] == 0: out.append(pts[i]) elif method == "smooth": out += _bezcorner(corner, dk[i], fn=fn or 0, fs=fs or 2.0) elif method == "chamfer": out += _chamfcorner(corner, dk[i]) else: out += _circlecorner(corner, dk[i], fn=fn, fa=fa, fs=fs) result = _dedup(out) dim = len(result[0]) return (Path3D if dim == 3 else Path)(result, closed=closed)
def _dedup(pts, eps=1e-9): out = [] for p in pts: if not out or not approx(out[-1], p, eps): out.append([float(c) for c in p]) if len(out) > 1 and approx(out[0], out[-1], eps): out.pop() return out # --------------------------------------------------------------------------- # Section: smooth_path # ---------------------------------------------------------------------------
[docs] def smooth_path( path, tangents=None, size=None, relsize=None, splinesteps: int = 10, uniform: bool = False, closed: bool = False, ): """Fit a smooth continuous-curvature curve through *path* (BOSL2 smooth_path(), method="edges"). Runs a cubic bezier through every point of *path*, matching the path's tangents, and samples it with *splinesteps* points per segment. *size* / *relsize* bound how far the curve may bow away from the straight path (relsize is a fraction of each segment, default 0.1). The BOSL2 ``method="corners"`` variant is not ported. Returns: A :class:`~bosl2.paths.Path` (2-D) or :class:`~bosl2.paths.Path3D` (3-D). Examples: A wiggly control path smoothed into a flowing curve: .. pythonscad-example:: pts = [[0, 0], [10, 30], [30, -10], [50, 20], [70, 0]] smooth_path(pts, relsize=0.4).stroke(width=2).linear_extrude(height=3).show() """ from bosl2.beziers import Bezier from bosl2.paths import Path, Path3D bez = Bezier.from_path( path, closed=closed, tangents=tangents, size=size, relsize=relsize, uniform=uniform, ) smoothed = [[float(c) for c in p] for p in bez.path_curve(splinesteps=splinesteps)] if closed and len(smoothed) > 1 and approx(smoothed[0], smoothed[-1]): smoothed = smoothed[:-1] dim = len(smoothed[0]) return (Path3D if dim == 3 else Path)(smoothed, closed=closed)
# --------------------------------------------------------------------------- # Section: Roundable mixin # ---------------------------------------------------------------------------
[docs] class Roundable: """Mixin adding the rounding.scad path operators as methods on :class:`~bosl2.paths.Path` and :class:`~bosl2.paths.Path3D`."""
[docs] def round_corners( self, radius: float | None = None, method: str = "circle", cut=None, joint=None, width: float | None = None, k: float | None = None, closed: bool | None = None, **kwargs, ): """Round every corner of this path (see :func:`round_corners`).""" return round_corners( self, method=method, radius=radius, cut=cut, joint=joint, width=width, k=k, closed=self.closed if closed is None else closed, # type: ignore[attr-defined] **kwargs, )
[docs] def smooth_path( self, tangents=None, size=None, relsize=None, splinesteps: int = 10, uniform: bool = False, closed: bool | None = None, ): """Fit a smooth continuous-curvature curve through this path (see :func:`smooth_path`).""" return smooth_path( self, tangents=tangents, size=size, relsize=relsize, splinesteps=splinesteps, uniform=uniform, closed=self.closed if closed is None else closed, # type: ignore[attr-defined] )