# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause
# LibFile: pybosl2/shapes3d/cuboid.py
# FileSummary: Cubes, prismoids, wedges and general polygonal prism shapes.
# DocCategory: Foundational
# FileGroup: BOSL2
"""Cubes, prismoids, wedges and general polygonal prism shapes."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from pybosl2._edges_lang import Anchor, EdgeAtom
from pybosl2._native import native
if TYPE_CHECKING:
from collections.abc import Sequence
from openscad import PyOpenSCAD
from pybosl2.points import Point
from pybosl2._helpers import frag_count as _frag_count
from pybosl2._helpers import quantup
from pybosl2.constants import BOTTOM, CENTER, FRONT, LEFT, UP
# Import base class and helper functions from shapes3d.base
from .base import (
Bosl2Solid,
_anchor_offset_box3,
_anchor_offset_cyl,
_anchor_offset_hull3,
_finish3,
_ocylinder,
_osphere,
_resolve_center_anchor,
)
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")
from pybosl2._edges_lang import EDGE_OFFSETS, EDGES_ALL
from pybosl2._edges_lang import edges as resolve_edges
from pybosl2.shapes3d.cylinder import cyl_profile
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:
"""Return 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: int | None,
fa: float | None,
fs: float | None,
) -> 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) -> "PyOpenSCAD":
return _rotate_to_axis(_ocylinder(height=length, radius=radius, center=True, fn=fn), 0)
def ytcyl(length: float, radius: float) -> "PyOpenSCAD":
return _rotate_to_axis(_ocylinder(height=length, radius=radius, center=True, fn=fn), 1)
def ztcyl(length: float, radius: float) -> "PyOpenSCAD":
return _ocylinder(height=length, radius=radius, center=True, fn=fn)
def tsphere(radius: float) -> "PyOpenSCAD":
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: int | None,
fa: float | None,
fs: float | None,
) -> 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 = []
# The flare is a bar along each treated edge plus a block filling each corner between them
# (BOSL2 cuboid(), negative chamfer/rounding). They are placed on one offset so the bars and
# blocks share whole faces: a piece standing a hair proud of its neighbour, or overlapping
# across its face, leaves faces that cannot merge and a union that is no longer manifold.
adj = [ard, ard, -ard]
for axis in (0, 1):
for i in range(4):
if edge_set[axis][i] > 0:
vec = EDGE_OFFSETS[axis][i]
t = [vec[k] / 2 * (sz[k] + adj[k]) for k in range(3)]
# built with its long side already on *axis* rather than rotated onto it: a
# rotation leaves the bar's faces a rounding error off the body's, and faces that
# are nearly-but-not-quite coincident cannot merge, leaving the union non-manifold
bar = [ard, ard, ard]
bar[axis] = sz[axis]
pieces.append(_ocube(bar, center=True).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:
# sized and placed exactly like the bars it fills between, so the three
# pieces share whole faces instead of overlapping across them
t3 = [[xa, ya, za][k] / 2 * (sz[k] + adj[k]) for k in range(3)]
pieces.append(_ocube([ard, ard, 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: object, method: str = "straight") -> Bosl2Solid:
"""Raise a hip roof over a 2-D *shape* via its straight skeleton (native ``roof()``).
Like :func:`~pybosl2.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:`Path2D.polygon`, or a :class:`Bosl2Solid` wrapping one. *method* selects the skeleton
algorithm. PythonSCAD-only (no BOSL2 counterpart); covered by the STL render tests.
Examples:
.. pythonscad-example::
from pybosl2 import roof, shapes2d as s2
roof(s2.square([20, 10]).shape).show()
"""
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,
chamfer: float | None = None,
rounding: float | None = None,
edges: EdgeAtom | list[EdgeAtom] = Anchor.ALL,
except_edges: list[EdgeAtom] | None = None,
trimcorners: bool = True,
teardrop: bool | float = False,
anchor: Anchor | Sequence[float] = Anchor.CENTER,
spin: float = 0,
orient: Anchor | Sequence[float] = Anchor.TOP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""Return a cube with optional chamfering or rounding of edges and corners.
Delegates to :func:`cuboid` with the full set of edge/corner chamfer and rounding options.
Args:
size: size of the cube, a number or length-3 vector
center: if given, overrides anchor (True -> CENTER, False -> FRONT+LEFT+BOTTOM)
chamfer: chamfer size along all edges (default none)
rounding: rounding radius along all edges (default none)
edges: edge specifier — "ALL", "NONE", "X", "Y", "Z", or list of direction vectors
except_edges: edges to exclude from chamfer/rounding
trimcorners: trim corners where 3+ edges meet (default True)
teardrop: limit the overhang angle for FDM printing (default False)
anchor: anchor point (default Anchor.CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
orient: direction to rotate the top towards, after spin (default Anchor.TOP)
fn: arc smoothness overrides
fa: arc smoothness overrides
fs: arc smoothness overrides
Examples:
Basic cube:
.. pythonscad-example::
from pybosl2.solid import cube
cube(size=20).show()
Cube with chamfered edges:
.. pythonscad-example::
from pybosl2.solid import cube
cube(size=20, chamfer=2).show()
Cube with rounded edges:
.. pythonscad-example::
from pybosl2.solid import cube
cube(size=20, rounding=3).show()
"""
sz = [float(size)] * 3 if isinstance(size, (int, float)) else [float(v) for v in size]
use_anchor: Anchor | Sequence[float] = anchor
if center is not None:
use_anchor = Anchor.CENTER if center else Anchor.BOTTOM_FRONT_LEFT
return cuboid(
size=sz,
chamfer=chamfer,
rounding=rounding,
edges=edges,
except_edges=except_edges,
trimcorners=trimcorners,
teardrop=teardrop,
anchor=use_anchor, # type: ignore[arg-type]
spin=spin,
orient=orient, # type: ignore[arg-type]
fn=fn,
fa=fa,
fs=fs,
)
[docs]
def cuboid(
size: float | Sequence[float] = [1, 1, 1],
p1: Point | None = None,
p2: Point | None = None,
chamfer: float | None = None,
rounding: float | None = None,
edges: EdgeAtom | list[EdgeAtom] = Anchor.ALL,
except_edges: list[EdgeAtom] | None = None,
trimcorners: bool = True,
teardrop: bool | float = False,
anchor: Anchor = Anchor.CENTER,
spin: float = 0,
orient: Anchor = Anchor.TOP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""Return 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=BOTTOM_FRONT_LEFT)
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)
teardrop: enable teardrop rounding (not supported by this pure-Python port)
anchor: anchor point (default Anchor.CENTER)
spin: Z-axis rotation in degrees (default 0)
orient: direction to rotate the top towards (default Anchor.TOP)
fn: arc smoothness overrides for rounded edges/corners
fa: arc smoothness overrides for rounded edges/corners
fs: arc smoothness overrides for rounded edges/corners
Examples:
.. pythonscad-example::
from pybosl2 import cuboid
shape = cuboid([40, 30, 20])
shape.show()
.. pythonscad-example::
from pybosl2 import cuboid
shape = 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=Anchor.BOTTOM_FRONT_LEFT,
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=Anchor.BOTTOM_FRONT_LEFT,
fn=fn,
fa=fa,
fs=fs,
)
return shape.translate([float(p1[0]), float(p1[1]), float(p1[2])])
edge_set = resolve_edges(edges, except_edges or [])
chamfer_v = chamfer if chamfer else 0
rounding_v = rounding if rounding else 0
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:
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:
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 _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: Anchor | Sequence[float] = BOTTOM,
spin: float = 0,
orient: Anchor | Sequence[float] = UP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""Return 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: height of the prism
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: arc smoothness overrides for rounded corners
fa: arc smoothness overrides for rounded corners
fs: arc smoothness overrides for rounded corners
Examples:
.. pythonscad-example::
from pybosl2 import prismoid
shape = prismoid([40, 40], [20, 25], height=30)
shape.show()
"""
from pybosl2._helpers import rect_path as _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 = _resolve_center_anchor(center, anchor, 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 _finish3(shape, offset, spin, orient, size=None, anchor=use_anchor)
[docs]
def octahedron(
size: float = 1,
anchor: Anchor | Sequence[float] = Anchor.CENTER,
spin: float = 0,
orient: Anchor | Sequence[float] = Anchor.TOP,
) -> Bosl2Solid:
"""Return 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)
Examples:
.. pythonscad-example::
from pybosl2.solid import octahedron
octahedron(size=20).show()
"""
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 _finish3(shape, offset, spin, orient, size=None, anchor=anchor)
[docs]
def wedge(
size: Sequence[float] = [1, 1, 1],
center: bool | None = None,
anchor: Anchor | Sequence[float] = FRONT.vector + LEFT.vector + BOTTOM.vector,
spin: float = 0,
orient: Anchor | Sequence[float] = Anchor.TOP,
) -> Bosl2Solid:
"""Return 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)
Examples:
.. pythonscad-example::
from pybosl2.solid import wedge
wedge([30, 20, 15]).show()
"""
sz = [float(size)] * 3 if isinstance(size, (int, float)) else [float(v) for v in size]
use_anchor = _resolve_center_anchor(center, anchor, [-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 _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, strict=False))
]
[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: Anchor | Sequence[float] = BOTTOM.vector,
spin: float = 0,
orient: Anchor | Sequence[float] = Anchor.TOP,
length: float | None = None,
) -> Bosl2Solid:
"""BOSL2 rect_tube() -- a rectangular tube (a rectangle with a rectangular hole through it).
Args:
height: height of the tube (default 1)
length: 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: outer [X,Y] size at the bottom/top
size2: outer [X,Y] size at the bottom/top
isize1: inner [X,Y] size at the bottom/top
isize2: inner [X,Y] size at the bottom/top
rounding: outer edge rounding radius (overall/bottom/top)
rounding1: outer edge rounding radius (overall/bottom/top)
rounding2: outer edge rounding radius (overall/bottom/top)
inner_rounding: inner edge rounding radius (default: same as rounding)
inner_rounding1: inner edge rounding radius (default: same as rounding)
inner_rounding2: inner edge rounding radius (default: same as rounding)
chamfer: outer edge chamfer size (overall/bottom/top)
chamfer1: outer edge chamfer size (overall/bottom/top)
chamfer2: outer edge chamfer size (overall/bottom/top)
inner_chamfer: inner edge chamfer size (default: same as chamfer)
inner_chamfer1: inner edge chamfer size (default: same as chamfer)
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)
Examples:
.. pythonscad-example::
from pybosl2.solid import rect_tube
rect_tube(size=30, wall=3, height=20).show()
"""
from pybosl2._helpers import rect_path as _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, "rect_tube(): bad size/size1/size2 argument."
assert size2_v is not None, "rect_tube(): bad size/size1/size2 argument."
assert isize1_v is not None, "rect_tube(): bad isize/isize1/isize2 argument."
assert isize2_v is not None, "rect_tube(): bad isize/isize1/isize2 argument."
assert isize1_v[0] < size1_v[0], "rect_tube(): inner size is larger than outer size at the bottom."
assert 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], "rect_tube(): inner size is larger than outer size at the top."
assert 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 = _resolve_center_anchor(center, anchor, 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 _finish3(shape, offset, spin, orient, size=out_size, anchor=use_anchor)
[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,
circumscribe: bool = False,
realign: bool = False,
shift: Sequence[float] = [0, 0],
center: bool | None = None,
anchor: Anchor | Sequence[float] | None = None,
spin: float = 0,
orient: Anchor | Sequence[float] = Anchor.TOP,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""Return 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: prism height (default 1)
length: prism height (default 1)
radius: overall size (see above)
diameter: overall size (see above)
inner_radius: overall size (see above)
inner_diameter: overall size (see above)
side: overall size (see above)
radius1: bottom/top circumradius for a tapered prism
radius2: bottom/top circumradius for a tapered prism
chamfer: end chamfer size (overall/bottom/top)
chamfer1: end chamfer size (overall/bottom/top)
chamfer2: end chamfer size (overall/bottom/top)
rounding: end rounding radius (overall/bottom/top)
rounding1: end rounding radius (overall/bottom/top)
rounding2: end rounding radius (overall/bottom/top)
circumscribe: 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)
fn: arc smoothness overrides
fa: arc smoothness overrides
fs: arc smoothness overrides
Examples:
.. pythonscad-example::
from pybosl2 import regular_prism
shape = regular_prism(6, height=20, radius=15)
shape.show()
.. pythonscad-example::
from pybosl2 import regular_prism
shape = regular_prism(5, height=20, inner_radius=12, rounding=2)
shape.show()
"""
assert isinstance(sides, int), f"regular_prism(): sides must be an integer >= 3, got {sides}"
assert 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 circumscribe:
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=fn, fa=fa, fs=fs)
from pybosl2._native import native
_opolygon = native("polygon")
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 _finish3(shape, offset, spin, orient, size=None, anchor=use_anchor)