# 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/shapes3d.py
# Every shape in this file (cube, cuboid, prismoid, octahedron, wedge, cylinder, cyl, xcyl,
# ycyl, zcyl, sphere, spheroid, rect_tube, tube, pie_slice, torus, teardrop, onion, text3d,
# path_text, interior_fillet, heightfield, cylindrical_heightfield, ruler) is a pure-Python
# port with no osuse()/BOSL2 runtime dependency at all: each shape is built directly from
# openscad primitives (cube()/cylinder()/sphere()/polyhedron()/hull()/minkowski()/
# rotate_extrude()/linear_extrude()/text()) rather than delegating to BOSL2. cuboid()'s edge
# rounding/chamfering mirrors BOSL2's own algorithm, which is itself CSG composition (union/
# intersection/difference/hull of primitive shapes at each corner), not raw polyhedron mesh
# math -- see BOSL2's shapes3d.scad module cuboid() for the reference algorithm this was
# ported from. heightfield()/cylindrical_heightfield() build their mesh directly via
# polyhedron() from a computed vertex/face grid (see _heightfield_polyhedron()).
#
# FileSummary: Attachable cubes, cylinders, spheres, text and rulers (BOSL2 shapes3d.scad).
# FileGroup: BOSL2
from __future__ import annotations
import math
import numbers
from collections.abc import Sequence
from typing import TYPE_CHECKING
import numpy as np
from bosl2._native import native
if TYPE_CHECKING:
from openscad import PyOpenSCAD # noqa: F401
from bosl2.shapes2d import Bosl2Shape2D # noqa: F401
from bosl2._backend import check_operand_backend as _check_operand_backend
from bosl2._backend import unsupported_feature as _unsupported_feature
from bosl2.color import Colorable
from bosl2.distributors import Distributable
from bosl2.geometry import cross
from bosl2.miscellaneous import Miscellaneous
from bosl2.partitions import Partitionable
from bosl2.paths import Path
from bosl2.vectors import is_vector, unit
from .constants import BOTTOM, CENTER, DOWN, FRONT, LEFT, UP
from .shapes2d import _frag_count, _pick_radius
from .shapes2d import text as _text2d
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 _ocylinder(
height=None,
radius=None,
radius1=None,
radius2=None,
center=None,
fn=None,
fa=None,
fs=None,
):
"""The native cylinder, accepting this file's full-word kwargs (native wants h/r/radius1/radius2)."""
kw = {}
for full, nat in (
(height, "h"),
(radius, "r"),
(radius1, "r1"),
(radius2, "r2"),
(center, "center"),
(fn, "fn"),
(fa, "fa"),
(fs, "fs"),
):
if full is not None:
kw[nat] = full
return _ocylinder_native(**kw)
def _osphere(radius=None, center=None, fn=None, fa=None, fs=None):
"""The native sphere, accepting this file's full-word kwargs (native wants r)."""
kw = {}
for full, nat in (
(radius, "r"),
(center, "center"),
(fn, "fn"),
(fa, "fa"),
(fs, "fs"),
):
if full is not None:
kw[nat] = full
return _osphere_native(**kw)
def _as_native_3d(obj) -> "PyOpenSCAD":
"""A raw native handle from *obj*: a :class:`Bosl2Solid` / ``Bosl2Shape2D`` wrapper, a native
shape, or anything exposing ``geometry()`` (a :class:`~bosl2.vnf.VNF`, a
:class:`~bosl2.paths.Path`, a :class:`~bosl2.regions.Region`)."""
from bosl2._helpers import unwrap
unwrapped = unwrap(obj)
if unwrapped is not obj: # a Bosl2Solid / Bosl2Shape2D wrapper
return unwrapped
geom = getattr(obj, "geometry", None) # VNF / Path / Region
if callable(geom):
return unwrap(geom())
return obj
# ---------------------------------------------------------------------------
# Section: Base class
# ---------------------------------------------------------------------------
[docs]
class Bosl2Solid(Distributable, Colorable, Partitionable, Miscellaneous):
"""Wraps a PyOpenSCAD solid together with the geometry metadata (nominal `size` and
`anchor`) that BOSL2's $parent_geom attachment system would otherwise track, so that
edge/corner/face masking (bosl2/masking.py) work as plain chained methods instead of
needing size=/anchor= threaded through by hand at every call site. Every function in this
file returns an instance of this class (or a subclass).
Every geometry method (translate/rotate/mirror/multmatrix/scale/color, the union/intersection/
difference CSG operators) delegates to the wrapped native shape and returns a new Bosl2Solid carrying the
same size/anchor metadata forward. Any other method not explicitly listed here (e.g.
resize(), offset()) falls through via __getattr__ to the native shape and returns its raw,
*unwrapped* result, since we can't know whether the size/anchor metadata still applies.
Only cuboid()-shaped objects (cube(), cuboid() -- the only ones in this file with a genuine
axis-aligned box `size`) support the masking methods; every other shape (prismoid, wedge,
octahedron, the cylinder family, the sphere family) carries size=None and will assert if a
masking method is called on it, since bosl2/masking.py's edge/corner positioning math only
supports cuboid parents.
CAVEAT: this is a plain Python wrapper (composition), not a subclass of the real native
PyOpenSCAD C-extension type -- there was no way to verify from this environment whether
that type even supports subclassing. Calling a method on a Bosl2Solid (`shape.translate(...)`,
`shape.edge_profile(...)`) is safe. But if a Bosl2Solid is ever passed *directly* as a bare
argument into a function that expects a native geometry object -- `hull(a, b)`,
`minkowski(a, b)` -- rather than having a method called on it, the receiving function needs
the raw native object: use `.shape` to unwrap explicitly.
"""
#: which realize backend produced this solid -- see bosl2/_backend.py. Bosl2Solid is the
#: exact-CSG (PythonSCAD) backend's Solid; the libfive/SDF backend uses its own wrapper.
backend = "csg"
def __init__(
self,
shape: PyOpenSCAD,
size: Sequence[float] | None = None,
anchor: "Sequence[float] | str | None" = None,
):
self.shape = shape
self.size = size
self.anchor = anchor if anchor is not None else CENTER
# True once a positional transform (translate/rotate/scale/...) has been applied, so the
# tracked cuboid size/anchor metadata no longer describes the object's current position.
self._moved = False
@staticmethod
def _unwrap(x):
from bosl2._helpers import unwrap
return unwrap(x)
def _wrap(self, new_shape: PyOpenSCAD) -> "Bosl2Solid":
"""Wrap a native result, carrying size/anchor metadata (and moved-ness) forward unchanged.
Use for ops that do NOT move/resize the geometry (colour, repair, native mesh ops)."""
out = Bosl2Solid(new_shape, self.size, self.anchor)
out._moved = self._moved
return out
def _wrap_moved(self, new_shape: PyOpenSCAD) -> "Bosl2Solid":
"""Wrap a native result of a positional transform, flagging the tracked metadata stale."""
out = Bosl2Solid(new_shape, self.size, self.anchor)
out._moved = True
return out
def __getattr__(self, name):
# __getattr__ only fires on a normal-lookup miss. Guard the recursion trap: never bounce
# back through here for `shape` (or dunders) when the object is half-built (unpickling,
# __new__, or an __init__ that raised before setting .shape) -- raise a clean AttributeError
# so copy/pickle/hasattr behave instead of blowing the stack.
if name == "shape" or (name.startswith("__") and name.endswith("__")):
raise AttributeError(name)
_unsupported = _unsupported_feature("csg", name) # SDF-only feature on the CSG backend?
if _unsupported is not None:
raise _unsupported
shape = object.__getattribute__(self, "shape") # bypass __getattr__: no recursion
attr = getattr(shape, name)
if not callable(attr):
return attr # plain native attr (.position/.size/...)
native_cls = type(shape)
def _forward(*args, **kwargs):
# Re-wrap native geometry so a passed-through op (linear_extrude/offset/resize/...) keeps
# the Bosl2Solid fluent API instead of silently leaking a raw handle. The result may be
# in a different position, so treat it as moved. Non-geometry results pass through.
result = attr(*args, **kwargs)
if isinstance(result, native_cls):
return self._wrap_moved(result)
if isinstance(result, (list, tuple)) and result and all(isinstance(r, native_cls) for r in result):
return type(result)(self._wrap_moved(r) for r in result)
return result
_forward.__name__ = name
return _forward
def __repr__(self) -> str:
return f"Bosl2Solid({self.shape!r}, size={self.size!r}, anchor={self.anchor!r})"
# ---- geometry passthrough, preserving size/anchor metadata ----
[docs]
def translate(self, v: Sequence[float]) -> "Bosl2Solid":
return self._wrap_moved(self.shape.translate(v))
move = translate
[docs]
def rotate(self, *a, **k) -> "Bosl2Solid":
# BOSL2 rot(a): a bare scalar angle is a rotation about the Z axis. The native openscad
# rotate() only accepts a vector or (angle, axis), so normalize here. Accept any real
# scalar (incl. numpy int/float scalars) but not bool (a subclass of int).
if len(a) == 1 and isinstance(a[0], numbers.Real) and not isinstance(a[0], bool) and "v" not in k:
a = ([0.0, 0.0, float(a[0])],)
return self._wrap_moved(self.shape.rotate(*a, **k))
rot = rotate
[docs]
def mirror(self, v: Sequence[float]) -> "Bosl2Solid":
return self._wrap_moved(self.shape.mirror(v))
# Directional translates (BOSL2 transforms.scad): right/left +/-X, back/fwd +/-Y, up/down +/-Z.
[docs]
def right(self, x: float) -> "Bosl2Solid":
return self.translate([x, 0.0, 0.0])
[docs]
def left(self, x: float) -> "Bosl2Solid":
return self.translate([-x, 0.0, 0.0])
[docs]
def back(self, y: float) -> "Bosl2Solid":
return self.translate([0.0, y, 0.0])
[docs]
def forward(self, y: float) -> "Bosl2Solid":
return self.translate([0.0, -y, 0.0])
fwd = forward
[docs]
def up(self, z: float) -> "Bosl2Solid":
return self.translate([0.0, 0.0, z])
[docs]
def down(self, z: float) -> "Bosl2Solid":
return self.translate([0.0, 0.0, -z])
[docs]
def multmatrix(self, m: Sequence[Sequence[float]]) -> "Bosl2Solid":
return self._wrap_moved(self.shape.multmatrix(m))
[docs]
def scale(self, v) -> "Bosl2Solid":
return self._wrap_moved(self.shape.scale(v))
# ---- native-only mesh operations (no BOSL2 equivalent) ----
#
# PythonSCAD provides several solid operations that BOSL2 has no counterpart for; they are
# exposed here as first-class Bosl2Solid methods (re-wrapping the native result so anchoring
# metadata and fluent chaining survive) rather than leaking raw native handles through
# __getattr__. These execute only inside the real PythonSCAD app; under the numeric test mock
# they degrade to identity/AABB stand-ins (see mock_libfive.py), so the fast suite still runs
# and their real geometry is covered by the STL render tests.
[docs]
def repair(self) -> "Bosl2Solid":
"""Force the mesh watertight, healing gaps/non-manifold edges (native ``repair()``)."""
return self._wrap(self.shape.repair())
[docs]
def wrap(self, radius: float, fn: int | None = None) -> "Bosl2Solid":
"""Wrap this solid around a cylinder of radius *radius*, bending +X into the cylinder's
circumference (native ``wrap()``). *fn* sets the facet count of the bend."""
if fn is not None:
return self._wrap(self.shape.wrap(r=float(radius), fn=float(fn)))
return self._wrap(self.shape.wrap(r=float(radius)))
[docs]
def pull(self, direction: "Sequence[float] | np.ndarray", distance: float) -> "Bosl2Solid":
"""Pull the part of the solid on the +*direction* side apart by *distance*, stretching the
material between (native ``pull()``)."""
return self._wrap(self.shape.pull([float(x) for x in direction], float(distance)))
[docs]
def oversample(self, sides: int) -> "Bosl2Solid":
"""Subdivide every mesh facet *sides*-fold, e.g. before :meth:`wrap` so the bend is smooth
(native ``oversample()``)."""
return self._wrap(self.shape.oversample(int(sides)))
[docs]
def separate(self) -> "list[Bosl2Solid]":
"""Split a solid made of disconnected lumps into a list of its connected components
(native ``separate()``)."""
return [self._wrap(part) for part in self.shape.separate()]
[docs]
def inside(self, point: "Sequence[float] | np.ndarray") -> bool:
"""True if *point* lies inside the solid (native ``inside()``)."""
return bool(self.shape.inside([float(x) for x in point]))
# ---- hull / projection ----
[docs]
def hull(self, *others) -> "Bosl2Solid":
"""The convex hull of this solid (OpenSCAD ``hull()``).
With arguments, the hull of this solid *together with* each of *others* -- the shrink-wrap
around them all, which is how BOSL2 builds a rounded box from spheres at its corners.
Each of *others* may be a ``Bosl2Solid``, a raw native solid, or a
:class:`~bosl2.vnf.VNF`/point list, which is meshed as a polyhedron first.
See :meth:`~bosl2.miscellaneous.Miscellaneous.chain_hull` to hull consecutive *pairs*
instead of everything at once.
Examples:
.. pythonscad-example::
capsule = s3.sphere(radius=8).hull(s3.sphere(radius=8).up(30))
capsule.show()
"""
return Bosl2Solid(_ohull(self.shape, *[_as_native_3d(o) for o in others]))
[docs]
def projection(self, cut: bool = False) -> "Bosl2Shape2D":
"""The 2-D shadow of this solid on the XY plane (OpenSCAD ``projection()``).
With ``cut=True`` you get the cross-section where the solid crosses the z=0 plane instead
of the full outline -- slice the solid at the height you want first.
Returns:
A :class:`~bosl2.shapes2d.Bosl2Shape2D`, so the result chains straight back into the
2-D operators (``.offset()``, ``.fill()``, ``.hull()``) and the extruders.
Note:
CSG only. The SDF backend's :meth:`~bosl2._sdf.shapes3d.PyShape.projection` raises
:class:`~bosl2.exceptions.UnsupportedByBackend` -- a distance field has no
closed-form 2-D shadow, and 2-D geometry is a CSG-backend notion.
Examples:
A footprint outline, grown 2mm, extruded into a base plate:
.. pythonscad-example::
part = s3.cuboid([30, 20, 10], rounding=3)
part.projection().offset(radius=2).linear_extrude(height=2).show()
"""
from bosl2.shapes2d import Bosl2Shape2D
return Bosl2Shape2D(self.shape.projection(cut=cut))
# ---- colour (bosl2/color.py) ----
#
# The color.scad operators (color/recolor/color_this/hsl/hsv/highlight/ghost) come from the
# Colorable mixin, which resolves to these native primitives: PythonSCAD's color(),
# highlight() (the # modifier) and background() (the % / ghost modifier).
def _color_native(self, c=None, alpha=None) -> "Bosl2Solid":
args = () if c is None else (c,)
kw = {} if alpha is None else {"alpha": alpha}
return self._wrap(self.shape.color(*args, **kw))
def _highlight_native(self) -> "Bosl2Solid":
return self._wrap(self.shape.highlight())
def _ghost_native(self) -> "Bosl2Solid":
return self._wrap(self.shape.background())
[docs]
def to_csg(self) -> "Bosl2Solid":
"""This solid is already on the CSG backend -- returns self (the converter no-op)."""
return self
[docs]
def to_sdf(self, voxel_size: float | None = None) -> "Bosl2Solid":
"""CSG -> SDF conversion is not supported (would require lossy voxel-sampling)."""
from bosl2.exceptions import UnsupportedByBackend
raise UnsupportedByBackend(
"to_sdf",
"csg",
hint="a CSG tree has no signed-distance field; build the shape on the SDF backend "
"instead (with use_backend('sdf')). Only SDF->CSG (PyShape.to_csg()) is supported.",
)
def __or__(self, other) -> "Bosl2Solid":
_check_operand_backend("csg", other)
return self._wrap(self.shape | Bosl2Solid._unwrap(other))
def __and__(self, other) -> "Bosl2Solid":
_check_operand_backend("csg", other)
return self._wrap(self.shape & Bosl2Solid._unwrap(other))
def __sub__(self, other) -> "Bosl2Solid":
_check_operand_backend("csg", other)
return self._wrap(self.shape - Bosl2Solid._unwrap(other))
def __ror__(self, other) -> "Bosl2Solid":
_check_operand_backend("csg", other)
return self._wrap(Bosl2Solid._unwrap(other) | self.shape)
def __rand__(self, other) -> "Bosl2Solid":
_check_operand_backend("csg", other)
return self._wrap(Bosl2Solid._unwrap(other) & self.shape)
def __rsub__(self, other) -> "Bosl2Solid":
_check_operand_backend("csg", other)
return self._wrap(Bosl2Solid._unwrap(other) - self.shape)
# ---- distributors (bosl2/distributors.py) ----
#
# The distributors.scad copiers, inherited from Distributable, resolve to _distribute(), which
# for a solid means: multmatrix a copy for each transform and union them into one new solid.
# This reuses the wrapped native handle across the copies, which is safe for direct-CSG solids
# (the norm here); a pysolidfive/frep-backed shape must instead be distributed via a factory to
# avoid the frep handle-reuse segfault (see the SDF-vs-direct note in CLAUDE.md).
def _distribute(self, mats) -> "Bosl2Solid":
"""Union a multmatrix copy of this solid for each transform matrix (BOSL2's module form)."""
assert len(mats), "distributor produced no copies."
out = self.shape.multmatrix(np.asarray(mats[0]).tolist())
for m in mats[1:]:
out = out | self.shape.multmatrix(np.asarray(m).tolist())
return self._wrap_moved(out)
# ---- bounding-box anchoring (works on ANY object, via PythonSCAD's native bbox) ----
#
# PythonSCAD exposes obj.position (min corner) / obj.size (extent) / obj.bbox (a solid),
# each computed by actually meshing the object. That lets anchoring/attachment/masking
# find where an anchor point is on ANY object without the caller passing a size -- BOSL2
# normally threads $parent_geom through for this. Tracked cuboid size/anchor metadata,
# when present, is used first as a no-meshing fast path.
def _native_bounds(self) -> "tuple[list[float], list[float]] | None":
"""The object's axis-aligned bounding box as (mincorner, size), read from the native
obj.position/obj.size. Returns None when those accessors aren't available (the numeric
test mock) or the geometry is empty/degenerate (native returns None)."""
try:
pos = self.shape.position
sz = self.shape.size
except AttributeError:
# The native handle doesn't expose position/size (the numeric test mock). A genuine
# error from the real accessor (e.g. a broken mesh) is NOT swallowed -- it propagates.
return None
if pos is None or sz is None:
return None
try:
mincorner = [float(pos[i]) for i in range(3)]
size = [float(sz[i]) for i in range(3)]
except (TypeError, IndexError, ValueError):
return None
return mincorner, size
[docs]
def bounds(self) -> "tuple[list[float], list[float]]":
"""This object's axis-aligned bounding box as (center, size) -- both plain [x, y, z]
float lists in the object's CURRENT coordinate frame (after any translate/rotate/CSG).
Prefers the native bbox, which always reflects the actual current geometry -- this is
what lets anchoring/attachment/masking work without the caller tracking a size, and
stays correct after the object has been moved or combined (tracked size/anchor metadata
would be stale there). Falls back to the tracked cuboid size/anchor metadata only when
the native accessors aren't available (the numeric test mock), where it assumes the box
is still at its construction position. Raises if neither is available."""
nb = self._native_bounds()
if nb is not None:
mincorner, size = nb
return [mincorner[i] + size[i] / 2 for i in range(3)], size
if self.size is not None and not isinstance(self.anchor, str):
# Fall back to construction-time cuboid metadata -- but only if the object hasn't been
# moved since, because that metadata tracks size/anchor, not the current position. Fail
# loud rather than return a silently-stale centre (this path is the numeric mock only).
if self._moved:
raise ValueError(
"bounds(): no native bounding box (numeric mock) and the object has been "
"transformed since construction, so its tracked cuboid metadata is stale. Run "
"under the real PythonSCAD app for a correct bbox, or anchor before transforming."
)
size = [float(v) for v in self.size]
return _anchor_offset_box3(size, self.anchor), size
raise ValueError(
"bounds(): object has no native bounding box and no tracked cuboid size/anchor "
"metadata (are you calling this under the numeric mock on a non-cuboid?)"
)
def _resolve_bounds(self, bbox=None) -> "tuple[list[float], list[float]]":
"""(center, size) for anchoring: from a passed-in *bbox* override if given, else the
object's native bounding box (:meth:`bounds`).
*bbox* overrides the object's own box -- useful when the native bbox is wrong for the
purpose (a shape with an overhang, a mask positioned against a nominal box, or a cheap way
to skip the meshing the native bbox needs). It is a min/max corner pair
``[[min_x, min_y, min_z], [max_x, max_y, max_z]]`` (the same shape :meth:`Path.bounds` and
the native ``obj.bbox`` use)."""
if bbox is None:
return self.bounds()
arr = np.asarray(bbox, dtype=float)
assert arr.shape == (2, 3), "bbox must be [[min_x,min_y,min_z],[max_x,max_y,max_z]]."
lo, hi = arr[0], arr[1]
assert bool(np.all(hi >= lo - 1e-12)), "bbox must be [[min...],[max...]] with max >= min."
return [(lo[i] + hi[i]) / 2 for i in range(3)], [hi[i] - lo[i] for i in range(3)]
[docs]
def anchor_point(self, anchor: Sequence[float], bbox=None) -> list[float]:
"""The [x, y, z] point on this object's bounding box for the given anchor vector, in the
object's current coordinate frame: center + anchor * size / 2. Works on any object.
Pass *bbox* to anchor against a supplied box instead of the object's own (see
:meth:`_resolve_bounds`)."""
center, size = self._resolve_bounds(bbox)
a = list(anchor)
return [center[i] + a[i] * size[i] / 2 for i in range(3)]
[docs]
def reanchor(self, anchor: Sequence[float], bbox=None) -> "Bosl2Solid":
"""Return this object translated so its bounding-box `anchor` point sits at the origin.
Re-anchors any object by its bbox after the fact (cube()/cuboid() only do this at
construction, and only for cuboids). Pass *bbox* to use a supplied box."""
p = self.anchor_point(anchor, bbox=bbox)
moved = self.translate([-p[0], -p[1], -p[2]])
if moved.size is not None:
moved.anchor = list(anchor)
return moved
[docs]
def position(self, anchor: Sequence[float], child, bbox=None) -> "Bosl2Solid":
"""BOSL2 position(): place `child` so its local origin lands on this object's
bounding-box `anchor` point, keeping the child's own orientation, and return self
unioned with the placed child. `child` may be a Bosl2Solid or a raw native solid."""
p = self.anchor_point(anchor, bbox=bbox)
placed = Bosl2Solid._unwrap(child).translate(p)
# Untracked result: bounds() on it queries the true combined bbox rather than the
# parent box, so a chained attach/position builds on the combined shape.
return Bosl2Solid(self.shape | placed)
[docs]
def align(
self,
anchor: Sequence[float],
child,
align: Sequence[float] | None = None,
inside: bool = False,
overlap: float = 0.0,
bbox=None,
) -> "Bosl2Solid":
"""BOSL2 align(): place `child` on this object's `anchor` face and return self unioned
with it. Like attach() it mates a child face to a parent face, but WITHOUT reorienting
the child -- the child keeps its own axes and is merely translated.
With `align` omitted the child is centered on the face, sitting OUTSIDE the parent
(inside=False, the default) or tucked inside (inside=True). Pass `align` (an edge/corner
direction within the face, e.g. RIGHT for the +x edge) to sit the child flush against
that edge/corner instead -- matching BOSL2 align()'s anchor+align pair. Both anchor
points come from the native bounding boxes, so no size needs to be passed.
Args:
anchor: the parent face to place the child on (e.g. TOP)
child: the solid to place (Bosl2Solid or raw native solid)
align: edge/corner within the face to sit flush against (default: centered)
inside: place the child inside the parent instead of outside (default False)
overlap: pull the child toward the parent along the face normal by this much
"""
face = list(anchor)
edge = [0.0, 0.0, 0.0] if align is None else list(align)
factor = -1.0 if inside else 1.0
csolid = child if isinstance(child, Bosl2Solid) else Bosl2Solid(child)
# The child's own mating anchor: its face opposite the parent face (so it sits on the
# outside), shifted to the aligned edge/corner. Matches BOSL2's thisedge - factor*thisface.
child_anchor = [edge[i] - factor * face[i] for i in range(3)]
cpt = csolid.anchor_point(child_anchor)
dest = self.anchor_point([face[i] + edge[i] for i in range(3)], bbox=bbox)
fdir = list(unit(face)) if any(face) else [0.0, 0.0, 0.0]
ov = -overlap if inside else overlap
placed = csolid.translate([dest[i] - cpt[i] - fdir[i] * ov for i in range(3)])
return Bosl2Solid(self.shape | placed.shape)
[docs]
def attach(
self,
parent_anchor: Sequence[float],
child,
child_anchor: Sequence[float] | None = None,
overlap: float = 0.0,
spin: float = 0.0,
bbox=None,
) -> "Bosl2Solid":
"""BOSL2 attach(): orient and place `child` so its `child_anchor` face mates flush
against this object's `parent_anchor` face, then return self unioned with the placed
child. Both anchor points come from the native bounding boxes, so neither object needs
its size passed explicitly.
Args:
parent_anchor: which face of self to attach to (e.g. TOP)
child: the solid to attach (Bosl2Solid or raw native solid)
child_anchor: which face of the child mates against it (default: the child's
face OPPOSITE parent_anchor, so the two mate naturally)
overlap: pull the child in by this much along the mating axis (default 0)
spin: spin the child about the mating axis, in degrees (default 0)
"""
pa = list(parent_anchor)
ca = [-a for a in pa] if child_anchor is None else list(child_anchor)
csolid = child if isinstance(child, Bosl2Solid) else Bosl2Solid(child)
# 1. bring the child's mating face to the origin
cpt = csolid.anchor_point(ca)
placed = csolid.translate([-cpt[0], -cpt[1], -cpt[2]])
# 2. rotate so the child's mating-face direction points opposite the parent's face
angle, axis = _rot_from_to(ca, [-a for a in pa])
if angle:
placed = placed.rotate(angle, axis)
# 3. optional spin about the mating (parent-face) axis
if spin and any(pa):
placed = placed.rotate(spin, list(unit(pa)))
# 4. move onto the parent's anchor point, pulling in by `overlap`
ppt = self.anchor_point(pa, bbox=bbox)
pdir = list(unit(pa)) if any(pa) else [0.0, 0.0, 0.0]
placed = placed.translate([ppt[i] - pdir[i] * overlap for i in range(3)])
return Bosl2Solid(self.shape | placed.shape)
[docs]
def reorient(
self,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
bbox=None,
) -> "Bosl2Solid":
"""Reorient this already-built object by its bounding box (BOSL2 reorient()).
Moves the bounding-box *anchor* point to the origin, spins *spin* degrees about Z, then
rotates the object's UP toward *orient*. The size comes from the native bbox, so -- unlike
BOSL2's function form -- you never pass it. cube()/cuboid()/etc. take anchor/spin/orient at
construction; this applies the same transform to any object after the fact. Pass *bbox* to
reorient against a supplied box instead of the object's own."""
from bosl2.transforms import reorient as _reorient_matrix
center, size = self._resolve_bounds(bbox)
m = _reorient_matrix(anchor=list(anchor), spin=spin, orient=list(orient), size=size)
centered = self.translate([-center[0], -center[1], -center[2]])
return centered.multmatrix(np.asarray(m).tolist())
[docs]
def orient(self, direction: Sequence[float] = UP, spin: float = 0, bbox=None) -> "Bosl2Solid":
"""Rotate this object so its top (UP) faces *direction* (BOSL2 orient()); uses the bbox."""
return self.reorient(anchor=CENTER, spin=spin, orient=direction, bbox=bbox)
# ---- edge/corner/face masking (bosl2/masking.py), box-shaped objects ----
#
# These now work on ANY box-shaped object: the cutter size and box center come from
# bounds() (tracked metadata when available, else the native bbox), so callers no longer
# have to pass size= or keep the object as a freshly-built cuboid.
[docs]
def edge_mask(
self,
edges: str | list = "ALL",
except_edges: list | None = None,
children: PyOpenSCAD | None = None,
bbox=None,
) -> "Bosl2Solid":
from . import masking
center, size = self._resolve_bounds(bbox)
return self._wrap(masking.edge_mask(self.shape, edges, except_edges, children, size=size, center=center))
[docs]
def edge_profile(
self,
edges: str | list = "ALL",
except_edges: list | None = None,
children: Sequence[Sequence[float]] | None = None,
convexity: int = 10,
bbox=None,
) -> "Bosl2Solid":
from . import masking
center, size = self._resolve_bounds(bbox)
return self._wrap(
masking.edge_profile(
self.shape,
edges,
except_edges,
children,
size=size,
convexity=convexity,
center=center,
)
)
[docs]
def edge_profile_asym(
self,
edges: str | list = "ALL",
except_edges: list | None = None,
children: Sequence[Sequence[float]] | None = None,
convexity: int = 10,
) -> "Bosl2Solid":
return self.edge_profile(edges, except_edges, children, convexity)
[docs]
def corner_profile(
self,
corners: str | list = "ALL",
except_corners: list | None = None,
radius: float | None = None,
diameter: float | None = None,
children: Sequence[Sequence[float]] | None = None,
convexity: int = 10,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
bbox=None,
) -> "Bosl2Solid":
from . import masking
center, size = self._resolve_bounds(bbox)
return self._wrap(
masking.corner_profile(
self.shape,
corners,
except_corners,
radius,
diameter,
size=size,
children=children,
convexity=convexity,
center=center,
fn=fn,
fa=fa,
fs=fs,
)
)
[docs]
def face_profile(
self,
faces: str | list = "ALL",
radius: float | None = None,
diameter: float | None = None,
children: Sequence[Sequence[float]] | None = None,
convexity: int = 10,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
bbox=None,
) -> "Bosl2Solid":
from . import masking
center, size = self._resolve_bounds(bbox)
return self._wrap(
masking.face_profile(
self.shape,
faces,
radius,
diameter,
size=size,
children=children,
convexity=convexity,
center=center,
fn=fn,
fa=fa,
fs=fs,
)
)
# ---------------------------------------------------------------------------
# Internal helpers (not part of BOSL2's public API)
# ---------------------------------------------------------------------------
def _quantup(x: float, y: float) -> float:
return math.ceil(x / y) * y
def _orient_rotate(shape: PyOpenSCAD, orient: Sequence[float]) -> PyOpenSCAD:
o = list(orient)
if o == [0, 0, 1]:
return shape
if o == [0, 0, -1]:
return shape.rotate(180, [1, 0, 0])
axis = np.asarray(cross([0, 0, 1], o), dtype=float)
sides = float(np.linalg.norm(axis))
if sides < 1e-12:
return shape
axis = (axis / sides).tolist()
ou = unit(o)
cosang = max(-1.0, min(1.0, ou[2]))
angle = math.degrees(math.acos(cosang))
return shape.rotate(angle, axis)
def _rot_from_to(a: Sequence[float], b: Sequence[float]) -> "tuple[float, list[float]]":
"""(angle_degrees, axis) that rotates direction *a* onto direction *b*, for shape.rotate().
Handles the parallel (no rotation) and antiparallel (180 deg about any perpendicular axis)
cases. Used by Bosl2Solid.attach() to point a child's mating face at a parent face."""
au, bu = unit(a), unit(b)
diameter = max(-1.0, min(1.0, sum(au[i] * bu[i] for i in range(3))))
if diameter > 1 - 1e-9:
return 0.0, [0.0, 0.0, 1.0]
if diameter < -1 + 1e-9:
axis = cross(au, [1.0, 0.0, 0.0])
if float(np.linalg.norm(np.asarray(axis, dtype=float))) < 1e-9:
axis = cross(au, [0.0, 1.0, 0.0])
return 180.0, list(unit(axis))
axis = list(unit(cross(au, bu)))
return math.degrees(math.acos(diameter)), axis
def _finish3(shape: PyOpenSCAD, offset: Sequence[float], spin: float, orient: Sequence[float]) -> PyOpenSCAD:
if offset[0] or offset[1] or offset[2]:
shape = shape.translate(offset)
if spin:
shape = shape.rotate(spin, [0, 0, 1])
return _orient_rotate(shape, orient)
def _anchor_offset_box3(size: Sequence[float], anchor: Sequence[float]) -> list[float]:
a = list(anchor)
return [-a[i] * size[i] / 2 for i in range(3)]
def _anchor_offset_hull3(points: Sequence[Sequence[float]], anchor: Sequence[float]) -> list[float]:
a = list(anchor)
if a[0] == 0 and a[1] == 0 and a[2] == 0:
return [0.0, 0.0, 0.0]
# The anchor point is the support point of the hull in direction `anchor`. When several vertices
# tie for the maximum projection (a whole face for a face anchor, two vertices for an edge
# anchor), the anchor is their centroid -- the face/edge centre -- not an arbitrary tied corner.
projs = [p[0] * a[0] + p[1] * a[1] + p[2] * a[2] for p in points]
m = max(projs)
eps = 1e-7 * (1.0 + abs(m))
tied = [p for p, pr in zip(points, projs) if pr >= m - eps]
sides = len(tied)
return [-sum(p[i] for p in tied) / sides for i in range(3)]
def _anchor_offset_cyl(
radius1: float,
radius2: float,
length: float,
anchor: Sequence[float],
axis: int = 2,
) -> list[float]:
a = list(anchor)
az = a[axis]
r_at = radius1 if az < 0 else (radius2 if az > 0 else (radius1 + radius2) / 2)
radial_axes = [i for i in range(3) if i != axis]
radial = [a[i] for i in radial_axes]
rn = math.hypot(*radial)
if rn > 0:
radial = [x / rn * r_at for x in radial]
offset = [0.0, 0.0, 0.0]
offset[axis] = az * length / 2
for i, ax in enumerate(radial_axes):
offset[ax] = radial[i]
return [-x for x in offset]
def _anchor_offset_sphere(radius: float, anchor: Sequence[float]) -> list[float]:
a = list(anchor)
sides = math.hypot(*a)
if sides == 0:
return [0.0, 0.0, 0.0]
return [-a[i] / sides * radius for i in range(3)]
# --- cuboid() edge-set machinery, mirroring BOSL2 attachments.scad -----------
# The edge-selector mini-language lives in bosl2._edges_lang so BOTH backends (CSG here, SDF in
# bosl2._sdf) share one implementation without depending on each other's shape module. Re-exported
# here for the many internal users below and for backward-compatible imports.
from bosl2._edges_lang import ( # noqa: E402, F401
_MAJOR_AXIS_VALID,
EDGE_OFFSETS,
EDGES_ALL,
EDGES_NONE,
_edge_set,
_edges,
_is_edge_array,
_is_plain_vector,
)
def _corner_edges(edges: Sequence[Sequence[float]], v: Sequence[float]) -> list[int]:
u = [(v[i] + 1) / 2 for i in range(3)]
return [
int(edges[0][int(u[1] + u[2] * 2)]),
int(edges[1][int(u[0] + u[2] * 2)]),
int(edges[2][int(u[0] + u[1] * 2)]),
]
def _rotate_to_axis(shape: PyOpenSCAD, axis: int) -> PyOpenSCAD:
if axis == 0:
return shape.rotate(90, [0, 1, 0])
if axis == 1:
return shape.rotate(-90, [1, 0, 0])
return shape
def _trunc_cube(s: Sequence[float], corner: Sequence[float]) -> PyOpenSCAD:
"""A small cube with the corner facing away from *corner* trimmed off diagonally (7 vertices).
Used to trim corner_shape() geometry down to just the correct octant of a cuboid corner.
"""
pts = [[1, 1, 1], [1, 1, 0], [1, 0, 0], [0, 1, 1], [0, 1, 0], [1, 0, 1], [0, 0, 1]]
faces = [
[0, 1, 2],
[2, 5, 0],
[0, 5, 6],
[0, 6, 3],
[0, 3, 4],
[0, 4, 1],
[1, 4, 2],
[3, 6, 4],
[5, 2, 6],
[2, 4, 6],
]
scaled = [
[
(p[0] - 0.5) * (s[0] + 0.001),
(p[1] - 0.5) * (s[1] + 0.001),
(p[2] - 0.5) * (s[2] + 0.001),
]
for p in pts
]
shape = _opolyhedron(scaled, faces)
if corner[0] < 0:
shape = shape.mirror([1, 0, 0])
if corner[1] < 0:
shape = shape.mirror([0, 1, 0])
if corner[2] < 0:
shape = shape.mirror([0, 0, 1])
return shape
def _corner_shape(
corner: Sequence[float],
size: Sequence[float],
edges: Sequence[Sequence[float]],
radius: float,
is_chamfer: bool,
trimcorners: bool,
fn,
fa,
fs,
) -> PyOpenSCAD:
e = _corner_edges(edges, corner)
cnt = sum(e)
c = [radius, radius, radius]
m = 0.01
c2 = [corner[i] * c[i] / 2 for i in range(3)]
c3 = [corner[i] * (c[i] - m / 2) for i in range(3)]
fn = 4 if is_chamfer else max(4, int(_quantup(_frag_count(radius, fn, fa, fs), 4)))
base_t = [corner[i] * (size[i] / 2 - c[i]) for i in range(3)]
def xtcyl(length: float, radius: float):
return _rotate_to_axis(_ocylinder(height=length, radius=radius, center=True, fn=fn), 0)
def ytcyl(length: float, radius: float):
return _rotate_to_axis(_ocylinder(height=length, radius=radius, center=True, fn=fn), 1)
def ztcyl(length: float, radius: float):
return _ocylinder(height=length, radius=radius, center=True, fn=fn)
def tsphere(radius: float):
return _osphere(radius=radius, fn=fn)
if cnt == 0 or radius == 0:
shape = _ocube(m, center=True).translate(c3)
elif cnt == 1:
if e[0]:
shape = xtcyl(c[0] * 2, radius).translate([c3[0], 0, 0])
elif e[1]:
shape = ytcyl(c[1] * 2, radius).translate([0, c3[1], 0])
else:
shape = ztcyl(c[2] * 2, radius).translate([0, 0, c3[2]])
shape = shape & _trunc_cube(c, corner).translate(c2)
elif cnt == 2:
if not e[0]:
shape = ytcyl(c[1] * 2, radius) & ztcyl(c[2] * 2, radius)
elif not e[1]:
shape = xtcyl(c[0] * 2, radius) & ztcyl(c[2] * 2, radius)
else:
shape = xtcyl(c[0] * 2, radius) & ytcyl(c[1] * 2, radius)
shape = shape & _trunc_cube(c, corner).translate(c2)
else:
shape = (
tsphere(radius)
if trimcorners
else (xtcyl(c[0] * 2, radius) & ytcyl(c[1] * 2, radius) & ztcyl(c[2] * 2, radius))
)
shape = shape & _trunc_cube(c, corner).translate(c2)
return shape.translate(base_t)
def _edge_mask_negative(
sz: Sequence[float],
edge_set: Sequence[Sequence[float]],
ard: float,
is_chamfer: bool,
trimcorners: bool,
fn,
fa,
fs,
) -> PyOpenSCAD:
assert edge_set == EDGES_ALL or edge_set[2] == [0, 0, 0, 0], (
"Cannot use negative rounding/chamfer with Z aligned edges."
)
pieces = []
cutters = []
for axis in (0, 1):
for i in range(4):
if edge_set[axis][i] > 0:
vec = EDGE_OFFSETS[axis][i]
adj = [ard - 0.01, ard - 0.01, -ard]
t = [vec[k] / 2 * (sz[k] + adj[k]) for k in range(3)]
box = _rotate_to_axis(_ocube([ard, ard, sz[axis]], center=True), axis)
pieces.append(box.translate(t))
adj2 = [2 * ard, 2 * ard, -2 * ard]
t2 = [vec[k] / 2 * (sz[k] + adj2[k]) for k in range(3)]
if is_chamfer:
cutter = _ocube(
[ard * math.sqrt(2), ard * math.sqrt(2), sz[axis] + 2.1 * ard],
center=True,
).rotate(45, [0, 0, 1])
else:
fn = int(_quantup(_frag_count(ard, fn, fa, fs), 4))
cutter = _ocylinder(height=sz[axis] + 2.1 * ard, radius=ard, center=True, fn=fn)
cutters.append(_rotate_to_axis(cutter, axis).translate(t2))
if trimcorners:
for za in (-1, 1):
for ya in (-1, 1):
for xa in (-1, 1):
ce = _corner_edges(edge_set, [xa, ya, za])
if ce[0] + ce[1] > 1:
adj3 = [ard - 0.01, ard - 0.01, -ard]
t3 = [[xa, ya, za][k] / 2 * (sz[k] + adj3[k]) for k in range(3)]
pieces.append(_ocube([ard + 0.01, ard + 0.01, ard], center=True).translate(t3))
edge_union = pieces[0]
for p in pieces[1:]:
edge_union = edge_union | p
for c in cutters:
edge_union = edge_union - c
return _ocube(sz, center=True) | edge_union
# ---------------------------------------------------------------------------
# Section: native-only 2-D -> 3-D constructor (no BOSL2 equivalent)
# ---------------------------------------------------------------------------
[docs]
def roof(shape, method: str = "straight") -> Bosl2Solid:
"""Raise a hip roof over a 2-D *shape* via its straight skeleton (native ``roof()``).
Like :func:`~bosl2.skin.linear_sweep`, this turns a 2-D outline into a 3-D solid, but the top is
a peaked roof (each edge slopes inward at 45 degrees to the skeleton) rather than a flat
extrusion. *shape* is any 2-D object -- a native ``square``/``circle``/``polygon``, a
:meth:`Path.polygon`, or a :class:`Bosl2Solid` wrapping one. *method* selects the skeleton
algorithm. PythonSCAD-only (no BOSL2 counterpart); covered by the STL render tests.
"""
return Bosl2Solid(Bosl2Solid._unwrap(shape).roof(method=method))
# ---------------------------------------------------------------------------
# Section: Cuboids, Prismoids and Pyramids
# ---------------------------------------------------------------------------
[docs]
def cube(
size: float | Sequence[float] = 1,
center: bool | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
) -> Bosl2Solid:
"""A cube, built with the builtin cube(), with BOSL2-style anchor/spin/orient support.
Args:
size: size of the cube, a number or length-3 vector
center: if given, overrides anchor (True -> CENTER, False -> FRONT+LEFT+BOTTOM)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
"""
sz = [float(size)] * 3 if isinstance(size, (int, float)) else [float(v) for v in size]
use_anchor = anchor
if center is not None:
use_anchor = CENTER if center else [-1, -1, -1]
shape = _ocube(sz, center=True)
offset = _anchor_offset_box3(sz, use_anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=sz, anchor=use_anchor)
[docs]
def cuboid(
size: float | Sequence[float] = [1, 1, 1],
p1: Sequence[float] | None = None,
p2: Sequence[float] | None = None,
chamfer: float | None = None,
rounding: float | None = None,
edges: str | list = "ALL",
except_edges: list | None = None,
trimcorners: bool = True,
teardrop: bool | float = False,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A cube/cuboid with optional chamfering or rounding of edges and corners.
Built directly from cube()/cylinder()/sphere()/hull()/minkowski(), mirroring BOSL2's own
cuboid() algorithm (which is itself CSG composition of primitive shapes at each corner,
not raw polyhedron mesh math).
You cannot mix chamfering and rounding on the same call. Negative chamfers/roundings
create external fillets, but only apply to edges around the top or bottom face.
Note: `teardrop=` is not supported by this pure-Python port.
Args:
size: size of the cuboid, a number or length-3 vector
p1: align the cuboid's corner at p1, if given (forces anchor=FRONT+LEFT+BOTTOM)
p2: if given with p1, defines the cuboid's opposing cornerpoint
chamfer: chamfer size, inset from sides (default: no chamfer)
rounding: edge rounding radius (default: no rounding)
edges: edges to mask (default "ALL")
except_edges: edges to explicitly not mask (BOSL2's `except=` synonym; `except` is a Python keyword)
trimcorners: round/chamfer corners where three treated edges meet (default True)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees (default 0)
orient: direction to rotate the top towards (default UP)
fn/fa/fs: arc smoothness overrides for rounded edges/corners
Examples:
.. pythonscad-example::
shape = bosl2.shapes3d.cuboid([40, 30, 20])
shape.show()
.. pythonscad-example::
shape = bosl2.shapes3d.cuboid([40, 30, 20], rounding=5)
shape.show()
"""
if teardrop:
raise NotImplementedError("cuboid(): teardrop= is not supported by this pure-Python port.")
sz = [float(size)] * 3 if isinstance(size, (int, float)) else [float(v) for v in size]
if p1 is not None:
if p2 is not None:
mn = [min(p1[i], p2[i]) for i in range(3)]
mx = [max(p1[i], p2[i]) for i in range(3)]
shape = cuboid(
[mx[i] - mn[i] for i in range(3)],
chamfer=chamfer,
rounding=rounding,
edges=edges,
except_edges=except_edges,
trimcorners=trimcorners,
anchor=[-1, -1, -1],
fn=fn,
fa=fa,
fs=fs,
)
return shape.translate(mn)
shape = cuboid(
sz,
chamfer=chamfer,
rounding=rounding,
edges=edges,
except_edges=except_edges,
trimcorners=trimcorners,
anchor=[-1, -1, -1],
fn=fn,
fa=fa,
fs=fs,
)
return shape.translate(p1)
edge_set = _edges(edges, except_edges or [])
chamfer_v = 0 if not chamfer else chamfer
rounding_v = 0 if not rounding else rounding
assert not (chamfer_v and rounding_v), "Cannot specify nonzero value for both chamfer and rounding"
corners8 = [[xa, ya, za] for za in (-1, 1) for ya in (-1, 1) for xa in (-1, 1)]
if chamfer_v != 0:
radius = chamfer_v
if edge_set == EDGES_ALL and trimcorners:
if radius < 0:
shape = _edge_mask_negative(sz, edge_set, abs(radius), True, trimcorners, fn, fa, fs)
else:
isize = [max(0.001, v - 2 * radius) for v in sz]
shape = _ohull(
_ocube([sz[0], isize[1], isize[2]], center=True),
_ocube([isize[0], sz[1], isize[2]], center=True),
_ocube([isize[0], isize[1], sz[2]], center=True),
)
elif radius < 0:
shape = _edge_mask_negative(sz, edge_set, abs(radius), True, trimcorners, fn, fa, fs)
else:
# Intersected with the plain box: _corner_shape()'s per-corner treatment (for a
# single active edge, e.g. edges="Z") is sized around the rounding radius alone, not
# clipped to the box's own extent along the *other* axes -- for edges="Z" specifically,
# each corner cap spans 2*radius along Z, so if radius exceeds half the box's Z size (a thin
# slab with a comparatively large XY corner rounding, e.g. labels.py's striped
# backgrounds), the un-intersected hull balloons far beyond the box's actual
# thickness instead of just rounding its corners.
shape = _ohull(
*[_corner_shape(c, sz, edge_set, radius, True, trimcorners, fn, fa, fs) for c in corners8]
) & _ocube(sz, center=True)
elif rounding_v != 0:
radius = rounding_v
if edge_set == EDGES_ALL and radius > 0:
isize = [max(0.001, v - 2 * radius) for v in sz]
fn = int(_quantup(_frag_count(radius, fn, fa, fs), 4))
shape = _ominkowski(_ocube(isize, center=True), _osphere(radius=radius, fn=fn))
elif radius < 0:
shape = _edge_mask_negative(sz, edge_set, abs(radius), False, trimcorners, fn, fa, fs)
else:
# See the chamfer branch above for why this needs clipping to the plain box.
shape = _ohull(
*[_corner_shape(c, sz, edge_set, radius, False, trimcorners, fn, fa, fs) for c in corners8]
) & _ocube(sz, center=True)
else:
shape = _ocube(sz, center=True)
offset = _anchor_offset_box3(sz, anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=sz, anchor=anchor)
[docs]
def prismoid(
size1: Sequence[float],
size2: Sequence[float],
height: float | None = None,
shift: Sequence[float] = [0, 0],
rounding: float | Sequence[float] = 0,
rounding1: float | Sequence[float] | None = None,
rounding2: float | Sequence[float] | None = None,
chamfer: float | Sequence[float] = 0,
chamfer1: float | Sequence[float] | None = None,
chamfer2: float | Sequence[float] | None = None,
length: float | None = None,
center: bool | None = None,
anchor: Sequence[float] = BOTTOM,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A rectangular prismoid, built as the convex hull() of two (optionally rounded/chamfered) rects.
Args:
size1: [width, length] of the bottom end
size2: [width, length] of the top end
height/length: height of the prism
shift: [X,Y] shift of the top center relative to the bottom center
rounding: vertical-edge roundover radius, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0)
rounding1: roundover radius for the bottom of the vertical-ish edges
rounding2: roundover radius for the top of the vertical-ish edges
chamfer: vertical-edge chamfer size, or per-corner list (default 0)
chamfer1: chamfer size for the bottom of the vertical-ish edges
chamfer2: chamfer size for the top of the vertical-ish edges
center: if given, overrides anchor
anchor: anchor point (default BOTTOM)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
fn/fa/fs: arc smoothness overrides for rounded corners
Examples:
.. pythonscad-example::
shape = bosl2.shapes3d.prismoid([40, 40], [20, 25], height=30)
shape.show()
"""
from .shapes2d import _rect_path
s1 = [float(size1)] * 2 if isinstance(size1, (int, float)) else [float(v) for v in size1]
s2 = [float(size2)] * 2 if isinstance(size2, (int, float)) else [float(v) for v in size2]
height = height if height is not None else (length if length is not None else 1)
radius1 = rounding1 if rounding1 is not None else rounding
radius2 = rounding2 if rounding2 is not None else rounding
c1 = chamfer1 if chamfer1 is not None else chamfer
c2 = chamfer2 if chamfer2 is not None else chamfer
use_anchor = anchor
if center is not None:
use_anchor = CENTER if center else BOTTOM
path1 = _rect_path(s1, rounding=radius1, chamfer=c1, fn=fn, fa=fa, fs=fs)
path2 = _rect_path(s2, rounding=radius2, chamfer=c2, fn=fn, fa=fa, fs=fs)
bottom_pts = [[p[0], p[1], -height / 2] for p in path1]
top_pts = [[p[0] + shift[0], p[1] + shift[1], height / 2] for p in path2]
bottom = _opolyhedron(bottom_pts, [list(range(len(bottom_pts)))])
top = _opolyhedron(top_pts, [list(range(len(top_pts)))])
shape = _ohull(bottom, top)
offset = _anchor_offset_hull3(bottom_pts + top_pts, use_anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=use_anchor)
[docs]
def octahedron(
size: float = 1,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
) -> Bosl2Solid:
"""An octahedron with axis-aligned points, built directly with polyhedron().
Args:
size: width of the octahedron, tip to tip
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
"""
s = size / 2
pts = [[s, 0, 0], [-s, 0, 0], [0, s, 0], [0, -s, 0], [0, 0, s], [0, 0, -s]]
faces = [
[2, 0, 4],
[1, 2, 4],
[3, 1, 4],
[0, 3, 4],
[0, 2, 5],
[2, 1, 5],
[1, 3, 5],
[3, 0, 5],
]
shape = _opolyhedron(pts, faces)
offset = _anchor_offset_hull3(pts, anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=anchor)
[docs]
def wedge(
size: Sequence[float] = [1, 1, 1],
center: bool | None = None,
anchor: Sequence[float] = FRONT + LEFT + BOTTOM,
spin: float = 0,
orient: Sequence[float] = UP,
) -> Bosl2Solid:
"""A 3-D triangular wedge with the hypotenuse in the X+Z+ quadrant, built directly with polyhedron().
Args:
size: [width, thickness, height]
center: if given, overrides anchor (True -> CENTER, False -> FRONT+LEFT+BOTTOM)
anchor: anchor point (default FRONT+LEFT+BOTTOM)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
"""
sz = [float(size)] * 3 if isinstance(size, (int, float)) else [float(v) for v in size]
use_anchor = anchor
if center is not None:
use_anchor = CENTER if center else [-1, -1, -1]
pts: list[list[float]] = [[1, 1, -1], [1, -1, -1], [1, -1, 1], [-1, 1, -1], [-1, -1, -1], [-1, -1, 1]]
pts = [[p[0] * sz[0] / 2, p[1] * sz[1] / 2, p[2] * sz[2] / 2] for p in pts]
faces = [
[0, 1, 2],
[3, 5, 4],
[0, 3, 1],
[1, 3, 4],
[1, 4, 2],
[2, 4, 5],
[2, 5, 3],
[0, 2, 3],
]
shape = _opolyhedron(pts, faces)
offset = _anchor_offset_hull3(pts, use_anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=use_anchor)
def _rect_tube_rounding(
factor: float,
inner_radius: Sequence[float | None],
radius: Sequence[float | None],
alternative: Sequence[float | None],
size: Sequence[float],
isize: Sequence[float],
) -> list[float]:
wall = min(size[0] - isize[0], size[1] - isize[1]) / 2 * factor
return [
iri
if iri is not None
else (max(0.0, (ri if ri is not None else 0.0) - wall) if alternative[i] is None else 0.0)
for i, (iri, ri) in enumerate(zip(inner_radius, radius))
]
[docs]
def rect_tube(
height: float | None = None,
size: float | Sequence[float] | None = None,
isize: float | Sequence[float] | None = None,
center: bool | None = None,
shift: Sequence[float] = [0, 0],
wall: float | None = None,
size1: float | Sequence[float] | None = None,
size2: float | Sequence[float] | None = None,
isize1: float | Sequence[float] | None = None,
isize2: float | Sequence[float] | None = None,
rounding: float | Sequence[float] = 0,
rounding1: float | Sequence[float] | None = None,
rounding2: float | Sequence[float] | None = None,
inner_rounding: float | Sequence[float] = 0,
inner_rounding1: float | Sequence[float] | None = None,
inner_rounding2: float | Sequence[float] | None = None,
chamfer: float | Sequence[float] = 0,
chamfer1: float | Sequence[float] | None = None,
chamfer2: float | Sequence[float] | None = None,
inner_chamfer: float | Sequence[float] = 0,
inner_chamfer1: float | Sequence[float] | None = None,
inner_chamfer2: float | Sequence[float] | None = None,
anchor: Sequence[float] = BOTTOM,
spin: float = 0,
orient: Sequence[float] = UP,
length: float | None = None,
) -> Bosl2Solid:
"""BOSL2 rect_tube() -- a rectangular tube (a rectangle with a rectangular hole through it).
Args:
height/length: height/length of the tube (default 1)
size: outer [X,Y] size of the tube
isize: inner [X,Y] size of the tube
center: if given, overrides anchor
shift: [X,Y] shift of the top center relative to the bottom center
wall: wall thickness
size1/size2: outer [X,Y] size at the bottom/top
isize1/isize2: inner [X,Y] size at the bottom/top
rounding/rounding1/rounding2: outer edge rounding radius (overall/bottom/top)
inner_rounding/inner_rounding1/inner_rounding2: inner edge rounding radius (default: same as rounding)
chamfer/chamfer1/chamfer2: outer edge chamfer size (overall/bottom/top)
inner_chamfer/inner_chamfer1/inner_chamfer2: inner edge chamfer size (default: same as chamfer)
anchor: anchor point (default BOTTOM)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
"""
from .shapes2d import _rect_path
def as2(v: float | Sequence[float] | None) -> list[float] | None:
if v is None:
return None
return [float(v), float(v)] if isinstance(v, (int, float)) else [float(x) for x in v]
def force4(v: float | Sequence[float] | None) -> list[float | None]:
if v is None:
return [None, None, None, None]
return [float(v)] * 4 if isinstance(v, (int, float)) else [float(x) for x in v]
def force4f(v: float | Sequence[float]) -> list[float]:
return [float(v)] * 4 if isinstance(v, (int, float)) else [float(x) for x in v]
def override_or_none(
specific: float | Sequence[float] | None, general: float | Sequence[float]
) -> float | Sequence[float] | None:
# `general` (inner_rounding/inner_chamfer) defaults to 0 rather than None in this port's
# signature, so a bare 0 is treated as "not specified" (inherit from rounding/chamfer);
# pass inner_rounding1=/inner_rounding2=/inner_chamfer1=/inner_chamfer2= (which do default to None) to force
# an explicit zero.
if specific is not None:
return specific
return general if general else None
height = height if height is not None else (length if length is not None else 1)
s1 = as2(size1) if size1 is not None else as2(size)
s2 = as2(size2) if size2 is not None else as2(size)
i1 = as2(isize1) if isize1 is not None else as2(isize)
i2 = as2(isize2) if isize2 is not None else as2(isize)
size1_v = (
s1
if s1 is not None
else ([i1[0] + 2 * wall, i1[1] + 2 * wall] if (wall is not None and i1 is not None) else None)
)
size2_v = (
s2
if s2 is not None
else ([i2[0] + 2 * wall, i2[1] + 2 * wall] if (wall is not None and i2 is not None) else None)
)
isize1_v = (
i1
if i1 is not None
else ([s1[0] - 2 * wall, s1[1] - 2 * wall] if (wall is not None and s1 is not None) else None)
)
isize2_v = (
i2
if i2 is not None
else ([s2[0] - 2 * wall, s2[1] - 2 * wall] if (wall is not None and s2 is not None) else None)
)
assert size1_v is not None and size2_v is not None, "rect_tube(): bad size/size1/size2 argument."
assert isize1_v is not None and isize2_v is not None, "rect_tube(): bad isize/isize1/isize2 argument."
assert isize1_v[0] < size1_v[0] and isize1_v[1] < size1_v[1], (
"rect_tube(): inner size is larger than outer size at the bottom."
)
assert isize2_v[0] < size2_v[0] and isize2_v[1] < size2_v[1], (
"rect_tube(): inner size is larger than outer size at the top."
)
rounding1_v = force4f(rounding1 if rounding1 is not None else rounding)
rounding2_v = force4f(rounding2 if rounding2 is not None else rounding)
chamfer1_v = force4f(chamfer1 if chamfer1 is not None else chamfer)
chamfer2_v = force4f(chamfer2 if chamfer2 is not None else chamfer)
irounding1_t = force4(override_or_none(inner_rounding1, inner_rounding))
irounding2_t = force4(override_or_none(inner_rounding2, inner_rounding))
ichamfer1_t = force4(override_or_none(inner_chamfer1, inner_chamfer))
ichamfer2_t = force4(override_or_none(inner_chamfer2, inner_chamfer))
irounding1_v = _rect_tube_rounding(1.0, irounding1_t, rounding1_v, ichamfer1_t, size1_v, isize1_v)
irounding2_v = _rect_tube_rounding(1.0, irounding2_t, rounding2_v, ichamfer2_t, size2_v, isize2_v)
ichamfer1_v = _rect_tube_rounding(1 / math.sqrt(2), ichamfer1_t, chamfer1_v, irounding1_t, size1_v, isize1_v)
ichamfer2_v = _rect_tube_rounding(1 / math.sqrt(2), ichamfer2_t, chamfer2_v, irounding2_t, size2_v, isize2_v)
use_anchor = anchor
if center is not None:
use_anchor = CENTER if center else BOTTOM
outer = prismoid(
size1_v,
size2_v,
height=height,
shift=shift,
rounding1=rounding1_v,
rounding2=rounding2_v,
chamfer1=chamfer1_v,
chamfer2=chamfer2_v,
anchor=CENTER,
)
inner = prismoid(
isize1_v,
isize2_v,
height=height + 0.02,
shift=shift,
rounding1=irounding1_v,
rounding2=irounding2_v,
chamfer1=ichamfer1_v,
chamfer2=ichamfer2_v,
anchor=CENTER,
)
shape = outer.shape - inner.shape
path1 = _rect_path(size1_v, rounding=rounding1_v, chamfer=chamfer1_v)
path2 = _rect_path(size2_v, rounding=rounding2_v, chamfer=chamfer2_v)
bottom_pts = [[p[0], p[1], -height / 2] for p in path1]
top_pts = [[p[0] + shift[0], p[1] + shift[1], height / 2] for p in path2]
offset = _anchor_offset_hull3(bottom_pts + top_pts, use_anchor)
straight = size1_v == size2_v and shift[0] == 0 and shift[1] == 0
out_size = [size1_v[0], size1_v[1], height] if straight else None
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=out_size, anchor=use_anchor)
# ---------------------------------------------------------------------------
# Section: Cylinders
# ---------------------------------------------------------------------------
[docs]
def cylinder(
height: float | None = None,
radius1: float | None = None,
radius2: float | None = None,
center: bool | None = None,
length: float | None = None,
radius: float | None = None,
diameter: float | None = None,
diameter1: float | None = None,
diameter2: float | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A cylinder/cone, built with the builtin cylinder(), with BOSL2-style anchor/spin/orient support.
Args:
length/height: height of the cylinder
radius1: bottom radius (before orientation)
radius2: top radius (before orientation)
center: if given, overrides anchor (True -> CENTER, False -> BOTTOM)
diameter1: bottom diameter (before orientation)
diameter2: top diameter (before orientation)
radius: radius of the cylinder
diameter: diameter of the cylinder
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
fn/fa/fs: arc smoothness overrides
"""
length = length if length is not None else (height if height is not None else 1)
rad1 = _pick_radius(radius1=radius1, diameter1=diameter1, radius=radius, diameter=diameter, dflt=1)
rad2 = _pick_radius(radius1=radius2, diameter1=diameter2, radius=radius, diameter=diameter, dflt=1)
use_anchor = anchor
if center is not None:
use_anchor = CENTER if center else BOTTOM
shape = _ocylinder(
height=length,
radius1=rad1,
radius2=rad2,
center=True,
fn=fn,
fa=fa,
fs=fs,
)
offset = _anchor_offset_cyl(rad1, rad2, length, use_anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=use_anchor)
[docs]
def cyl(
height: float | None = None,
radius: float | None = None,
center: bool | None = None,
length: float | None = None,
radius1: float | None = None,
radius2: float | None = None,
diameter: float | None = None,
diameter1: float | None = None,
diameter2: float | None = None,
chamfer: float | None = None,
chamfer1: float | None = None,
chamfer2: float | None = None,
chamfer_angle: float | None = None,
chamfer_angle1: float | None = None,
chamfer_angle2: float | None = None,
rounding: float | None = None,
rounding1: float | None = None,
rounding2: float | None = None,
circum: bool = False,
realign: bool = False,
shift: Sequence[float] = [0, 0],
from_end: bool | None = None,
from_end1: bool | None = None,
from_end2: bool | None = None,
texture: str | list | None = None,
tex_size: Sequence[float] = [5, 5],
tex_counts: Sequence[float] | None = None,
tex_inset: bool | float = False,
tex_rot: bool = False,
tex_scale: float = 1,
tex_samples: int | None = None,
tex_taper: float | list | None = None,
tex_style: str = "min_edge",
anchor: Sequence[float] | None = None,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A cylinder with optional chamfering/rounding of its end rims, built with
cube()/cylinder()/sphere()/rotate_extrude().
Positive rounding is built as a minkowski() of a shorter cylinder with a sphere at each
rounded end (an inset fillet, not an outward bulge), matching BOSL2's own rounded-end
geometry. Chamfering builds the exact half-profile (with the requested bevel at each end)
and revolves it with rotate_extrude().
Note: `texture=` (VNF surface texturing) is not supported by this pure-Python port.
Args:
length/height: length of the cylinder along its axis (default 1)
radius: radius of the cylinder (default 1)
center: if given, overrides anchor (True -> CENTER, False -> BOTTOM)
radius1/radius2: radius of the negative/positive end of the cylinder
diameter/diameter1/diameter2: diameter of the cylinder / negative end / positive end
chamfer/chamfer1/chamfer2: chamfer size on the ends (overall/bottom/top)
rounding/rounding1/rounding2: rounding radius on the ends (overall/bottom/top)
circum: circumscribe rather than inscribe the given radius (default False)
realign: rotate by half the angle of one face (default False)
shift: [X,Y] shift of the top center relative to the bottom center
anchor: anchor point (default CENTER, or BOTTOM if center=False)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
fn/fa/fs: arc smoothness overrides
Examples:
.. pythonscad-example::
shape = bosl2.shapes3d.cyl(height=30, radius=10)
shape.show()
.. pythonscad-example::
shape = bosl2.shapes3d.cyl(height=30, radius=10, rounding=3)
shape.show()
"""
if texture is not None:
raise NotImplementedError("cyl(): texture= is not supported by this pure-Python port.")
length = length if length is not None else (height if height is not None else 1)
rad1 = _pick_radius(radius1=radius1, diameter1=diameter1, radius=radius, diameter=diameter, dflt=1)
rad2 = _pick_radius(radius1=radius2, diameter1=diameter2, radius=radius, diameter=diameter, dflt=1)
if circum:
sides = _frag_count(max(rad1, rad2), fn, fa, fs)
sc = 1 / math.cos(math.pi / sides)
rad1 *= sc
rad2 *= sc
use_anchor = anchor
if use_anchor is None:
use_anchor = CENTER if center is None or center else BOTTOM
r1v = rounding1 if rounding1 is not None else (rounding if rounding is not None else 0)
r2v = rounding2 if rounding2 is not None else (rounding if rounding is not None else 0)
c1v = chamfer1 if chamfer1 is not None else (chamfer if chamfer is not None else 0)
c2v = chamfer2 if chamfer2 is not None else (chamfer if chamfer is not None else 0)
assert not ((r1v or r2v) and (c1v or c2v)), "Cannot specify nonzero value for both chamfer and rounding"
if not (r1v or r2v or c1v or c2v):
shape = _ocylinder(
height=length,
radius1=rad1,
radius2=rad2,
center=True,
fn=fn,
fa=fa,
fs=fs,
)
elif rad1 == rad2 and r1v == r2v and r1v > 0 and not c1v and not c2v:
# Straight cylinder, uniform rounding on both ends: exact via minkowski(cylinder, sphere).
inner_r = max(0.001, rad1 - r1v)
inner_l = max(0.001, length - 2 * r1v)
sphere_fn = int(_quantup(_frag_count(r1v, fn, fa, fs), 4))
shape = _ominkowski(
_ocylinder(height=inner_l, radius=inner_r, center=True, fn=fn, fa=fa, fs=fs),
_osphere(radius=r1v, fn=sphere_fn),
)
else:
profile = _cyl_profile(rad1, rad2, length, r1v, r2v, c1v, c2v, fn, fa, fs)
from .shapes2d import _opolygon
shape = _orotate_extrude(_opolygon(profile), fn=fn, fa=fa, fs=fs)
if realign:
sides = _frag_count(max(rad1, rad2), fn, fa, fs)
shape = shape.rotate(180 / sides, [0, 0, 1])
if shift[0] or shift[1]:
shear = [
[1, 0, shift[0] / length, 0],
[0, 1, shift[1] / length, 0],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
shape = shape.multmatrix(shear)
offset = _anchor_offset_cyl(rad1, rad2, length, use_anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=use_anchor)
def _cyl_profile(
radius1: float,
radius2: float,
length: float,
rounding1: float = 0,
rounding2: float = 0,
chamfer1: float = 0,
chamfer2: float = 0,
fn=None,
fa=None,
fs=None,
) -> list[list[float]]:
from .shapes2d import _arc_points
path = [[0.0, -length / 2]]
if rounding1:
sides = max(3, _frag_count(rounding1, fn, fa, fs) // 4)
center = [radius1 - rounding1, -length / 2 + rounding1]
path.extend(_arc_points(sides, rounding1, 270, 90, center))
elif chamfer1:
path.append([radius1 - chamfer1, -length / 2])
path.append([radius1, -length / 2 + chamfer1])
else:
path.append([radius1, -length / 2])
if rounding2:
sides = max(3, _frag_count(rounding2, fn, fa, fs) // 4)
center = [radius2 - rounding2, length / 2 - rounding2]
path.extend(_arc_points(sides, rounding2, 0, 90, center))
elif chamfer2:
path.append([radius2, length / 2 - chamfer2])
path.append([radius2 - chamfer2, length / 2])
else:
path.append([radius2, length / 2])
path.append([0.0, length / 2])
return path
[docs]
def regular_prism(
sides: int,
height: float | None = None,
radius: float | None = None,
diameter: float | None = None,
radius1: float | None = None,
radius2: float | None = None,
inner_radius: float | None = None,
inner_diameter: float | None = None,
side: float | None = None,
length: float | None = None,
chamfer: float | None = None,
chamfer1: float | None = None,
chamfer2: float | None = None,
rounding: float | None = None,
rounding1: float | None = None,
rounding2: float | None = None,
circum: bool = False,
realign: bool = False,
shift: Sequence[float] = [0, 0],
center: bool | None = None,
anchor: Sequence[float] | None = None,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A regular sides-sided prism (or frustum) -- the sides-gon analogue of cyl(): a regular polygon
cross-section extruded along Z, with optional per-end chamfer or rounding. Built the same
way cyl() is (native cylinder with fn=sides for the plain case; a revolved half-profile with
fn=sides for chamfered/rounded ends), so it shares cyl()'s exact rim geometry.
Sizing gives the CIRCUMradius (vertex distance) unless noted -- exactly one of ``radius``/``diameter``
(radius/diameter to the vertices), ``inner_radius``/``inner_diameter`` (inradius/apothem to the face centers,
converted via ``/cos(180/sides)``), or ``side`` (edge length, converted via ``/(2 sin(180/sides))``).
``radius1``/``radius2`` (or the corresponding taper) set the bottom/top radius independently for a frustum.
Note: BOSL2 regular_prism()'s texture=/teardrop= options are not ported (they need the VNF
texturing machinery this pure-Python port doesn't implement).
Args:
sides: number of sides (integer >= 3)
height/length/height/length: prism height (default 1)
radius/diameter/inner_radius/inner_diameter/side: overall size (see above)
radius1/radius2: bottom/top circumradius for a tapered prism
chamfer/chamfer1/chamfer2: end chamfer size (overall/bottom/top)
rounding/rounding1/rounding2: end rounding radius (overall/bottom/top)
circum: circumscribe the nominal radius (scale by 1/cos(180/sides)) (default False)
realign: rotate by half a facet so a face, not a vertex, faces +X (default False)
shift: [X,Y] shift of the top center relative to the bottom center
center: if given, overrides anchor (True -> CENTER, False -> BOTTOM)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
Examples:
.. pythonscad-example::
shape = bosl2.shapes3d.regular_prism(6, height=20, radius=15)
shape.show()
.. pythonscad-example::
shape = bosl2.shapes3d.regular_prism(5, height=20, inner_radius=12, rounding=2)
shape.show()
"""
assert isinstance(sides, int) and sides > 2, f"regular_prism(): sides must be an integer >= 3, got {sides}"
cos_half = math.cos(math.pi / sides)
def circumradius(spec_r: float | None) -> float:
if spec_r is not None:
return spec_r
if side is not None:
return side / (2 * math.sin(math.pi / sides))
if inner_diameter is not None:
return (inner_diameter / 2) / cos_half
if inner_radius is not None:
return inner_radius / cos_half
if diameter is not None:
return diameter / 2
if radius is not None:
return radius
return 1.0
rad1 = circumradius(radius1)
rad2 = circumradius(radius2)
if circum:
sc = 1 / cos_half
rad1 *= sc
rad2 *= sc
prism_len = next((v for v in (length, height, height, length) if v is not None), 1.0)
r1v = rounding1 if rounding1 is not None else (rounding if rounding is not None else 0)
r2v = rounding2 if rounding2 is not None else (rounding if rounding is not None else 0)
c1v = chamfer1 if chamfer1 is not None else (chamfer if chamfer is not None else 0)
c2v = chamfer2 if chamfer2 is not None else (chamfer if chamfer is not None else 0)
assert not ((r1v or r2v) and (c1v or c2v)), "Cannot specify nonzero value for both chamfer and rounding"
use_anchor = anchor
if use_anchor is None:
use_anchor = CENTER if center is None or center else BOTTOM
if not (r1v or r2v or c1v or c2v):
shape = _ocylinder(height=prism_len, radius1=rad1, radius2=rad2, center=True, fn=sides)
else:
profile = _cyl_profile(rad1, rad2, prism_len, r1v, r2v, c1v, c2v, fn, fa, fs)
from .shapes2d import _opolygon
shape = _orotate_extrude(_opolygon(profile), fn=sides)
# OpenSCAD's cylinder(fn=n) puts a vertex on +X; realign rotates half a facet so a face
# centre faces +X instead (BOSL2's realign convention).
if realign:
shape = shape.rotate(180 / sides, [0, 0, 1])
if shift[0] or shift[1]:
shear = [
[1, 0, shift[0] / prism_len, 0],
[0, 1, shift[1] / prism_len, 0],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
shape = shape.multmatrix(shear)
offset = _anchor_offset_cyl(rad1, rad2, prism_len, use_anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=use_anchor)
[docs]
def xcyl(
height: float | None = None,
radius: float | None = None,
diameter: float | None = None,
radius1: float | None = None,
radius2: float | None = None,
diameter1: float | None = None,
diameter2: float | None = None,
length: float | None = None,
chamfer: float | None = None,
chamfer1: float | None = None,
chamfer2: float | None = None,
rounding: float | None = None,
rounding1: float | None = None,
rounding2: float | None = None,
circum: bool = False,
realign: bool = False,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A cylinder oriented along the X axis. See cyl() for argument details."""
length = length if length is not None else (height if height is not None else 1)
rad1 = _pick_radius(radius1=radius1, diameter1=diameter1, radius=radius, diameter=diameter, dflt=1)
rad2 = _pick_radius(radius1=radius2, diameter1=diameter2, radius=radius, diameter=diameter, dflt=1)
shape = cyl(
length=length,
radius1=rad1,
radius2=rad2,
chamfer=chamfer,
chamfer1=chamfer1,
chamfer2=chamfer2,
rounding=rounding,
rounding1=rounding1,
rounding2=rounding2,
circum=circum,
realign=realign,
anchor=CENTER,
fn=fn,
fa=fa,
fs=fs,
).shape.rotate(90, [0, 1, 0])
offset = _anchor_offset_cyl(rad1, rad2, length, anchor, axis=0)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=anchor)
[docs]
def ycyl(
height: float | None = None,
radius: float | None = None,
diameter: float | None = None,
radius1: float | None = None,
radius2: float | None = None,
diameter1: float | None = None,
diameter2: float | None = None,
length: float | None = None,
chamfer: float | None = None,
chamfer1: float | None = None,
chamfer2: float | None = None,
rounding: float | None = None,
rounding1: float | None = None,
rounding2: float | None = None,
circum: bool = False,
realign: bool = False,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A cylinder oriented along the Y axis. See cyl() for argument details."""
length = length if length is not None else (height if height is not None else 1)
rad1 = _pick_radius(radius1=radius1, diameter1=diameter1, radius=radius, diameter=diameter, dflt=1)
rad2 = _pick_radius(radius1=radius2, diameter1=diameter2, radius=radius, diameter=diameter, dflt=1)
shape = cyl(
length=length,
radius1=rad1,
radius2=rad2,
chamfer=chamfer,
chamfer1=chamfer1,
chamfer2=chamfer2,
rounding=rounding,
rounding1=rounding1,
rounding2=rounding2,
circum=circum,
realign=realign,
anchor=CENTER,
fn=fn,
fa=fa,
fs=fs,
).shape.rotate(-90, [1, 0, 0])
offset = _anchor_offset_cyl(rad1, rad2, length, anchor, axis=1)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=anchor)
[docs]
def zcyl(
height: float | None = None,
radius: float | None = None,
diameter: float | None = None,
radius1: float | None = None,
radius2: float | None = None,
diameter1: float | None = None,
diameter2: float | None = None,
length: float | None = None,
chamfer: float | None = None,
chamfer1: float | None = None,
chamfer2: float | None = None,
rounding: float | None = None,
rounding1: float | None = None,
rounding2: float | None = None,
circum: bool = False,
realign: bool = False,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""
A cylinder oriented along the Z axis (same as cyl() with default orientation). See cyl() for
argument details.
"""
return cyl(
height=height,
radius=radius,
diameter=diameter,
radius1=radius1,
radius2=radius2,
diameter1=diameter1,
diameter2=diameter2,
length=length,
chamfer=chamfer,
chamfer1=chamfer1,
chamfer2=chamfer2,
rounding=rounding,
rounding1=rounding1,
rounding2=rounding2,
circum=circum,
realign=realign,
anchor=anchor,
spin=spin,
orient=orient,
fn=fn,
fa=fa,
fs=fs,
)
[docs]
def tube(
height: float | None = None,
outer_radius: float | None = None,
inner_radius: float | None = None,
center: bool | None = None,
outer_diameter: float | None = None,
inner_diameter: float | None = None,
wall: float | None = None,
outer_radius1: float | None = None,
outer_radius2: float | None = None,
outer_diameter1: float | None = None,
outer_diameter2: float | None = None,
inner_radius1: float | None = None,
inner_radius2: float | None = None,
inner_diameter1: float | None = None,
inner_diameter2: float | None = None,
realign: bool = False,
length: float | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""BOSL2 tube() -- a hollow cylindrical tube.
Note: BOSL2's outer-radius parameters are named `or`/`or1`/`or2`, which collide with the
Python keyword `or`; they are exposed here as `outer_radius`/`outer_radius1`/`outer_radius2` instead.
Args:
height/length: height of the tube (default 1)
outer_radius: outer radius of the tube (BOSL2 `or`) (default 1)
inner_radius: inner radius of the tube
center: if given, overrides anchor (True -> CENTER, False -> DOWN)
outer_diameter: outer diameter of the tube
inner_diameter: inner diameter of the tube
wall: horizontal wall thickness (default 1)
outer_radius1/outer_radius2: outer radius of the bottom/top (BOSL2 `or1`/`or2`)
outer_diameter1/outer_diameter2: outer diameter of the bottom/top
inner_radius1/inner_radius2: inner radius of the bottom/top
inner_diameter1/inner_diameter2: inner diameter of the bottom/top
realign: rotate by half the angle of one face (default False)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
fn/fa/fs: arc smoothness overrides
Examples:
.. pythonscad-example::
shape = bosl2.shapes3d.tube(height=20, outer_radius=15, inner_radius=10)
shape.show()
"""
height = height if height is not None else (length if length is not None else 1)
orr1 = _pick_radius(
radius1=outer_radius1,
diameter1=outer_diameter1,
radius=outer_radius,
diameter=outer_diameter,
dflt=None,
)
orr2 = _pick_radius(
radius1=outer_radius2,
diameter1=outer_diameter2,
radius=outer_radius,
diameter=outer_diameter,
dflt=None,
)
irr1 = _pick_radius(
radius1=inner_radius1,
diameter1=inner_diameter1,
radius=inner_radius,
diameter=inner_diameter,
dflt=None,
)
irr2 = _pick_radius(
radius1=inner_radius2,
diameter1=inner_diameter2,
radius=inner_radius,
diameter=inner_diameter,
dflt=None,
)
wall_v = wall if wall is not None else 1
rad1 = orr1 if orr1 is not None else (irr1 + wall_v if irr1 is not None else None)
rad2 = orr2 if orr2 is not None else (irr2 + wall_v if irr2 is not None else None)
irad1 = irr1 if irr1 is not None else (orr1 - wall_v if orr1 is not None else None)
irad2 = irr2 if irr2 is not None else (orr2 - wall_v if orr2 is not None else None)
assert rad1 is not None and rad2 is not None and irad1 is not None and irad2 is not None, (
"tube(): must specify two of inner radius/diam, outer radius/diam, and wall width."
)
assert irad1 <= rad1 and irad2 <= rad2, "tube(): inner radius is larger than outer radius."
use_anchor = anchor
if center is not None:
use_anchor = CENTER if center else BOTTOM
outer = _ocylinder(
height=height,
radius1=rad1,
radius2=rad2,
center=True,
fn=fn,
fa=fa,
fs=fs,
)
inner = _ocylinder(
height=height + 0.02,
radius1=irad1,
radius2=irad2,
center=True,
fn=fn,
fa=fa,
fs=fs,
)
shape = outer - inner
if realign:
sides = _frag_count(max(rad1, rad2), fn, fa, fs)
shape = shape.rotate(180 / sides, [0, 0, 1])
offset = _anchor_offset_cyl(rad1, rad2, height, use_anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=use_anchor)
[docs]
def pie_slice(
height: float | None = None,
radius: float | None = None,
angle: float = 30,
center: bool | None = None,
radius1: float | None = None,
radius2: float | None = None,
diameter: float | None = None,
diameter1: float | None = None,
diameter2: float | None = None,
length: float | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""BOSL2 pie_slice() -- a pie slice, wedge of a cylinder/cone.
Args:
height/length: height of the pie slice
radius: radius of the pie slice
angle: pie slice angle in degrees (default 30)
center: if given, overrides anchor
radius1/radius2: bottom/top radius of the pie slice
diameter/diameter1/diameter2: diameter of the pie slice / bottom / top
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
fn/fa/fs: arc smoothness overrides
"""
from .shapes2d import _arc_points, _opolygon
length = height if height is not None else (length if length is not None else 1)
rad1 = _pick_radius(radius1=radius1, diameter1=diameter1, radius=radius, diameter=diameter, dflt=10)
rad2 = _pick_radius(radius1=radius2, diameter1=diameter2, radius=radius, diameter=diameter, dflt=10)
use_anchor = anchor
if center is not None:
use_anchor = CENTER if center else BOTTOM
base = _ocylinder(
height=length,
radius1=rad1,
radius2=rad2,
center=True,
fn=fn,
fa=fa,
fs=fs,
)
if isinstance(angle, (list, tuple)): # [start, end] wedge
start, sweep = float(angle[0]), float(angle[1]) - float(angle[0])
else:
start, sweep = 0.0, float(angle)
ang_v = sweep % 360 if (sweep > 360 or sweep < 0) else sweep
if ang_v <= 0 or ang_v >= 360:
shape = base
else:
maxd = max(rad1, rad2) + 0.1
sides = max(3, math.ceil(_frag_count(maxd, fn, fa, fs) * ang_v / 360))
arc = _arc_points(sides, maxd, start, ang_v)
sector = _opolygon([[0.0, 0.0]] + arc).linear_extrude(height=length + 0.2, center=True)
shape = base & sector
offset = _anchor_offset_cyl(rad1, rad2, length, use_anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=use_anchor)
# ---------------------------------------------------------------------------
# Section: Other Round Objects
# ---------------------------------------------------------------------------
[docs]
def sphere(
radius: float | None = None,
diameter: float | None = None,
circum: bool = False,
style: str = "orig",
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A sphere, built with the builtin sphere(), with BOSL2-style anchor/spin/orient support.
Note: `style=`/`circum=` are accepted for signature compatibility but not applied; the
builtin sphere() is used directly.
Args:
radius: radius of the sphere
diameter: diameter of the sphere
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
fn/fa/fs: arc smoothness overrides
Examples:
.. pythonscad-example::
shape = bosl2.shapes3d.sphere(radius=15)
shape.show()
"""
rad = radius if radius is not None else (diameter / 2 if diameter is not None else 1)
shape = _osphere(radius=rad, fn=fn, fa=fa, fs=fs)
offset = _anchor_offset_sphere(rad, anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=anchor)
[docs]
def spheroid(
radius: float | None = None,
style: str = "aligned",
diameter: float | None = None,
circum: bool = False,
dual: bool = False,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""An approximate sphere; this pure-Python port just builds a plain sphere() (style/dual are ignored).
Args:
radius: radius of the spheroid
diameter: diameter of the spheroid
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
fn/fa/fs: arc smoothness overrides
"""
return sphere(
radius=radius,
diameter=diameter,
anchor=anchor,
spin=spin,
orient=orient,
fn=fn,
fa=fa,
fs=fs,
)
def _teardrop2d_path(
radius: float,
angle: float,
cap_height: float | None,
circum: bool,
realign: bool,
sides: int,
) -> list[list[float]]:
"""The 2-D (X,Y) outline of a BOSL2-style teardrop2d(): a circle of radius *radius* capped by a
point (or, if *cap_height* truncates it, a flat top) formed by two walls tangent to the circle at
+-*angle* degrees from the Y axis. *sides* is the segment count for a full circle of this radius
(as from _frag_count()); *realign* is approximated by toggling the parity of the round
section's vertex count, since a vertex landing exactly at the bottom gives a "point" and a
vertex straddling it gives a "flat" bottom -- the same effect BOSL2 gets from its own $fn
discretization.
"""
from .shapes2d import _arc_points
rad = radius / math.cos(math.pi / sides) if circum else radius
maxheight = rad / math.sin(math.radians(angle))
minheight = rad * math.sin(math.radians(angle))
assert cap_height is None or cap_height >= minheight - 1e-9, (
"teardrop2d(): cap_height cannot be less than radius*sin(angle)."
)
pointy = cap_height is None or cap_height >= maxheight
sweep = 180 + 2 * angle
pts = max(2, round(sides * sweep / 360)) + 1
if realign == (pts % 2 == 1):
pts += 1
arc = _arc_points(pts, rad, angle, -sweep, [0.0, 0.0])
if pointy or cap_height is None:
return [[0.0, maxheight]] + arc
cap_x = (maxheight - cap_height) * math.tan(math.radians(angle))
return [[cap_x, cap_height]] + arc + [[-cap_x, cap_height]]
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 .shapes2d import _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]
[docs]
def torus(
major_radius: float | None = None,
minor_radius: float | None = None,
center: bool | None = None,
major_diameter: float | None = None,
minor_diameter: float | None = None,
outer_radius: float | None = None,
inner_radius: float | None = None,
outer_diameter: float | None = None,
inner_diameter: float | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""BOSL2 torus() -- a torus (donut) shape.
Note: BOSL2's outer-radius parameter is named `or`, which collides with the Python
keyword `or`; it is exposed here as `outer_radius` instead.
Args:
major_radius: major radius of the torus ring (use with minor_radius or minor_diameter)
minor_radius: minor radius of the torus ring (use with major_radius or major_diameter)
center: if given, overrides anchor (True -> CENTER, False -> DOWN)
major_diameter: major diameter of the torus ring
minor_diameter: minor diameter of the torus ring
outer_radius: outer radius of the torus (BOSL2 `or`) (use with inner_radius or inner_diameter)
inner_radius: inside radius of the torus (use with outer_radius or outer_diameter)
outer_diameter: outer diameter of the torus (use with inner_radius or inner_diameter)
inner_diameter: inside diameter of the torus (use with outer_radius or outer_diameter)
anchor: anchor point (default CENTER)
orient: direction to rotate the top towards, after spin (default UP)
fn/fa/fs: arc smoothness overrides
Examples:
.. pythonscad-example::
shape = bosl2.shapes3d.torus(major_radius=25, minor_radius=8)
shape.show()
"""
from .shapes2d import _arc_points, _opolygon
_or = _pick_radius(radius=outer_radius, diameter=outer_diameter, dflt=None)
_ir = _pick_radius(radius=inner_radius, diameter=inner_diameter, dflt=None)
_r_maj = _pick_radius(radius=major_radius, diameter=major_diameter, dflt=None)
_r_min = _pick_radius(radius=minor_radius, diameter=minor_diameter, dflt=None)
if _r_maj is not None:
maj_rad = _r_maj
elif _ir is not None and _or is not None:
maj_rad = (_or + _ir) / 2
elif _ir is not None and _r_min is not None:
maj_rad = _ir + _r_min
elif _or is not None and _r_min is not None:
maj_rad = _or - _r_min
else:
assert False, "torus(): bad parameters."
if _r_min is not None:
min_rad = _r_min
elif _ir is not None:
min_rad = maj_rad - _ir
elif _or is not None:
min_rad = _or - maj_rad
else:
assert False, "torus(): bad parameters."
use_anchor = anchor
if center is not None:
use_anchor = CENTER if center else DOWN
sides = _frag_count(min_rad, fn, fa, fs)
profile = _arc_points(sides, min_rad, 0, 360, [maj_rad, 0.0], endpoint=False)
shape = _orotate_extrude(_opolygon(profile), fn=fn, fa=fa, fs=fs)
offset = _anchor_offset_cyl(maj_rad + min_rad, maj_rad + min_rad, min_rad * 2, use_anchor)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=use_anchor)
[docs]
def teardrop(
height: float | None = None,
radius: float | None = None,
angle: float = 45,
cap_height: float | None = None,
circum: bool = False,
radius1: float | None = None,
radius2: float | None = None,
diameter: float | None = None,
diameter1: float | None = None,
diameter2: float | None = None,
cap_h1: float | None = None,
cap_h2: float | None = None,
chamfer: float = 0,
chamfer1: float = 0,
chamfer2: float = 0,
realign: bool = False,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""BOSL2 teardrop() -- a teardrop shape, useful for 3D-printable horizontal holes.
Args:
height/l: thickness of the teardrop (default 1)
radius: radius of the circular part (default 1)
angle: angle of the hat walls from the Z axis in degrees (default 45)
cap_height: height above center to truncate the shape (default: no truncation)
circum: produce a circumscribing teardrop shape (default False)
radius1/radius2: radius of the circular portion of the front/back end
diameter/diameter1/diameter2: diameter of the circular portion / front end / back end
cap_h1/cap_h2: truncation height on the front/back side
chamfer/chamfer1/chamfer2: chamfer size along the bottom/top faces (overall/bottom/top) (default 0)
realign: shift face alignment, passed to teardrop2d (default False)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
fn/fa/fs: arc smoothness overrides
"""
length = height if height is not None else 1.0
rad1 = _pick_radius(radius1=radius1, diameter1=diameter1, radius=radius, diameter=diameter, dflt=1)
rad2 = _pick_radius(radius1=radius2, diameter1=diameter2, radius=radius, diameter=diameter, dflt=1)
cap_h1v = cap_h1 if cap_h1 is not None else cap_height
cap_h2v = cap_h2 if cap_h2 is not None else cap_height
c1 = chamfer1 if chamfer1 else chamfer
c2 = chamfer2 if chamfer2 else chamfer
sides = _frag_count(max(rad1, rad2), fn, fa, fs)
def section(rad: float, cap_hv: float | None, y: float) -> list[list[float]]:
path = _teardrop2d_path(rad, angle, cap_hv, circum, realign, sides)
return [[p[0], y, p[1]] for p in path]
front_y, back_y = -length / 2, length / 2
slices = []
if c1:
cap_hv = (cap_h1v - c1) if cap_h1v is not None else None
slices.append(section(max(0.001, rad1 - c1), cap_hv, front_y))
front_y += abs(c1)
slices.append(section(rad1, cap_h1v, front_y))
if c2:
back_y -= abs(c2)
slices.append(section(rad2, cap_h2v, back_y))
if c2:
cap_hv = (cap_h2v - c2) if cap_h2v is not None else None
slices.append(section(max(0.001, rad2 - c2), cap_hv, back_y + abs(c2)))
solids = [_opolyhedron(pts, [list(range(len(pts)))]) for pts in slices]
shape = solids[0]
for a, b in zip(solids, solids[1:]):
piece = _ohull(a, b)
shape = piece if shape is solids[0] else (shape | piece)
offset = _anchor_offset_cyl(rad1, rad2, length, anchor, axis=1)
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=anchor)
[docs]
def onion(
radius: float | None = None,
angle: float = 45,
cap_height: float | None = None,
circum: bool = False,
realign: bool = False,
diameter: float | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
orient: Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""BOSL2 onion() -- an onion-dome shape (a sphere with a conical cap).
Args:
radius: radius of the spherical portion of the bottom (default 1)
angle: angle of the cone from vertical in degrees (default 45)
cap_height: height above the sphere center to truncate the shape (default: no truncation)
circum: circumscribe rather than inscribe the given radius/diameter (default False)
realign: adjust point alignment (flat vs pointy bottom) (default False)
diameter: diameter of the spherical portion of the bottom
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default UP)
fn/fa/fs: arc smoothness overrides
"""
from .shapes2d import _arc_points, _opolygon
rad = _pick_radius(radius=radius, diameter=diameter, dflt=1)
sides = _frag_count(rad, fn, fa, fs)
scaled = rad / math.cos(math.pi / sides) if circum else rad
maxheight = scaled / math.sin(math.radians(angle))
top_z = min(cap_height, maxheight) if cap_height is not None else maxheight
pointy = top_z >= maxheight - 1e-9
sweep = 90 + angle
pts = max(2, round(sides * sweep / 360)) + 1
arc = list(reversed(_arc_points(pts, scaled, angle, -sweep, [0.0, 0.0])))
if pointy:
profile = arc + [[0.0, top_z]]
else:
cap_x = (maxheight - top_z) * math.tan(math.radians(angle))
profile = arc + [[cap_x, top_z], [0.0, top_z]]
shape = _orotate_extrude(_opolygon(profile), fn=fn, fa=fa, fs=fs)
a = list(anchor)
off_z = 0.0 if a[2] == 0 else (scaled if a[2] < 0 else -top_z)
rn = math.hypot(a[0], a[1])
off_xy = [-a[0] / rn * scaled, -a[1] / rn * scaled] if rn > 0 else [0.0, 0.0]
offset = [off_xy[0], off_xy[1], off_z]
return Bosl2Solid(_finish3(shape, offset, spin, orient), size=None, anchor=anchor)
# ---------------------------------------------------------------------------
# Section: Text
# ---------------------------------------------------------------------------
def _text3d_anchor_vec(anchor) -> 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]
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 = [cross(yu, zu), yu, zu]
elif yu is None:
m = [xu, cross(zu, xu), zu]
elif zu is None:
m = [xu, yu, cross(xu, yu)]
else:
m = [xu, yu, zu]
return [
[m[0][0], m[1][0], m[2][0], 0.0],
[m[0][1], m[1][1], m[2][1], 0.0],
[m[0][2], m[1][2], m[2][2], 0.0],
[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, path: Sequence[Sequence[float]], 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 `path_cut_points()` cut point.
"""
out = []
for entry in pathcut:
idx = entry[1]
a = path[idx - 1]
b = path[idx]
c = entry[0]
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])])
return out
def _path_text_bcast_dir(v, dim: int, path: Sequence[Sequence[float]], 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):
return [list(v)] * len(path)
if dim == 2 and is_vector(v, 3) and abs(v[2]) < 1e-9:
return [[v[0], v[1]]] * len(path)
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: Sequence[float] = UP,
) -> 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)
"""
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,
)
# .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)
offset = _anchor_offset_box3([size, size, height], [0, 0, av[2]])
shape = _finish3(shape, offset, spin, orient)
return Bosl2Solid(shape, size=None, anchor=anchor)
[docs]
def path_text(
path: Sequence[Sequence[float]],
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,
) -> 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 -- bosl2.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:
assert False, "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._path_length(path)
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._path_cut_points(path, 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[3]] for p in pts]
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][2]
if toppts is not None:
tt = toppts[i]
proj = sum(a * b for a, b in zip(tangent, tt)) / sum(v * v for v in tt)
adjustment = [proj * v for v in tt]
elif usernorm:
nn = normpts[i]
proj = sum(a * b for a, b in zip(tangent, nn)) / sum(v * v for v in nn)
adjustment = [proj * v for v in nn]
else:
adjustment = [0.0] * dim
x_axis = [tangent[k] - adjustment[k] for k in range(dim)]
# .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").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).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][0]))
result = letters[0]
for s in letters[1:]:
result = result | s
return Bosl2Solid(result, size=None, anchor=CENTER)
# ---------------------------------------------------------------------------
# Advanced surface / plot / ruler builders live in bosl2/surfaces3d.py. They are re-exported here
# LAZILY (module __getattr__) so `from bosl2.shapes3d import heightfield` keeps working without an
# import cycle: surfaces3d imports the core names (Bosl2Solid, _finish3, ...) from this module, so
# this module must not import surfaces3d at load time.
# ---------------------------------------------------------------------------
_SURFACE_EXPORTS = frozenset(
{
"interior_fillet",
"heightfield",
"cylindrical_heightfield",
"plot3d",
"plot_revolution",
"fillet",
"textured_tile",
"ruler",
}
)
def __getattr__(name):
"""Lazily resolve the surfaces3d re-exports (PEP 562), breaking the shapes3d<->surfaces3d cycle."""
if name in _SURFACE_EXPORTS:
import bosl2.surfaces3d as _surfaces3d
return getattr(_surfaces3d, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")