Source code for pybosl2.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: pybosl2/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:`~pybosl2.paths.Path2D` / :class:`~pybosl2.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 Path2D/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:`~pybosl2.shapes3d.Bosl2Solid` via :class:`Miscellaneous`.
#
#    Only matrix/vector math and pybosl2.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.
# DocCategory: Extras
# FileGroup: BOSL2

"""Extrusions, bounding box, chain hull, and minkowski-based transforms."""

from __future__ import annotations

import math
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

    from pybosl2.shapes3d import Bosl2Solid
import operator
from functools import reduce

import numpy as np

from pybosl2._edges_lang import Anchor
from pybosl2._helpers import frame_map4_yz, rot_from_to4, unwrap, vec3
from pybosl2._helpers import pick_radius as _pick_radius
from pybosl2.constants import BACK, UP
from pybosl2.enums import ResampleMethod
from pybosl2.exceptions import Bosl2ValueError
from pybosl2.geometry import vector_angle3 as _vector_angle3
from pybosl2.transforms import axis_angle_matrix, rot_from_to
from pybosl2.vectors import unit

__all__ = [
    "extrude_from_to",
    "cylindrical_extrude",
    "chain_hull",
    "minkowski_difference",
    "Extrudable",
]


# ---------------------------------------------------------------------------
# Section: helpers
# ---------------------------------------------------------------------------


def _as_native_2d(profile: object) -> Any:
    """Return a raw native 2-D shape from *profile* (a Bosl2Shape2D/Bosl2Solid wrapper, a native shape,.

    a Path2D, or a Region) -- see :func:`pybosl2.shapes2d._as_native_2d`, which this defers to.
    """
    from pybosl2._helpers import as_native_2d as _coerce

    return _coerce(profile)


def _profile_factory(profile: object) -> Callable[[], Any]:
    """Return 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 pybosl2.shapes2d import Bosl2Shape2D
    from pybosl2.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: Sequence[float], a: Sequence[float], b: Sequence[float]) -> float:
    return float((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]))


def _planar_half(shape: Any, keep_positive_x: bool, s: float) -> Any:
    """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: object, pt1: Sequence[float], pt2: Sequence[float], 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. 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 Path2D/Region, a Bosl2Solid, or a factory. Args: profile: The cross-section to sweep or extrude. pt1: The first point. pt2: The second point. twist: Total twist in degrees. scale: Scale applied along the extrusion. slices: How many intermediate sections to insert. convexity: Convexity hint for the renderer. Examples: A twisted, tapering column between two points: .. pythonscad-example:: from pybosl2 import shapes2d as s2, extrude_from_to extrude_from_to(s2.circle(radius=4), [0, 0, 0], [10, 20, 30], twist=180, scale=2).show() """ from pybosl2.shapes3d import Bosl2Solid p1, p2 = vec3(pt1), vec3(pt2) diameter = [p2[i] - p1[i] for i in range(3)] height = math.hypot(math.hypot(diameter[0], diameter[1]), diameter[2]) if height <= 0: raise Bosl2ValueError("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: object, inner_radius: float | None = None, outer_radius: float | None = None, outer_diameter: float | None = None, inner_diameter: float | None = None, size: float | Sequence[float] | None = None, spin: float = 0, orient: Anchor | Sequence[float] = 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*). Args: profile: The 2-D shape to wrap around the cylinder. inner_radius: Radius the wrapped profile starts at. outer_radius: Radius the wrapped profile reaches. outer_diameter: Outer diameter, instead of *outer_radius*. inner_diameter: Inner diameter, instead of *inner_radius*. size: Size of the flat region the profile is drawn in before wrapping. spin: Z-axis rotation in degrees after anchor. orient: Direction to rotate the top towards, after spin. convexity: Convexity hint for the renderer; higher for more self-overlapping profiles. 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. Examples: .. pythonscad-example:: from pybosl2 import shapes2d as s2, cylindrical_extrude cylindrical_extrude(s2.circle(radius=8), inner_radius=25, outer_radius=30).show() """ from pythonscad import square as _square from pybosl2._helpers import frag_count as _frag_count from pybosl2.shapes3d import Bosl2Solid irv = _pick_radius(radius=inner_radius, diameter=inner_diameter, dflt=None) orv = _pick_radius(radius=outer_radius, diameter=outer_diameter, dflt=None) if not (irv is not None): raise Bosl2ValueError("cylindrical_extrude(): give positive inner and outer radius/diameter.") if not (orv is not None): raise Bosl2ValueError("cylindrical_extrude(): give positive inner and outer radius/diameter.") if not (irv > 0): raise Bosl2ValueError("cylindrical_extrude(): give positive inner and outer radius/diameter.") if not (orv > 0): raise Bosl2ValueError("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) orient_vec: Sequence[float] = orient.vector if isinstance(orient, Anchor) else orient angle, axis = rot_from_to(UP.vector, orient_vec) 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: object) -> Bosl2Solid: """Union the hulls of each consecutive pair of *objects*. Examples: .. pythonscad-example:: from pybosl2.solid import sphere from pybosl2 import chain_hull chain_hull([sphere(radius=5).translate([x * 20, 0, 0]) for x in range(4)]).show() """ from pythonscad import hull as _hull from pybosl2.shapes3d import Bosl2Solid objs = list(objects[0]) if len(objects) == 1 and isinstance(objects[0], (list, tuple)) else list(objects) if not objs: raise Bosl2ValueError("chain_hull(): needs at least one shape to hull.") 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: object, *diffs: object, size: float = 1000, convexity: int = 10) -> Bosl2Solid: """Carve *diffs* out of the surface of *base*. Args: base: The shape to carve out of. *diffs: The shapes to subtract from its surface. size: The size, one number or one per axis. convexity: Convexity hint for the renderer. """ _ = (size, convexity) from pythonscad import cube as _cube from pythonscad import minkowski as _mink from pybosl2.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] if not (ds): raise Bosl2ValueError("minkowski_difference(): needs at least one diff shape.") center, sz = Bosl2Solid(b)._center_size() 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: Any) -> tuple[list[float], list[float]]: from pybosl2.shapes3d import Bosl2Solid return Bosl2Solid(shape)._center_size() # --------------------------------------------------------------------------- # Section: Extrudable mixin (Path2D / Path3D) # ---------------------------------------------------------------------------
[docs] class Extrudable: """Mixin adding path_extrude / path_extrude2d as methods on :class:`~pybosl2.paths.Path2D` and. :class:`~pybosl2.paths.Path3D`. Both take the 2-D cross-section as a *profile* argument instead of OpenSCAD children (a native 2-D shape, a Path2D/Region, a Bosl2Solid, or a factory). """
[docs] def path_extrude2d( self, profile: object, caps: bool = False, closed: bool | None = None, s: float | None = None, convexity: int = 10, ) -> Bosl2Solid: """Extrude a 2-D *profile* along this 2-D path, standing it vertically. 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). Args: profile: The cross-section to sweep or extrude. caps: Close the open ends. closed: Treat the path as closed. s: The scale factor. convexity: Convexity hint for the renderer. """ from pybosl2.shapes3d import Bosl2Solid if not (len(self[0]) == 2): # type: ignore[index] raise Bosl2ValueError("path_extrude2d(): the path must be 2-D (use path_extrude for 3-D).") is_closed = self.closed if closed is None else closed # type: ignore[attr-defined] if caps and is_closed: raise Bosl2ValueError("path_extrude2d(): cannot cap a closed extrusion.") pts = [[float(p[0]), float(p[1])] for p in self.deduplicate()] # type: ignore[attr-defined] sides = len(pts) if not (sides >= 2): raise Bosl2ValueError("path_extrude2d(): need at least two points.") if s is None: min_x = min(p[0] for p in pts) min_y = min(p[1] for p in pts) max_x = max(p[0] for p in pts) max_y = max(p[1] for p in pts) s = math.hypot(max_x - min_x, max_y - min_y) factory = _profile_factory(profile) parts = [] # straight segments last = sides if is_closed else sides - 1 for i in range(last): a = pts[i] b = pts[(i + 1) % sides] segv = [float(b[0]) - float(a[0]), float(b[1]) - float(a[1])] seglen = math.hypot(segv[0], segv[1]) 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])): 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) if not parts: # pragma: no cover - defensive: a path short enough to build nothing is # already rejected above ("need at least two points"), and a degenerate one fails in # the frame maths before reaching here. Kept so the failure would name the call. raise Bosl2ValueError("path_extrude2d(): nothing to extrude.") return Bosl2Solid(reduce(operator.or_, parts))
[docs] def path_extrude( self, profile: object, convexity: int = 10, clipsize: float = 100, ) -> Bosl2Solid: """Extrude a 2-D *profile* along this path in 3-D. Places an oriented linear extrusion for each segment and clips it at the mitre planes between segments. A 2-D Path2D is lifted to the ``z=0`` plane first. For most sweeps :func:`~pybosl2.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. Args: profile: The cross-section to sweep or extrude. convexity: Convexity hint for the renderer. clipsize: Size of the clipping box used to trim the result. """ from pythonscad import cube as _cube from pybosl2.shapes3d import Bosl2Solid from pybosl2.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) if not (sides >= 2): raise Bosl2ValueError("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.vector, dtype=float) if i == 0 else unit(list(parr[i] - parr[i - 1])) vec2 = unit(list(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([list(m) for m in rotmats], num_copies=2, method=ResampleMethod.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) if not parts: # pragma: no cover - defensive: a path short enough to build nothing is # already rejected above ("need at least two points"), and a degenerate one fails in # the frame maths before reaching here. Kept so the failure would name the call. raise Bosl2ValueError("path_extrude(): nothing to extrude.") return Bosl2Solid(reduce(operator.or_, parts))
# --------------------------------------------------------------------------- # Section: Miscellaneous mixin (Bosl2Solid) # ---------------------------------------------------------------------------