Source code for pybosl2.shapes3d.extrusions

# 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/shapes3d/extrusions.py
# FileSummary: Text3d, path_text, cross and extrusion-related math helpers.
# DocCategory: Foundational
# FileGroup: BOSL2

from __future__ import annotations

import math
from typing import TYPE_CHECKING, Any

import numpy as np

from pybosl2._edges_lang import Anchor
from pybosl2._native import native

if TYPE_CHECKING:
    from collections.abc import Sequence

    from pybosl2.path2d import Path2D
    from pybosl2.path3d import Path3D
    from pybosl2.shapes2d import Bosl2Shape2D
from pybosl2.constants import CENTER
from pybosl2.shapes2d import text as _text2d
from pybosl2.vectors import is_vector, unit

# Import base class and helper functions from shapes3d.base
from .base import (
    Bosl2Solid,
    _anchor_offset_box3,
    _finish3,
)

if TYPE_CHECKING:  # real stub-typed imports for the checker (identical to pre-lazy)
    from pythonscad import cube as _ocube
    from pythonscad import cylinder as _ocylinder_native
    from pythonscad import hull as _ohull
    from pythonscad import minkowski as _ominkowski
    from pythonscad import polyhedron as _opolyhedron
    from pythonscad import rotate_extrude as _orotate_extrude
    from pythonscad import sphere as _osphere_native
    from pythonscad import textmetrics as _otextmetrics
else:
    _ocube = native("cube")
    _ocylinder_native = native("cylinder")
    _ohull = native("hull")
    _ominkowski = native("minkowski")
    _opolyhedron = native("polyhedron")
    _orotate_extrude = native("rotate_extrude")
    _osphere_native = native("sphere")
    _otextmetrics = native("textmetrics")


def _interior_fillet_path(radius: float, angle: float, overlap: float, sides: int) -> list[list[float]]:
    """The 2-D cross-section of an interior_fillet(): the wedge bounded by the corner point, the
    two tangent points on each wall (distance radius/tan(angle/2) from the corner), and the concave arc
    of radius *radius* joining them (center at distance radius/sin(angle/2) from the corner along the
    bisector) -- the generalization to arbitrary *angle* of the classic `cube() - cylinder()`
    quarter-round fillet at angle=90. Each straight wall edge is extended *overlap* past the ideal
    corner point so the piece unions cleanly onto both adjoining faces instead of meeting them at
    an exact, potentially non-manifold, edge.
    """
    from pybosl2._helpers import arc_points as _arc_points

    half = math.radians(angle / 2)
    tlen = radius / math.tan(half) if radius > 0 else 0.0
    p0 = [tlen, 0.0]
    p1 = [tlen * math.cos(math.radians(angle)), tlen * math.sin(math.radians(angle))]
    flap0 = [-overlap, 0.0]
    flap1 = [
        -overlap * math.cos(math.radians(angle)),
        -overlap * math.sin(math.radians(angle)),
    ]
    if radius <= 0:
        return [flap0, p0, p1, flap1]

    dist = radius / math.sin(half)
    center = [dist * math.cos(half), dist * math.sin(half)]
    start_a = math.degrees(math.atan2(p0[1] - center[1], p0[0] - center[0]))
    end_a = math.degrees(math.atan2(p1[1] - center[1], p1[0] - center[0]))
    sweep = ((end_a - start_a + 180) % 360) - 180
    arc_n = max(2, round(sides * abs(sweep) / 360)) + 1
    arc = _arc_points(arc_n, radius, start_a, sweep, center)
    return [flap0] + arc + [flap1]


# ---------------------------------------------------------------------------
# Section: Text
# ---------------------------------------------------------------------------


def _text3d_anchor_vec(anchor: "Anchor | Sequence[float] | str") -> list[float]:
    """Extracts a 3-vector from an `anchor` argument that may be a plain vector or (to
    accommodate this port's unusual `anchor: str = "baseline[-1,0,-1]"` default) a string
    with a bracketed `[x,y,z]` vector embedded in it. Falls back to LEFT if no vector can
    be found in a string anchor, matching BOSL2's own `default(anchor, center?CENTER:LEFT)`.
    """
    if isinstance(anchor, str):
        i = anchor.find("[")
        j = anchor.find("]")
        if i >= 0 and j > i:
            return [float(x) for x in anchor[i + 1 : j].split(",")]
        return [-1.0, 0.0, 0.0]
    if isinstance(anchor, Anchor):
        anchor = list(anchor.vector)
    return [float(x) for x in anchor]


def _frame_map(
    x: Sequence[float] | None = None,
    y: Sequence[float] | None = None,
    z: Sequence[float] | None = None,
) -> list[list[float]]:
    """Port of BOSL2's frame_map(): builds the 4x4 change-of-basis matrix whose columns are
    the (up to) two given unit axes plus the third axis completed via cross product, matching
    BOSL2's exact axis-completion rules (used by path_text() to orient each glyph).
    """
    xu = unit(x) if x is not None else None
    yu = unit(y) if y is not None else None
    zu = unit(z) if z is not None else None
    if xu is None:
        m = [np.cross(yu, zu), yu, zu]  # type: ignore[arg-type]
    elif yu is None:
        m = [xu, np.cross(zu, xu), zu]  # type: ignore[arg-type]
    elif zu is None:
        m = [xu, yu, np.cross(xu, yu)]
    else:
        m = [xu, yu, zu]
    return [
        [m[0][0], m[1][0], m[2][0], 0.0],  # type: ignore[index]
        [m[0][1], m[1][1], m[2][1], 0.0],  # type: ignore[index]
        [m[0][2], m[1][2], m[2][2], 0.0],  # type: ignore[index]
        [0.0, 0.0, 0.0, 1.0],
    ]


def _point3d(v: Sequence[float]) -> list[float]:
    return list(v) if len(v) >= 3 else [v[0], v[1], 0.0]


def _cut_interp(
    pathcut: list[Any], path: Sequence[Sequence[float]] | Path2D | Path3D, data: Sequence[Sequence[float]]
) -> list[list[float]]:
    """Port of BOSL2's `_cut_interp()`: linearly interpolates a per-path-vertex vector array
    `data` to the fractional position of each `cut_points()` cut point.
    """
    out = []
    for entry in pathcut:
        idx = entry.next_index
        a = path[idx - 1]
        b = path[idx]
        c = entry.point
        i = max(range(len(b)), key=lambda k: abs(b[k] - a[k]))
        factor = (c[i] - a[i]) / (b[i] - a[i])
        out.append([(1 - factor) * da + factor * db for da, db in zip(data[idx - 1], data[idx], strict=False)])
    return out


def _path_text_bcast_dir(
    v: object, dim: int, path: Sequence[Sequence[float]] | Path2D | Path3D, label: str
) -> list[list[float]] | None:
    """Broadcasts a `normal=`/`top=` argument (undefined, a single vector, or a per-path-point
    list of vectors) to a list of one vector per path point, mirroring BOSL2's normalok/topok
    argument checks (including the "3-vector with z==0 on a 2d path" compatibility form).
    """
    if v is None:
        return None
    if is_vector(v, dim):  # type: ignore[arg-type]
        return [list(v)] * len(path)  # type: ignore[call-overload]
    if dim == 2 and is_vector(v, 3) and abs(v[2]) < 1e-9:  # type: ignore[arg-type,index]
        return [[v[0], v[1]]] * len(path)  # type: ignore[index]
    if isinstance(v, list) and len(v) == len(path) and all(is_vector(p, dim) for p in v):
        return [list(p) for p in v]
    raise ValueError(
        f'path_text(): "{label}" must be a length-{dim} vector or a list of {len(path)} such vectors matching the path.'
    )


[docs] def text3d( text: str, height: float = 1, size: float = 10, font: str = "Liberation Sans", halign: str | None = None, valign: str | None = None, spacing: float = 1.0, direction: str = "ltr", language: str = "em", script: str = "latin", anchor: str = "baseline[-1,0,-1]", spin: float = 0, orient: Anchor | Sequence[float] = Anchor.TOP, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """BOSL2 text3d() -- 3-D extruded text, with anchor/spin/orient support. Args: text: text to create height: extrusion height (default 1) size: font size divided by 0.72 (default 10) font: font to use (default "Liberation Sans") halign: horizontal alignment: "left", "center", "right" (overrides anchor) valign: vertical alignment: "top", "center", "baseline", "bottom" (overrides anchor) spacing: relative spacing multiplier between characters (default 1.0) direction: text direction: "ltr", "rtl", "ttb", "btt" (default "ltr") language: language the text is in (default "en") script: script the text is in (default "latin") anchor: anchor point (default "baseline") spin: Z-axis rotation in degrees (default 0) orient: direction to rotate the top towards (default UP) Examples: .. pythonscad-example:: from pybosl2 import text3d text3d("BOSL2", size=10, height=3).show() """ av = _text3d_anchor_vec(anchor) ha = halign if halign is not None else ("left" if av[0] < 0 else "right" if av[0] > 0 else "center") va = valign if valign is not None else ("bottom" if av[1] < 0 else "top" if av[1] > 0 else "baseline") flat = _text2d( text, size=size, font=font, halign=ha, valign=va, spacing=spacing, direction=direction, language=language, script=script, fn=fn, fa=fa, fs=fs, ) # .shape: _text2d() hands back a Bosl2Shape2D, but everything below works on raw natives # (_finish3) and the result is wrapped once, at the end. shape = flat.shape.linear_extrude(height=height, center=True, fn=fn, fa=fa, fs=fs) offset = _anchor_offset_box3([size, size, height], [0, 0, av[2]]) return _finish3(shape, offset, spin, orient, size=None, anchor=av)
[docs] def path_text( path: Path2D | Path3D, text: str, font: str = "Liberation Sans", size: float = 10, thickness: float | None = None, lettersize: float | Sequence[float] | None = None, offset: float = 0, reverse: bool = False, normal: Sequence[float] | list[list[float]] | None = None, top: Sequence[float] | list[list[float]] | None = None, center: bool = False, textmetrics: bool = False, kern: float | Sequence[float] = 0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """BOSL2 path_text() -- places text characters along a path. Args: path: path to place the text on text: text to create font: font to use (default "Liberation Sans") size: font size divided by 0.72 (default 10) thickness: thickness of the letters (not allowed for a 2-D path) lettersize: scalar or array giving the size of the letters center: center text on the path instead of starting at the first point (default False) offset: distance to shift letters "up" towards the reader (default 0, 3-D paths only) normal: direction(s) pointing towards the reader of the text (3-D paths only) top: direction(s) pointing towards the top of the text reverse: reverse the letters if true (default False, 3-D paths only) textmetrics: use the experimental textmetrics feature when lettersize is not given (default False) kern: scalar or array giving per-letter size adjustments (default 0) """ # Imported lazily (only path_text() needs it) so that everything else in this file stays # free of a numpy dependency -- pybosl2.paths uses numpy internally, and numpy isn't always # loadable inside the real PythonSCAD app (e.g. a hardened-runtime-signed build combined # with an ad-hoc-signed/unsigned numpy install fails library validation). assert len(text) > 0, "path_text(): text must be non-empty." assert size > 0, "path_text(): must give positive text size." assert normal is None or top is None, 'path_text(): cannot define both "normal" and "top".' dim = len(path[0]) assert dim in (2, 3), "path_text(): must supply a 2d or 3d path." if dim == 2: assert thickness is None, "path_text(): cannot give a thickness with a 2d path." assert not reverse, "path_text(): reverse not allowed with a 2d path." assert offset == 0, "path_text(): cannot give offset with a 2d path." assert normal is None, 'path_text(): cannot define "normal" for a 2d path, only "top".' th = 1.0 if thickness is None else thickness sides = len(text) if lettersize is not None: lsize = [float(lettersize)] * sides if isinstance(lettersize, (int, float)) else [float(v) for v in lettersize] assert len(lsize) == sides, "path_text(): lettersize list must have one entry per character." elif textmetrics: lsize = [_otextmetrics(ch, font=font, size=size)["advance"][0] for ch in text] else: raise AssertionError("path_text(): textmetrics disabled -- must specify lettersize.") kern_list = [float(kern)] * (sides - 1) if isinstance(kern, (int, float)) else [float(v) for v in kern] assert len(kern_list) == sides - 1, "path_text(): kern must be a scalar or a list of length len(text)-1." centers = [] prefix = 0.0 kern_prefix = 0.0 for i in range(sides): centers.append(prefix + kern_prefix + lsize[i] / 2.0) prefix += lsize[i] if i < sides - 1: kern_prefix += kern_list[i] textlength = prefix + kern_prefix plen = path.perimeter() assert textlength <= plen, "path_text(): path is too short for the text." start = (plen - textlength) / 2.0 if center else 0.0 dists = [start + c for c in centers] pts = path.cut_points(dists, direction=True) normal_pv = _path_text_bcast_dir(normal, 3, path, "normal") top_pv = _path_text_bcast_dir(top, dim, path, "top") if normal_pv is None: sign = 1.0 if reverse else -1.0 normpts = [[sign * v for v in p.normal] for p in pts] # type: ignore[union-attr] else: normpts = _cut_interp(pts, path, normal_pv) toppts = None if top_pv is None else _cut_interp(pts, path, top_pv) _usetop = top_pv is not None usernorm = normal_pv is not None letters = [] for i, ch in enumerate(text): tangent = pts[i].direction if toppts is not None: tt = toppts[i] proj = sum(a * b for a, b in zip(tangent, tt, strict=False)) / sum(v * v for v in tt) # type: ignore[arg-type] adjustment = [proj * v for v in tt] elif usernorm: nn = normpts[i] proj = sum(a * b for a, b in zip(tangent, nn, strict=False)) / sum(v * v for v in nn) # type: ignore[arg-type] adjustment = [proj * v for v in nn] else: adjustment = [0.0] * dim x_axis = [tangent[k] - adjustment[k] for k in range(dim)] # type: ignore[index] # .shape: the letters are composed as raw natives and wrapped once, at the end. glyph = ( _text2d(ch, size=size, font=font, halign="left", valign="baseline", fn=fn, fa=fa, fs=fs) .translate([-lsize[i] / 2.0, 0]) .shape ) if dim == 3: z_axis = None if toppts is not None else normpts[i] y_axis = toppts[i] if toppts is not None else None m = _frame_map(x=x_axis, y=y_axis, z=z_axis) letter = glyph.linear_extrude(height=th, fn=fn, fa=fa, fs=fs).translate([0.0, 0.0, offset - th / 2.0]) else: y_axis = toppts[i] if toppts is not None else [-v for v in normpts[i]] m = _frame_map(x=_point3d(x_axis), y=_point3d(y_axis)) letter = glyph letters.append(letter.multmatrix(m).translate(pts[i].point)) result = letters[0] for s in letters[1:]: result = result | s return Bosl2Solid(result, size=None, anchor=CENTER)
def _s2cross( size: float | Sequence[float] = [10, 10], arm_width: float | Sequence[float] | None = None, ) -> "Bosl2Shape2D": """Return the 2‑D cross polygon as a Bosl2Shape2D (internal helper for the 3‑D cross).""" from pybosl2.shapes2d import cross as _cross2d return _cross2d(size=size, arm_width=arm_width)
[docs] def cross( size: float | Sequence[float] = [10, 10], height: float | None = None, arm_width: float | Sequence[float] | None = None, length: float | None = None, center: bool | None = None, anchor: Anchor | Sequence[float] = Anchor.CENTER, spin: float = 0, orient: Anchor | Sequence[float] = Anchor.TOP, ) -> Bosl2Solid: """A 3-D cross (plus) shape: two perpendicular centred rectangular prisms. Builds from the 2‑D :func:`~pybosl2.shapes2d.cross` polygon linear-extruded to *height*. Args: size: overall XY size, a scalar square or ``[width, length]`` (default ``[10, 10]``). height: Z-axis thickness (mutually exclusive with *length*). arm_width: width of each arm; a scalar or ``[horizontal, vertical]`` pair. When *None* (default) the arms are one-third of the overall size. length: alias for *height*. center: centre alignment (default True). anchor: anchor point (default Anchor.CENTER). spin: Z-axis rotation in degrees after anchor (default 0). orient: direction to rotate the top towards, after spin (default Anchor.TOP). Returns: A :class:`Bosl2Solid`. Examples: .. pythonscad-example:: from pybosl2 import cross cross(size=30, height=5).show() """ h = height if height is not None else length assert h and h > 0, "cross(): need a positive height or length." use_center = center if center is not None else True use_anchor = anchor if center is not None: use_anchor = Anchor.CENTER if center else Anchor.BOTTOM sz2d = [float(size)] * 2 if isinstance(size, (int, float)) else [float(size[0]), float(size[1])] sz3d = [sz2d[0], sz2d[1], float(h)] profile = _s2cross(size=sz2d, arm_width=arm_width) body = profile.linear_extrude(height=float(h), center=use_center) offset = _anchor_offset_box3(sz3d, use_anchor) return _finish3(body.shape, offset, spin, orient, size=sz3d, anchor=use_anchor)