Source code for pybosl2.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
# DocCategory: Paths, regions & surfaces

# LibFile: pybosl2/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:`~pybosl2.paths.Path2D` and :class:`~pybosl2.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:`~pybosl2.beziers.Bezier`; the circle corners reuse :func:`~pybosl2.shapes2d.arc` (2-D) or a
#    slerp arc (3-D).
#
#

"""Path-rounding core: round_corners and smooth_path (BOSL2 rounding.scad)."""

from __future__ import annotations

import math
from typing import TYPE_CHECKING, Any, Sequence, cast

if TYPE_CHECKING:
    from shapely.geometry import MultiPolygon

    from pybosl2.path2d import Path2D
    from pybosl2.paths import Path

import numpy as np

from pybosl2._helpers import is_num
from pybosl2.caps import CapsSpec, CapType
from pybosl2.enums import Measure, RoundingMethod, VNFStyle
from pybosl2.math import EPSILON

# Late imports to avoid circular dependencies
from pybosl2.vectors import unit

__all__ = [
    "Roundable",
]


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


def _vector_angle3(a: Sequence[float], b: Sequence[float], c: Sequence[float]) -> float:
    """Return 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: Sequence[Sequence[float]], k: float) -> list[list[float]]:
    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: Sequence[Sequence[float]], parm: float | Sequence[float], fn: int = 0, fs: float = 2.0
) -> list[list[float]]:
    """Return a continuous-curvature (bezier) corner (BOSL2 _bezcorner())."""
    from pybosl2.beziers import Bezier

    if isinstance(parm, (list, tuple, np.ndarray)):
        d, k = float(parm[0]), float(parm[1])
        p1 = [float(points[1][i]) for i in range(len(points[1]))]
        dim = len(p1)
        prev = unit([float(points[0][i]) - p1[i] for i in range(dim)])
        nxt = unit([float(points[2][i]) - p1[i] for i in range(dim)])
        ctrl = [
            [p1[i] + d * prev[i] for i in range(dim)],
            [p1[i] + k * d * prev[i] for i in range(dim)],
            p1,
            [p1[i] + k * d * nxt[i] for i in range(dim)],
            [p1[i] + d * nxt[i] for i in range(dim)],
        ]
    else:
        ctrl = _smooth_bez_fill(points, float(parm))  # type: ignore[arg-type]
    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.arc_length() / fs))
    return [[float(c) for c in p] for p in bez.curve(sides, endpoint=True)]


def _chamfcorner(points: Sequence[Sequence[float]], parm: Sequence[float]) -> list[list[float]]:
    """Return a straight chamfer across a corner (BOSL2 _chamfcorner())."""
    diameter = float(parm[0])
    p1 = [float(points[1][i]) for i in range(len(points[1]))]
    dim = len(p1)
    prev = unit([float(points[0][i]) - p1[i] for i in range(dim)])
    nxt = unit([float(points[2][i]) - p1[i] for i in range(dim)])
    return [
        [p1[i] + prev[i] * diameter for i in range(dim)],
        [p1[i] + nxt[i] * diameter for i in range(dim)],
    ]


def _arc3d(center: Sequence[float], start: Sequence[float], end: Sequence[float], n: int) -> list[list[float]]:
    """*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
    dot_v: float = float(np.dot(v0, v1))
    denom: float = float(np.linalg.norm(v0) * np.linalg.norm(v1))
    cos_angle: float = max(-1.0, min(1.0, dot_v / denom))
    angle = math.acos(cos_angle)
    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: Sequence[Sequence[float]],
    parm: Sequence[float],
    fn: int | None = None,
    fa: float | None = None,
    fs: float | None = None,
) -> list[list[float]]:
    """Return a circular-arc corner (BOSL2 _circlecorner())."""
    from pybosl2._helpers import frag_count as _frag_count
    from pybosl2.shapes2d import arc

    angle = _vector_angle3(points[0], points[1], points[2]) / 2
    d, radius = float(parm[0]), float(parm[1])
    p1 = [float(points[1][i]) for i in range(len(points[1]))]
    dim = len(p1)
    prev = unit([float(points[0][i]) - p1[i] for i in range(dim)])
    nxt = unit([float(points[2][i]) - p1[i] for i in range(dim)])
    start = [p1[i] + prev[i] * d for i in range(dim)]
    end = [p1[i] + nxt[i] * d for i in range(dim)]
    if math.isclose(angle, 90, rel_tol=0, abs_tol=EPSILON):
        return [start, end]
    sum_vec = [prev[i] + nxt[i] for i in range(dim)]
    u = unit(sum_vec)
    scale = radius / math.sin(math.radians(angle))
    center = [scale * u[i] + p1[i] for i in range(dim)]
    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
# ---------------------------------------------------------------------------


def _round_corners(
    path: Sequence[Sequence[float]],
    method: RoundingMethod = RoundingMethod.CIRCLE,
    radius: float | Sequence[float] | None = None,
    cut: float | Sequence[float] | None = None,
    joint: float | Sequence[float] | None = None,
    width: float | Sequence[float] | None = None,
    curvature: float | Sequence[float] | None = None,
    closed: bool = True,
    fn: int | None = None,
    fa: float | None = None,
    fs: float | None = None,
    k: float | Sequence[float] | None = None,
) -> object:
    """Round every corner of *path* (internal implementation).

    Public API: use :meth:`Path.round_corners` instead of calling this directly.
    """
    from pybosl2.path2d import Path2D
    from pybosl2.path3d import Path3D

    curv_val = curvature if curvature is not None else k  # `k` is BOSL2's name for curvature

    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"Path2D has length {sides}. Length must be 3 or more."
    assert method == RoundingMethod.CIRCLE or measure != Measure.RADIUS, 'radius is allowed only with method="circle".'
    assert method == RoundingMethod.CHAMFER or measure != Measure.WIDTH, 'width is allowed only with method="chamfer".'

    if is_num(size):
        parm = [float(size)] * sides  # type: ignore[arg-type]
    elif isinstance(size, (list, tuple, np.ndarray)):
        parm = [0.0] + [float(v) for v in size] + [0.0] if len(size) < sides else [float(v) for v in size]
    if curv_val is None:
        kv = [0.5] * sides
    elif curv_val is not None and is_num(curv_val):
        assert method == RoundingMethod.SMOOTH, 'k is only allowed with method="smooth".'
        kv = [float(cast("float", curv_val))] * sides
    elif isinstance(curv_val, (list, tuple, np.ndarray)):
        assert method == RoundingMethod.SMOOTH, 'k is only allowed with method="smooth".'
        kv = ([0.0] + [float(v) for v in curv_val] + [0.0]) if len(curv_val) < sides else [float(v) for v in curv_val]
    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 np.allclose(p0, p1, rtol=0, atol=EPSILON), (
            f"Repeated point in path at index {i} with nonzero rounding."
        )
        assert not np.allclose(p1, p2, rtol=0, atol=EPSILON), (
            f"Repeated point in path at index {i} with nonzero rounding."
        )
        angle = _vector_angle3(p0, p1, p2) / 2
        assert not math.isclose(angle, 0, rel_tol=0, abs_tol=EPSILON), (
            f"Path2D turns back on itself at index {i} with nonzero rounding."
        )
        ar = math.radians(angle)
        if method == RoundingMethod.CHAMFER:
            dk.append(
                [
                    (
                        parm[i]
                        if measure == Measure.JOINT
                        else (parm[i] / math.cos(ar) if measure == Measure.CUT else parm[i] / math.sin(ar) / 2)
                    )
                ]
            )  # width
        elif method == RoundingMethod.SMOOTH:
            dk.append(
                [parm[i], kv[i]] if measure == Measure.JOINT else [8 * parm[i] / math.cos(ar) / (1 + 4 * kv[i]), kv[i]]
            )  # cut
        elif measure == Measure.RADIUS:
            dk.append([parm[i] / math.tan(ar), parm[i]])
        elif measure == Measure.JOINT:
            dk.append([parm[i], parm[i] * math.tan(ar)])
        else:  # circle + cut
            if math.isclose(angle, 90, rel_tol=0, abs_tol=EPSILON):
                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 == RoundingMethod.SMOOTH:
            out += _bezcorner(corner, dk[i], fn=fn or 0, fs=fs or 2.0)
        elif method == RoundingMethod.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 Path2D)(result, closed=closed)


def _dedup(pts: Sequence[Sequence[float]], eps: float = 1e-9) -> list[list[float]]:
    out: list[list[float]] = []
    for p in pts:
        if not out or not np.allclose(out[-1], p, rtol=0, atol=eps):
            out.append([float(c) for c in p])
    if len(out) > 1 and np.allclose(out[0], out[-1], rtol=0, atol=eps):
        out.pop()
    return out


# ---------------------------------------------------------------------------
# Section: smooth_path
# ---------------------------------------------------------------------------


def _smooth_path(
    path: Sequence[Sequence[float]],
    tangents: Sequence[Sequence[float]] | None = None,
    size: float | Sequence[float] | None = None,
    relsize: float | None = None,
    splinesteps: int = 10,
    uniform: bool = False,
    closed: bool = False,
) -> object:
    """Fit a smooth continuous-curvature curve through *path* (internal implementation).

    Public API: use :meth:`Path.smooth_path` instead of calling this directly.
    """
    from pybosl2.beziers import create_bezier
    from pybosl2.path2d import Path2D
    from pybosl2.path3d import Path3D

    bez = create_bezier(
        path,  # type: ignore[arg-type]
        closed=closed,
        tangents=tangents,  # type: ignore[arg-type]
        size=size,  # type: ignore[arg-type]
        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 np.allclose(smoothed[0], smoothed[-1], rtol=0, atol=EPSILON):
        smoothed = smoothed[:-1]
    dim = len(smoothed[0])
    return (Path3D if dim == 3 else Path2D)(smoothed, closed=closed)


# ---------------------------------------------------------------------------
# Section: Roundable mixin
# ---------------------------------------------------------------------------


[docs] class Roundable: """Mixin adding the rounding.scad path operators as methods on :class:`~pybosl2.paths.Path2D` and. :class:`~pybosl2.paths.Path3D`. """
[docs] def round_corners( self, radius: float | None = None, method: RoundingMethod = RoundingMethod.CIRCLE, cut: float | Sequence[float] | None = None, joint: float | Sequence[float] | None = None, width: float | None = None, curvature: float | None = None, closed: bool | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, k: float | None = None, ) -> object: """Round every corner of this 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* (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. *curvature* (smooth only, 0..1) tunes how tight the curvature match is. Works on 2-D and 3-D paths. Returns: A :class:`~pybosl2.paths.Path2D` (2-D) or :class:`~pybosl2.paths.Path3D` (3-D). Examples: A rounded, smoothed and chamfered square (three copies): .. pythonscad-example:: from pybosl2 import Path2D, Path3D from pybosl2.enums import RoundingMethod sq = [[0, 0], [40, 0], [40, 40], [0, 40]] Path2D(sq).round_corners(method=RoundingMethod.SMOOTH, joint=10).polygon().linear_extrude( height=4 ).show() A 2-D path with circle-rounded corners: .. pythonscad-example:: from pybosl2 import Path2D, Path3D from pybosl2.enums import RoundingMethod path = Path2D([[0, 0], [20, 0], [20, 10], [10, 15], [0, 10]]) path.round_corners(method=RoundingMethod.CIRCLE, radius=3).polygon().linear_extrude(height=5).show() """ path_self = cast("Path", self) return _round_corners( cast("Sequence[Sequence[float]]", self), method=method, radius=radius, cut=cut, joint=joint, width=width, curvature=curvature, closed=path_self.closed if closed is None else closed, fn=fn, fa=fa, fs=fs, k=k, )
[docs] def smooth_path( self, tangents: Sequence[Sequence[float]] | None = None, size: float | Sequence[float] | None = None, relsize: float | None = None, splinesteps: int = 10, uniform: bool = False, closed: bool | None = None, ) -> object: """Fit a smooth continuous-curvature curve through this path (BOSL2 smooth_path(), method="edges"). Runs a cubic bezier through every point, 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:`~pybosl2.paths.Path2D` (2-D) or :class:`~pybosl2.paths.Path3D` (3-D). Examples: A wiggly control path smoothed into a flowing curve: .. pythonscad-example:: from pybosl2 import Path2D, Path3D pts = [[0, 0], [10, 30], [30, -10], [50, 20], [70, 0]] Path2D(pts).smooth_path(relsize=0.4).stroke(width=2).linear_extrude(height=3).show() A sawtooth path smoothed with explicit size and relsize: .. pythonscad-example:: from pybosl2 import Path2D, Path3D path = Path2D([[0, 0], [10, 5], [20, 0], [30, 10]]) path.smooth_path(relsize=0.1).stroke(width=1).linear_extrude(height=3).show() """ path_self = cast("Path", self) return _smooth_path( cast("Sequence[Sequence[float]]", self), tangents=tangents, size=size, relsize=relsize, splinesteps=splinesteps, uniform=uniform, closed=path_self.closed if closed is None else closed, )
[docs] def offset_stroke( self, width: float = 1.0, closed: bool | None = None, endcap: CapType = CapType.ROUND, joint: CapType = CapType.ROUND, ) -> object: """Offset this 2-D path to create a thickened outline Region (BOSL2 offset_stroke()).""" path_self = cast("Path", self) return _offset_stroke( cast("Sequence[Sequence[float]]", self), width=width, closed=path_self.closed if closed is None else closed, endcap=endcap, joint=joint, )
[docs] def offset_sweep( self, height: float, bottom: object = None, top: object = None, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> object: """Offset sweep/extrusion of this 2-D shape (BOSL2 offset_sweep()).""" from pybosl2.skin import _offset_sweep as _os return _os( cast("Sequence[Sequence[float]]", self), height=height, bottom=bottom, top=top, steps=steps, caps=caps, style=style, )
[docs] def convex_offset_extrude( self, height: float, bottom: object = None, top: object = None, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> object: """Offset sweep/extrusion of this 2-D shape (BOSL2 convex_offset_extrude()).""" from pybosl2.skin import _convex_offset_extrude as _coe return _coe( cast("Sequence[Sequence[float]]", self), height=height, bottom=bottom, top=top, steps=steps, caps=caps, style=style, )
[docs] def rounded_prism( self, top: Sequence[Sequence[float]] | None = None, height: float | None = None, joint_top: float | dict[str, object] | None = None, joint_bottom: float | dict[str, object] | None = None, joint_sides: float | list[float] | None = None, curvature_sides: float | list[float] | None = None, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, joint_bot: float | dict[str, object] | None = None, k_sides: float | list[float] | None = None, ) -> object: """Return the rounded prism between this path and a top path (BOSL2 rounded_prism()).""" from pybosl2.skin import _rounded_prism as _rp # joint_bot / k_sides are BOSL2's names for joint_bottom / curvature_sides j_bot = joint_bottom if joint_bottom is not None else joint_bot k_sides = curvature_sides if curvature_sides is not None else k_sides return _rp( cast("Sequence[Sequence[float]]", self), top=top, height=height, joint_top=joint_top, joint_bottom=j_bot, joint_sides=joint_sides, curvature_sides=k_sides, steps=steps, caps=caps, style=style, )
[docs] def join_prism( self, height: float, fillet: float = 0.0, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> object: """Join this prism to a base plane with a filleted transition (BOSL2 join_prism()).""" from pybosl2.skin import _join_prism as _jp return _jp( cast("Sequence[Sequence[float]]", self), height=height, fillet=fillet, steps=steps, caps=caps, style=style, )
[docs] def prism_connector( self, length: float, fillet: float = 0.0, fillet1: float | None = None, fillet2: float | None = None, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> object: """Construct a filleted prism connecting two objects (BOSL2 prism_connector()).""" from pybosl2.skin import _prism_connector as _pc return _pc( cast("Sequence[Sequence[float]]", self), length=length, fillet=fillet, fillet1=fillet1, fillet2=fillet2, steps=steps, caps=caps, style=style, )
[docs] def attach_prism( self, length: float, fillet: float = 0.0, rounding: float = 0.0, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> object: """Attach a filleted prism with optional rounded end (BOSL2 attach_prism()).""" from pybosl2.skin import _attach_prism as _ap return _ap( cast("Sequence[Sequence[float]]", self), length=length, fillet=fillet, rounding=rounding, steps=steps, caps=caps, style=style, )
[docs] def bent_cutout_mask( self, radius: float, thickness: float, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> object: """Create a mask to generate a round-edged cutout in a cylindrical shell (BOSL2 bent_cutout_mask()).""" from pybosl2.skin import _bent_cutout_mask as _bcm return _bcm( radius=radius, thickness=thickness, path=cast("Sequence[Sequence[float]]", self), style=style, )
[docs] def path_join( self, other_paths: Sequence[Sequence[Sequence[float]]], radius: float | list[float] | None = None, cut: float | list[float] | None = None, joint: float | list[float] | None = None, curvature: float | list[float] | None = None, relocate: bool = True, closed: bool | None = None, k: float | list[float] | None = None, ) -> object: """Join multiple paths to this path end-to-end (see :func:`path_join`).""" return _path_join( [self] + list(other_paths), # type: ignore[arg-type] radius=radius, cut=cut, joint=joint, curvature=curvature, relocate=relocate, closed=self.closed if closed is None else closed, # type: ignore[attr-defined] k=k, )
def _path_join( paths: Sequence[Sequence[Sequence[float]]], radius: float | list[float] | None = None, cut: float | list[float] | None = None, joint: float | list[float] | None = None, curvature: float | list[float] | None = None, relocate: bool = True, closed: bool = False, k: float | list[float] | None = None, ) -> Any: """Join multiple paths end-to-end with optional rounding at the joint connections (BOSL2 path_join()). Consecutive endpoints are merged if they are within a tolerance (and *relocate* is True). The joints between adjacent paths are rounded using the same options as :func:`round_corners`. Args: paths: A sequence of 2-D or 3-D paths (each a sequence of points). radius: Rounding radius at joints (mutually exclusive with cut/joint). cut: Cut parameter for joint rounding. joint: Joint parameter for joint rounding. curvature: Continuous curvature (smooth) parameter for joints. relocate: Merge consecutive endpoints if they are close (default True). closed: Close the resulting joined path (default False). k: Acronym alias for *curvature*. **kwargs: Additional keyword arguments (e.g. ``k`` for curvature). Returns: A :class:`~pybosl2.paths.Path2D` or :class:`~pybosl2.paths.Path3D` depending on the input dimensions. """ from pybosl2.path2d import Path2D as _Path from pybosl2.path3d import Path3D as _Path3D curv_val = curvature if curvature is not None else k # `k` is BOSL2's name for curvature if not paths: return _Path([]) # Concatenate paths pts = [list(map(float, pt)) for pt in paths[0]] joint_indices = [] for p in paths[1:]: p_pts = [list(map(float, pt)) for pt in p] if not p_pts: continue if relocate and np.allclose(pts[-1], p_pts[0], atol=1e-9): joint_indices.append(len(pts) - 1) pts.extend(p_pts[1:]) else: joint_indices.append(len(pts) - 1) pts.extend(p_pts) if closed and len(pts) > 2: if relocate and np.allclose(pts[-1], pts[0], atol=1e-9): pts.pop() joint_indices.append(len(pts) - 1) # Determine dimension dim = len(pts[0]) cls = _Path3D if dim == 3 else _Path # If no rounding requested, return the joined path as-is given = [ (m, v) for m, v in ( ("radius", radius), ("cut", cut), ("joint", joint), ) if v is not None ] if not given: return cls(pts, closed=closed) measure, size = given[0] # Build a per-corner size list sides = len(pts) size_list = [0.0] * sides # Map input size to joint indices if isinstance(size, (list, tuple, np.ndarray)): # Assign elements sequentially to the joints for i, idx in enumerate(joint_indices): if i < len(size): size_list[idx] = float(size[i]) else: for idx in joint_indices: size_list[idx] = float(size) # Do the same for k if given k_list: list[float] | None = None curv_val = curvature if curvature is not None else k # `k` is BOSL2's name for curvature if curv_val is not None: k_list = [0.5] * sides if isinstance(curv_val, (list, tuple, np.ndarray)): for i, idx in enumerate(joint_indices): if i < len(curv_val): k_list[idx] = float(curv_val[i]) else: for idx in joint_indices: k_list[idx] = float(curv_val) # Call round_corners with the per-corner sizes, on whichever measure was given sizes: dict[str, list[float] | None] = {"radius": None, "cut": None, "joint": None, "width": None} sizes[measure] = size_list return _round_corners( pts, radius=sizes["radius"], cut=sizes["cut"], joint=sizes["joint"], width=sizes["width"], closed=closed, k=k_list, ) def _from_shapely(geom: "MultiPolygon") -> list[Path2D]: """Extract paths (exterior + holes) from a shapely geometry. Handles ``Polygon`` and ``MultiPolygon`` by taking the largest polygon. Args: geom: A ``shapely.Polygon`` or ``shapely.MultiPolygon``. Returns: A list of :class:`~pybosl2.paths.Path2D` objects: outer ring, then holes. """ from shapely.geometry import MultiPolygon, Polygon from pybosl2.path2d import Path2D as _Path if geom.is_empty: return [] if isinstance(geom, MultiPolygon): geom = max(geom.geoms, key=lambda g: g.area) if not isinstance(geom, Polygon): return [] paths: list[_Path] = [] exterior = list(geom.exterior.coords)[:-1] paths.append(_Path([[float(x), float(y)] for x, y in exterior])) for interior in geom.interiors: ring = list(interior.coords)[:-1] paths.append(_Path([[float(x), float(y)] for x, y in ring])) return paths def _offset_stroke( path: Sequence[Sequence[float]], width: float = 1.0, closed: bool = False, endcap: CapType = CapType.ROUND, joint: CapType = CapType.ROUND, ) -> Any: """Offset a 2-D path by *width* to create a thickened outline Region (BOSL2 offset_stroke()). If *closed* is True, the path is treated as a closed loop. When :mod:`shapely` is installed, returns a :class:`Region` containing the coordinates of the outline. Without shapely, falls back to PythonSCAD geometry (CSG shape). """ from shapely.geometry import LineString from pybosl2.path2d import Path2D as _Path from pybosl2.regions import Region # Coerce to Path2D p = path if isinstance(path, _Path) else _Path(path) pts = [(float(pt[0]), float(pt[1])) for pt in p] if not pts: return Region([]) # Map endcap/join style to shapely integer constants cap_map: dict[CapType, int] = {CapType.ROUND: 1, CapType.BUTT: 2, CapType.SQUARE: 3} join_map: dict[CapType, int] = {CapType.ROUND: 1, CapType.SQUARE: 3} c_style = cap_map.get(endcap, 1) j_style = join_map.get(joint, 1) # For a closed loop, append first point to ensure it's closed line = LineString(pts + [pts[0]]) if closed and len(pts) > 1 and pts[0] != pts[-1] else LineString(pts) geom = line.buffer(width / 2.0, cap_style=c_style, join_style=j_style) return Region(_from_shapely(geom))