# 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))