# 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, Self, Sequence, cast
if TYPE_CHECKING:
from shapely.geometry import MultiPolygon
from pybosl2._backend import Solid
from pybosl2.path2d import Path2D
from pybosl2.paths import Path
from pybosl2.regions import Region
from pybosl2.vnf import VNF
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
# ---------------------------------------------------------------------------
from pybosl2.exceptions import Bosl2ValueError
from pybosl2.geometry import vector_angle3 as _vector_angle3
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."""
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."""
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."""
d, radius = float(parm[0]), float(parm[1])
if len(points[1]) == 2:
from pybosl2.path2d import Path2D
return Path2D._circlecorner([[float(c) for c in p] for p in points], d, radius, fn, fa, fs)
angle = _vector_angle3(points[0], points[1], points[2]) / 2
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)]
from pybosl2._helpers import frag_count as _frag_count
sides = max(3, math.ceil((90 - angle) / 180 * _frag_count(radius, fn, fa, fs)))
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
]
if len(given) != 1:
raise Bosl2ValueError("round_corners(): 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)
if sides <= 2:
raise Bosl2ValueError(f"round_corners(): needs a path of 3 or more points to round; got {sides}.")
if method != RoundingMethod.CIRCLE and measure == Measure.RADIUS:
raise Bosl2ValueError(
'round_corners(): radius= is allowed only with method="circle"; use cut=/joint=/width= instead.'
)
if method != RoundingMethod.CHAMFER and measure == Measure.WIDTH:
raise Bosl2ValueError('round_corners(): 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):
if not (method == RoundingMethod.SMOOTH):
raise Bosl2ValueError('k is only allowed with method="smooth".')
kv = [float(cast("float", curv_val))] * sides
elif isinstance(curv_val, (list, tuple, np.ndarray)):
if not (method == RoundingMethod.SMOOTH):
raise Bosl2ValueError('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]
if not (all((v >= 0 for v in parm))):
raise Bosl2ValueError(f"{measure} must be nonnegative.")
if not (all((0 <= v <= 1 for v in kv))):
raise Bosl2ValueError("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
if np.allclose(p0, p1, rtol=0, atol=EPSILON):
raise Bosl2ValueError(f"Repeated point in path at index {i} with nonzero rounding.")
if np.allclose(p1, p2, rtol=0, atol=EPSILON):
raise Bosl2ValueError(f"Repeated point in path at index {i} with nonzero rounding.")
angle = _vector_angle3(p0, p1, p2) / 2
if math.isclose(angle, 0, rel_tol=0, abs_tol=EPSILON):
raise Bosl2ValueError(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))
if not (not scale or min(scale) >= 1 - 1e-09):
raise Bosl2ValueError("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]]:
from pybosl2.path2d import Path2D
return [list(p) for p in Path2D._deduplicate(pts, closed=True, eps=eps)]
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
def _as_solid(mesh: "VNF | Solid") -> "Solid":
"""Realize a mesh as a solid on the active backend (SPEC S-19a) -- see :func:`pybosl2.skin._as_solid`."""
from pybosl2.skin import _as_solid as _convert
return _convert(mesh)
[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,
) -> Self:
"""Round every corner of this path.
*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.
Args:
radius: The rounding radius. A single float applies to all corners; a list applies per-corner radii.
method: The rounding method (``"circle"``, ``"smooth"``, etc.).
cut: Cut depth for chamfers.
joint: Joint distance for rounding.
width: Width for rounding.
curvature: Curvature value for rounding.
closed: Override whether paths are treated as closed.
fn: Fixed number of fragments per full circle; ambient default when omitted. Omitted, the ambient
``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs.
fa: Minimum fragment angle in degrees. Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: Minimum fragment size in millimetres. Omitted, the ambient ``use_defaults(fs=...)`` value applies.
k: Smoothing parameter for continuous-curvature rounding, from 0 (sharp) to 1.
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)
result = _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,
)
if hasattr(self, "_color") and self._color is not None:
result._color = self._color # type: ignore[attr-defined]
return cast("Self", result)
[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,
) -> Self:
"""Fit a smooth continuous-curvature curve through this path.
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.
Args:
tangents: Tangent directions, one per point, instead of deriving them.
size: The size, one number or one per axis.
relsize: Corner size as a fraction of the shorter adjacent segment.
splinesteps: How many segments each bezier is flattened into.
uniform: Sample by arc length rather than by parameter.
closed: Treat the path as closed.
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 cast(
"Self",
_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,
) -> "Region":
"""Offset this 2-D path to create a thickened outline Region.
Args:
width: Width of the result.
closed: Treat the path as closed.
endcap: Treatment applied to the ends.
joint: Rounding size as the distance along each leg from the corner.
"""
path_self = cast("Path", self)
return cast(
"Region",
_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 | None = None,
caps: CapsSpec = CapType.BUTT,
style: VNFStyle = VNFStyle.MIN_EDGE,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> "Solid":
"""Offset sweep/extrusion of this 2-D shape.
Args:
height: Extrusion height.
bottom: Rim treatment for the bottom edge (an ``os_*`` profile).
top: Rim treatment for the top edge.
steps: Slices per rim treatment; resolved from the rim radius and the ambient facet
controls when omitted.
caps: End caps for the extrusion.
style: Quad-subdivision style for the mesh.
fn: Fixed fragment count for the rim arcs; ambient default when omitted. Omitted, the ambient
``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs.
fa: Minimum fragment angle for the rim arcs. Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: Minimum fragment size for the rim arcs. Omitted, the ambient ``use_defaults(fs=...)`` value applies.
Returns:
The extruded solid.
"""
from pybosl2.skin import _offset_sweep as _os
return _as_solid(
_os(
cast("Sequence[Sequence[float]]", self),
height=height,
bottom=bottom,
top=top,
fn=fn,
fa=fa,
fs=fs,
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,
) -> "Solid":
"""Offset sweep/extrusion of this 2-D shape.
Args:
height: Height of the result.
bottom: Treatment applied to the bottom.
top: Treatment applied to the top.
steps: How many segments the rounded corner is built from.
caps: Close the open ends.
style: How each grid cell is split into triangles.
"""
from pybosl2.skin import _convex_offset_extrude as _coe
return _as_solid(
_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,
) -> "Solid":
"""Return the rounded prism between this path and a top path.
Args:
top: Treatment applied to the top.
height: Height of the result.
joint_top: Joint distance at the top.
joint_bottom: Joint distance at the bottom.
joint_sides: Joint distance on the side edges.
curvature_sides: Continuous-curvature smoothness on the side edges, from 0 to 1.
steps: How many segments the rounded corner is built from.
caps: Close the open ends.
style: How each grid cell is split into triangles.
joint_bot: Joint distance at the bottom.
k_sides: Continuous-curvature smoothness on the side edges, from 0 to 1.
"""
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 _as_solid(
_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,
) -> "Solid":
"""Join this prism to a base plane with a filleted transition.
Args:
height: Height of the result.
fillet: Fillet radius.
steps: How many segments the rounded corner is built from.
caps: Close the open ends.
style: How each grid cell is split into triangles.
"""
from pybosl2.skin import _join_prism as _jp
return _as_solid(
_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,
) -> "Solid":
"""Construct a filleted prism connecting two objects.
Args:
length: Length of the result.
fillet: Fillet radius.
fillet1: Fillet radius at the start.
fillet2: Fillet radius at the end.
steps: How many segments the rounded corner is built from.
caps: Close the open ends.
style: How each grid cell is split into triangles.
"""
from pybosl2.skin import _prism_connector as _pc
return _as_solid(
_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 | None = None,
caps: CapsSpec = CapType.BUTT,
style: VNFStyle = VNFStyle.MIN_EDGE,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> "Solid":
"""Attach a filleted prism with optional rounded end.
Args:
length: Length of the prism.
fillet: Fillet radius where the prism meets the surface.
rounding: Rounding radius on the free end.
steps: Slices per fillet/rounding arc; resolved from the radius and the ambient facet
controls when omitted.
caps: End caps for the prism.
style: Quad-subdivision style for the mesh.
fn: Fixed fragment count for the arcs; ambient default when omitted. Omitted, the ambient
``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs.
fa: Minimum fragment angle for the arcs. Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: Minimum fragment size for the arcs. Omitted, the ambient ``use_defaults(fs=...)`` value applies.
Returns:
The prism solid.
"""
from pybosl2.skin import _attach_prism as _ap
return _as_solid(
_ap(
cast("Sequence[Sequence[float]]", self),
length=length,
fillet=fillet,
rounding=rounding,
steps=steps,
fn=fn,
fa=fa,
fs=fs,
caps=caps,
style=style,
)
)
[docs]
def bent_cutout_mask(
self,
radius: float,
thickness: float,
style: VNFStyle = VNFStyle.MIN_EDGE,
) -> "Solid":
"""Create a mask to generate a round-edged cutout in a cylindrical shell.
Args:
radius: Rounding radius.
thickness: Wall thickness.
style: How each grid cell is split into triangles.
"""
from pybosl2.skin import _bent_cutout_mask as _bcm
return _as_solid(
_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,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Self:
"""Join multiple paths to this path end-to-end (see :func:`path_join`).
Args:
other_paths: The further paths to join onto this one, in order.
radius: Rounding radius at each join, one value or one per join.
cut: Rounding size given as the cut distance from the corner instead of a radius.
joint: Rounding size given as the joint distance along each leg instead of a radius.
curvature: Continuous-curvature smoothness at each join, from 0 (sharp) to 1.
relocate: Move each path so its start meets the previous path's end, rather than requiring them to already
touch.
closed: Join the last path back to the first. Defaults to this path's own flag.
k: Smoothing parameter for the continuous-curvature joins, one value or one per join.
fn: Fixed fragment count for curved surfaces. Omitted, the ambient ``use_defaults(fn=...)`` value applies;
``fn=0`` opts back out to fa/fs.
fa: Minimum fragment angle in degrees. Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: Minimum fragment size in millimetres. Omitted, the ambient ``use_defaults(fs=...)`` value applies.
"""
return cast(
"Self",
_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,
fn=fn,
fa=fa,
fs=fs,
),
)
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,
fn: int | None = None,
fa: float | None = None,
fs: 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*.
fn: Fixed fragment count for the joint rounding; ambient default when omitted.
fa: Minimum fragment angle for the joint rounding.
fs: Minimum fragment size for the joint rounding.
**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,
fn=fn,
fa=fa,
fs=fs,
)
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))