Source code for bosl2.miscellaneous

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