# 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/miscellaneous.py
# Pure-Python port of BOSL2's miscellaneous.scad: extrusions (extrude_from_to, path_extrude2d,
# path_extrude, cylindrical_extrude), the bounding box, chain_hull, and the minkowski-based
# transforms (minkowski_difference, offset3d, round3d).
#
# The two path extrusions are methods on :class:`~bosl2.paths.Path` / :class:`~bosl2.paths.Path3D`
# via the :class:`Extrudable` mixin, and -- unlike BOSL2, which extrudes its *children* -- they
# take the 2-D cross-section as a *profile* argument: a native 2-D shape, a Path/Region, a
# Bosl2Solid wrapping 2-D geometry, or a zero-argument factory that returns fresh geometry (the
# "children" form; use a factory to avoid the frep handle-reuse segfault). The bbox/offset/round
# operators are methods on :class:`~bosl2.shapes3d.Bosl2Solid` via :class:`Miscellaneous`.
#
# Only matrix/vector math and bosl2.transforms/constants are imported at load time; native
# primitives, shapes3d, and skin.rot_resample are imported lazily, so shapes3d/paths can pull in
# the mixins during their own import without a cycle.
#
# FileSummary: Extrusions, bounding box, chain hull, and minkowski-based transforms.
# FileGroup: BOSL2
from __future__ import annotations
import math
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from bosl2.shapes3d import Bosl2Solid
import operator
from functools import reduce
import numpy as np
from bosl2._helpers import frame_map4_yz, rot_from_to4, unwrap, vec3
from bosl2.constants import BACK, UP
from bosl2.geometry import pointlist_bounds
from bosl2.transforms import axis_angle_matrix, rot_from_to
from bosl2.vectors import unit
__all__ = [
"extrude_from_to",
"cylindrical_extrude",
"chain_hull",
"minkowski_difference",
"Extrudable",
"Miscellaneous",
]
# ---------------------------------------------------------------------------
# Section: helpers
# ---------------------------------------------------------------------------
def _as_native_2d(profile):
"""A raw native 2-D shape from *profile* (a Bosl2Shape2D/Bosl2Solid wrapper, a native shape,
a Path, or a Region) -- see :func:`bosl2.shapes2d._as_native_2d`, which this defers to."""
from bosl2.shapes2d import _as_native_2d as _coerce
return _coerce(profile)
def _profile_factory(profile):
"""A zero-arg callable yielding native 2-D geometry -- a factory is called fresh each time
(the "children" form, safe for frep handles); anything else is meshed once and reused."""
from bosl2.shapes2d import Bosl2Shape2D
from bosl2.shapes3d import Bosl2Solid
if callable(profile) and not isinstance(profile, (list, tuple, Bosl2Solid, Bosl2Shape2D)):
return lambda: _as_native_2d(profile())
native = _as_native_2d(profile)
return lambda: native
def _point_left_of_line2d(p, a, b):
return float((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]))
def _vector_angle3(a, b, c):
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 _planar_half(shape, keep_positive_x, s):
"""Keep the x>=0 (or x<=0) half of a native 2-D *shape* (BOSL2 right_half/left_half planar)."""
from pythonscad import square as _square
strip = _square([s, 2 * s], center=True)
strip = strip.translate([s / 2 if keep_positive_x else -s / 2, 0])
return shape & strip
# ---------------------------------------------------------------------------
# Section: extrude_from_to / cylindrical_extrude (free functions)
# ---------------------------------------------------------------------------
[docs]
def extrude_from_to(
profile,
pt1,
pt2,
twist: float = 0,
scale: float = 1,
slices: int | None = None,
convexity: int = 10,
) -> Bosl2Solid:
"""Linearly extrude a 2-D *profile* between two 3-D points (BOSL2 extrude_from_to()).
The profile's origin is placed on *pt1* and *pt2*, oriented perpendicular to the line between
them. *profile* is a native 2-D shape, a Path/Region, a Bosl2Solid, or a factory.
Examples:
A twisted, tapering column between two points:
.. pythonscad-example::
extrude_from_to(s2.circle(radius=4), [0, 0, 0], [10, 20, 30], twist=180, scale=2).show()
"""
from bosl2.shapes3d import Bosl2Solid
p1, p2 = vec3(pt1), vec3(pt2)
diameter = p2 - p1
height = float(np.linalg.norm(diameter))
if height <= 0:
raise AssertionError("extrude_from_to(): the two points must differ.")
theta = math.degrees(math.atan2(diameter[1], diameter[0]))
phi = math.degrees(math.atan2(math.hypot(diameter[0], diameter[1]), diameter[2]))
native = _as_native_2d(profile)
kw = {
"height": height,
"center": False,
"twist": twist,
"scale": scale,
"convexity": convexity,
}
if slices is not None:
kw["slices"] = slices
solid = native.linear_extrude(**kw).rotate([0, phi, theta]).translate([float(c) for c in p1])
return Bosl2Solid(solid)
[docs]
def cylindrical_extrude(
profile,
inner_radius: float | None = None,
outer_radius: float | None = None,
outer_diameter: float | None = None,
inner_diameter: float | None = None,
size=None,
spin: float = 0,
orient=UP,
convexity: int = 10,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""Wrap a 2-D *profile* around a cylinder, from radius *inner_radius* out to *outer_radius* (BOSL2
cylindrical_extrude()).
Chops the profile into vertical facets and extrudes each radially. Handy for embossing text
onto a curved wall. The profile's X spans one revolution by default (override with *size*).
"""
from pythonscad import square as _square
from bosl2.shapes2d import _frag_count
from bosl2.shapes3d import Bosl2Solid
irv = inner_radius if inner_radius is not None else (inner_diameter / 2 if inner_diameter is not None else None)
orv = outer_radius if outer_radius is not None else (outer_diameter / 2 if outer_diameter is not None else None)
assert irv is not None and orv is not None and irv > 0 and orv > 0, (
"cylindrical_extrude(): give positive inner and outer radius/diameter."
)
circumf = 2 * math.pi * orv
if size is None:
size = [circumf, 1000.0]
elif isinstance(size, (int, float)):
size = [float(size), 1000.0]
else:
size = [float(size[0]), float(size[1])]
sides = _frag_count(orv, fn, fa, fs)
step = circumf / sides
steps = math.ceil(size[0] / step)
scalefactor = sides / math.pi * math.sin(math.radians(180 / sides))
native = _as_native_2d(profile)
facets = []
for i in range(steps):
x = (i + 0.5 - steps / 2) * step
clip = _square([max(step, 2**-15), size[1]], center=True)
slab = native.translate([-x, 0]) & clip
slab = slab.scale([scalefactor, 1]).mirror([0, 1])
wedge = slab.linear_extrude(height=orv - irv, scale=[irv / orv, 1], center=False, convexity=convexity)
wedge = wedge.rotate([-90, 0, 0]).translate([0, -orv * math.cos(math.radians(180 / sides)), 0])
wedge = wedge.rotate([0, 0, 360 * x / circumf])
facets.append(wedge)
solid = reduce(operator.or_, facets)
angle, axis = rot_from_to(UP, orient)
m = np.eye(4)
m[:3, :3] = axis_angle_matrix(angle, axis)
solid = solid.rotate([0, 0, spin]).multmatrix(m.tolist())
return Bosl2Solid(solid)
# ---------------------------------------------------------------------------
# Section: chain_hull / minkowski_difference (free functions)
# ---------------------------------------------------------------------------
[docs]
def chain_hull(*objects):
"""Union the hulls of each consecutive pair of *objects* (BOSL2 chain_hull())."""
from pythonscad import hull as _hull
from bosl2.shapes3d import Bosl2Solid
objs = list(objects[0]) if len(objects) == 1 and isinstance(objects[0], (list, tuple)) else list(objects)
assert objs, "chain_hull(): needs at least one object."
natives = [unwrap(o) for o in objs]
if len(natives) == 1:
return Bosl2Solid(natives[0])
hulls = [_hull(natives[i - 1], natives[i]) for i in range(1, len(natives))]
return Bosl2Solid(reduce(operator.or_, hulls))
[docs]
def minkowski_difference(base, *diffs, size: float = 1000, convexity: int = 10):
"""Carve *diffs* out of the surface of *base* (BOSL2 minkowski_difference())."""
from pythonscad import cube as _cube
from pythonscad import minkowski as _mink
from bosl2.shapes3d import Bosl2Solid
b = unwrap(base)
raw = list(diffs[0]) if len(diffs) == 1 and isinstance(diffs[0], (list, tuple)) else list(diffs)
# Diffs may arrive as Bosl2Solid wrappers; the native minkowski() only takes raw solids.
ds = [unwrap(d) for d in raw]
assert ds, "minkowski_difference(): needs at least one diff shape."
center, sz = Bosl2Solid(b).bounds() if isinstance(base, Bosl2Solid) else _native_bounds(b)
box0 = _cube([sz[i] for i in range(3)], center=True).translate([float(c) for c in center])
box1 = _cube([sz[i] + 2 for i in range(3)], center=True).translate([float(c) for c in center])
shell = box1 - b
carve = reduce(operator.or_, [_mink(shell, d) for d in ds]) if len(ds) > 1 else _mink(shell, ds[0])
return Bosl2Solid(box0 - carve)
def _native_bounds(shape):
from bosl2.shapes3d import Bosl2Solid
return Bosl2Solid(shape).bounds()
# ---------------------------------------------------------------------------
# Section: Extrudable mixin (Path / Path3D)
# ---------------------------------------------------------------------------
[docs]
class Extrudable:
"""Mixin adding path_extrude / path_extrude2d as methods on :class:`~bosl2.paths.Path` and
:class:`~bosl2.paths.Path3D`. Both take the 2-D cross-section as a *profile* argument instead
of OpenSCAD children (a native 2-D shape, a Path/Region, a Bosl2Solid, or a factory).
"""
[docs]
def path_extrude2d(self, profile, caps: bool = False, closed: bool | None = None, s=None, convexity: int = 10):
"""Extrude a 2-D *profile* along this 2-D path, standing it vertically (BOSL2 path_extrude2d()).
Builds a straight run for each segment and a revolved fillet at each corner, unioned into a
3-D "moulding" that follows the path. *caps* rounds the two open ends (the profile must be
symmetric across the Y axis); *closed* joins the ends into a loop; *s* is the internal mask
size (defaults to the path's bounding-box diagonal).
"""
from bosl2.shapes3d import Bosl2Solid
assert len(self[0]) == 2, "path_extrude2d(): the path must be 2-D (use path_extrude for 3-D)." # type: ignore[index]
is_closed = self.closed if closed is None else closed # type: ignore[attr-defined]
assert not (caps and is_closed), "path_extrude2d(): cannot cap a closed extrusion."
pts = [[float(p[0]), float(p[1])] for p in self.deduplicated()] # type: ignore[attr-defined]
sides = len(pts)
assert sides >= 2, "path_extrude2d(): need at least two points."
if s is None:
b = pointlist_bounds(pts)
s = float(np.linalg.norm(b[1] - b[0]))
factory = _profile_factory(profile)
parts = []
# straight segments
last = sides if is_closed else sides - 1
for i in range(last):
a = np.asarray(pts[i])
b = np.asarray(pts[(i + 1) % sides])
segv = b - a
seglen = float(np.linalg.norm(segv))
if seglen < 1e-9:
continue
block = factory().linear_extrude(height=seglen, center=True, convexity=convexity)
block = block.rotate([90, 0, 0]).multmatrix(rot_from_to4(BACK, [segv[0], segv[1], 0]).tolist())
block = block.translate([float((a[0] + b[0]) / 2), float((a[1] + b[1]) / 2), 0])
parts.append(block)
# corner fillets
ea = 0.1 # tiny overlap so the fillets fuse to the segments
idxs = range(sides) if is_closed else range(1, sides - 1)
for i in idxs:
t0, t1, t2 = pts[(i - 1) % sides], pts[i], pts[(i + 1) % sides]
angle = -(180 - _vector_angle3(t0, t1, t2)) * (1 if _point_left_of_line2d(t2, t0, t1) >= 0 else -1)
if abs(angle) < 1e-9:
continue
sgn = 1 if angle > 0 else -1
half = _planar_half(factory(), keep_positive_x=(angle < 0), s=s)
corner = half.rotate_extrude(angle=angle + sgn * ea)
corner = corner.rotate([0, 0, -sgn * ea / 2])
corner = corner.multmatrix(frame_map4_yz([t2[0] - t1[0], t2[1] - t1[1], 0], UP).tolist())
corner = corner.translate([t1[0], t1[1], 0])
parts.append(corner)
# rounded caps on the open ends
if caps and not is_closed:
for a, b in ((pts[0], pts[1]), (pts[-1], pts[-2])): # type: ignore[misc]
cap = _planar_half(factory(), keep_positive_x=True, s=s).rotate_extrude(angle=180)
cap = cap.multmatrix(rot_from_to4(BACK, [a[0] - b[0], a[1] - b[1], 0]).tolist())
cap = cap.translate([a[0], a[1], 0])
parts.append(cap)
assert parts, "path_extrude2d(): nothing to extrude."
return Bosl2Solid(reduce(operator.or_, parts))
[docs]
def path_extrude(self, profile, convexity: int = 10, clipsize: float = 100):
"""Extrude a 2-D *profile* along this path in 3-D (BOSL2 path_extrude()).
Places an oriented linear extrusion for each segment and clips it at the mitre planes
between segments. A 2-D Path is lifted to the ``z=0`` plane first. For most sweeps
:func:`~bosl2.skin.path_sweep` is faster and cleaner; this exists for extruding an arbitrary
native 2-D object (text, multi-part shapes) that is not a single polygon.
"""
from pythonscad import cube as _cube
from bosl2.shapes3d import Bosl2Solid
from bosl2.skin import rot_resample
dim = len(self[0]) # type: ignore[index]
path: list[list[float]] = [[float(p[0]), float(p[1]), float(p[2]) if dim == 3 else 0.0] for p in self] # type: ignore[attr-defined]
sides = len(path)
assert sides >= 2, "path_extrude(): need at least two points."
parr = [np.asarray(p) for p in path]
rotmats = []
acc = np.eye(4)
for i in range(sides - 1):
vec1 = np.asarray(UP, dtype=float) if i == 0 else unit(parr[i] - parr[i - 1])
vec2 = unit(parr[i + 1] - parr[i])
# left-multiply so each frame maps local +Z exactly onto its segment direction
# (frame_i @ UP == dir_i); this is the discrete rotation-minimizing frame.
acc = rot_from_to4(vec1, vec2) @ acc
rotmats.append(acc)
interp = rot_resample(rotmats, sides=2, method="count")
eps = 1e-4
factory = _profile_factory(profile)
parts = []
for i in range(sides - 1):
pt1, pt2 = parr[i], parr[i + 1]
dist = float(np.linalg.norm(pt2 - pt1))
if dist < 1e-9:
continue
t = rotmats[i]
ext = (
factory()
.linear_extrude(height=dist + clipsize / 2, convexity=convexity)
.translate([0, 0, -clipsize / 4])
.multmatrix(t.tolist())
.translate([float(c) for c in pt1])
)
hq_start = np.asarray(interp[2 * i - 1]) if i > 0 else t
hq_end = np.asarray(interp[2 * i + 1]) if i < sides - 2 else t
c1 = (
_cube([clipsize] * 3, center=True)
.translate([0, 0, -(clipsize / 2 + eps)])
.multmatrix(hq_start.tolist())
.translate([float(c) for c in pt1])
)
c2 = (
_cube([clipsize] * 3, center=True)
.translate([0, 0, clipsize / 2 + eps])
.multmatrix(hq_end.tolist())
.translate([float(c) for c in pt2])
)
parts.append((ext - c1) - c2)
assert parts, "path_extrude(): nothing to extrude."
return Bosl2Solid(reduce(operator.or_, parts))
# ---------------------------------------------------------------------------
# Section: Miscellaneous mixin (Bosl2Solid)
# ---------------------------------------------------------------------------
[docs]
class Miscellaneous(ABC):
"""Mixin adding bounding_box / offset3d / round3d / chain_hull / minkowski_difference as methods
on :class:`~bosl2.shapes3d.Bosl2Solid`."""
@abstractmethod
def _wrap(self, new_shape): # pragma: no cover - provided by the host class (Bosl2Solid)
"""Re-wrap a native shape as the host solid type."""
raise NotImplementedError
[docs]
def bounding_box(self, excess: float = 0):
"""The smallest axis-aligned cuboid containing this solid, grown by *excess* (BOSL2 bounding_box()).
Uses the native bounding box, so it is exact and fast (BOSL2's projection/minkowski trick is
not needed here)."""
from bosl2.shapes3d import cuboid
center, size = self.bounds() # type: ignore[attr-defined]
return cuboid([size[i] + 2 * excess for i in range(3)]).translate([float(c) for c in center])
[docs]
def offset3d(self, radius: float, size: float = 1000, convexity: int = 10):
"""Expand (or, for negative *radius*, contract) the surface of this solid by *radius* (BOSL2 offset3d()).
Uses ``minkowski()`` with a sphere and is *very* slow; use sparingly."""
from pythonscad import cube as _cube
from pythonscad import minkowski as _mink
from pythonscad import sphere as _sphere
from bosl2.shapes2d import _frag_count
if radius == 0:
return self
sides = max(8, _frag_count(abs(radius)))
sides = int(math.ceil(sides / 4) * 4)
if radius > 0:
return self._wrap(_mink(self.shape, _sphere(radius, fn=sides))) # type: ignore[attr-defined]
big1 = _cube([size * 1.02] * 3, center=True)
big2 = _cube([size] * 3, center=True)
return self._wrap(big2 - _mink(big1 - self.shape, _sphere(-radius, fn=sides))) # type: ignore[attr-defined]
[docs]
def round3d(
self,
radius: float | None = None,
outer_radius: float | None = None,
inner_radius: float | None = None,
size: float = 1000,
):
"""Round the corners of this solid (BOSL2 round3d()): *radius* rounds all, *outer_radius* only convex,
*inner_radius* only concave. Uses ``offset3d`` three times and is extremely slow."""
orr = outer_radius if outer_radius is not None else (radius if radius is not None else 0)
irr = inner_radius if inner_radius is not None else (radius if radius is not None else 0)
return self.offset3d(orr, size=size).offset3d(-irr - orr, size=size).offset3d(irr, size=size)
[docs]
def chain_hull(self, *others):
"""This solid chain-hulled with *others*, in order (see :func:`chain_hull`)."""
return chain_hull(self, *others)
[docs]
def minkowski_difference(self, *diffs, size: float = 1000):
"""Carve *diffs* out of this solid's surface (see :func:`minkowski_difference`)."""
return minkowski_difference(self, *diffs, size=size)