# 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/shapes2d.py
# Pure-Python port of BOSL2's shapes2d.scad, laid out in the same
# order/sections as the original .scad file so the two are easy to
# cross-reference. No osuse()/BOSL2 runtime dependency at all -- every
# shape's outline is computed here in plain Python and then built with
# direct openscad primitive calls (square()/circle()/polygon()/text()/
# hull()/.offset()), rather than delegating to BOSL2. Every shape function
# always returns real 2-D geometry (never a raw path), wrapped in a
# :class:`Bosl2Shape2D` -- the 2-D counterpart of
# :class:`~bosl2.shapes3d.Bosl2Solid`, which carries the 2-D operators
# (fill(), hull(), offset()) and the 2-D -> 3-D extruders
# (linear_extrude(), rotate_extrude(), path_extrude()) as chainable
# methods. `.shape` unwraps back to the raw native handle.
#
# Anywhere BOSL2 lets you tune arc smoothness with the special variables
# $fn/$fa/$fs, this module exposes the same knob as an explicit `fn`/
# `fa`/`fs` keyword argument (matching this project's existing calling
# convention, e.g. `circle(radius=5, fn=64)`), and uses it when computing the
# point count for any rounded/curved portion of the shape.
#
# FileSummary: 2D primitives, polygons, curves, text and rounding (BOSL2 shapes2d.scad).
# FileGroup: BOSL2
from __future__ import annotations
import math
import numbers
import random
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Union, overload
import numpy as np
from bosl2._native import native
# Imported explicitly (rather than `from pythonscad import *`) so editors/type-checkers
# can resolve these names -- this module immediately shadows all five with its own
# BOSL2-style square()/circle()/polygon()/text()/hull() below, so the plain builtins are
# captured under private names first.
if TYPE_CHECKING:
from openscad import PyOpenSCAD # noqa: F401
from bosl2.shapes3d import Bosl2Solid # 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 is_collinear
from bosl2.paths import Path
from bosl2.vectors import unit
from .constants import CENTER
if TYPE_CHECKING: # real stub-typed imports for the checker (identical to pre-lazy)
from pythonscad import circle as _ocircle
from pythonscad import fill as _ofill
from pythonscad import hull as _ohull
from pythonscad import polygon as _opolygon
from pythonscad import square as _osquare
from pythonscad import text as _otext
else:
_ocircle = native("circle")
_ofill = native("fill")
_ohull = native("hull")
_opolygon = native("polygon")
_osquare = native("square")
_otext = native("text")
# ---------------------------------------------------------------------------
# Internal helpers (not part of BOSL2's public API)
# ---------------------------------------------------------------------------
def _frag_count(
radius: float,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> int:
"""
Number of polygon segments to approximate a circle of radius *radius*, mirroring OpenSCAD's
$fn/$fa/$fs rules.
"""
if fn is not None and fn >= 3:
return int(math.floor(fn))
fa = fa if fa else 12.0
fs = fs if fs else 2.0
return max(5, int(math.ceil(min(360.0 / fa, (2 * math.pi * abs(radius)) / fs))))
def _quant(x: float, y: float) -> float:
return math.ceil(x / y) * y
def _polar_to_xy(radius: float, angle: float) -> list[float]:
rad = math.radians(angle)
return [radius * math.cos(rad), radius * math.sin(rad)]
def _rotate2d(point: Sequence[float], degrees: float) -> list[float]:
rad = math.radians(degrees)
c, s = math.cos(rad), math.sin(rad)
return [point[0] * c - point[1] * s, point[0] * s + point[1] * c]
def _circle_pts(radius: float, count: int, start: float = 0.0) -> list[list[float]]:
return [_polar_to_xy(radius, start + 360.0 * i / count) for i in range(count)]
def _arc_points(
count: int,
radius: float,
start: float,
angle: float,
center: Sequence[float] = (0.0, 0.0),
endpoint: bool = True,
) -> list[list[float]]:
"""
*count* points along an arc of radius *radius* centered at *center*, from angle *start*
sweeping *angle* degrees.
"""
if not endpoint:
return _arc_points(count + 1, radius, start, angle, center, True)[:-1]
if count <= 1:
return [
[
radius * math.cos(math.radians(start)) + center[0],
radius * math.sin(math.radians(start)) + center[1],
]
]
pts = []
for i in range(count):
theta = math.radians(start + i * angle / (count - 1))
pts.append([radius * math.cos(theta) + center[0], radius * math.sin(theta) + center[1]])
return pts
def _arc_between_points(
center: Sequence[float],
point_start: Sequence[float],
point_end: Sequence[float],
radius: float,
endpoint: bool = True,
fn=None,
fa=None,
fs=None,
) -> list[list[float]]:
"""Arc around *center* from *point_start* to *point_end*, sweeping the shorter way around."""
a0 = math.degrees(math.atan2(point_start[1] - center[1], point_start[0] - center[0]))
a1 = math.degrees(math.atan2(point_end[1] - center[1], point_end[0] - center[0]))
delta = (a1 - a0 + 180) % 360 - 180
count = max(3, math.ceil(_frag_count(radius, fn, fa, fs) * abs(delta) / 360))
return _arc_points(count, radius, a0, delta, center, endpoint=endpoint)
def _arc_through_3(
center: Sequence[float],
radius: float,
point_start: Sequence[float],
point_mid: Sequence[float],
point_end: Sequence[float],
endpoint: bool = True,
fn=None,
fa=None,
fs=None,
) -> list[list[float]]:
"""
Arc around *center* from *point_start* to *point_end*, sweeping through *point_mid* (may be
the long way around).
"""
a0 = math.degrees(math.atan2(point_start[1] - center[1], point_start[0] - center[0]))
am = math.degrees(math.atan2(point_mid[1] - center[1], point_mid[0] - center[0]))
a1 = math.degrees(math.atan2(point_end[1] - center[1], point_end[0] - center[0]))
d_mid = (am - a0) % 360
d_end = (a1 - a0) % 360
delta = d_end if d_mid <= d_end else d_end - 360
count = max(3, math.ceil(_frag_count(radius, fn, fa, fs) * abs(delta) / 360))
return _arc_points(count, radius, a0, delta, center, endpoint=endpoint)
@overload
def _pick_radius(
radius1: float | None = None,
diameter1: float | None = None,
radius2: float | None = None,
diameter2: float | None = None,
radius: float | None = None,
diameter: float | None = None,
*,
dflt: float,
) -> float: ...
@overload
def _pick_radius(
radius1: float | None = None,
diameter1: float | None = None,
radius2: float | None = None,
diameter2: float | None = None,
radius: float | None = None,
diameter: float | None = None,
dflt: None = None,
) -> float | None: ...
def _pick_radius(
radius1=None,
diameter1=None,
radius2=None,
diameter2=None,
radius=None,
diameter=None,
dflt=None,
):
"""
Mirror BOSL2's get_radius(): (radius1,diameter1) > (radius2,diameter2) > (radius,diameter) >
dflt.
"""
if radius1 is not None:
return radius1
if diameter1 is not None:
return diameter1 / 2
if radius2 is not None:
return radius2
if diameter2 is not None:
return diameter2 / 2
if radius is not None:
return radius
if diameter is not None:
return diameter / 2
return dflt
def _circle_from_3pts(points: Sequence[Sequence[float]]) -> tuple[list[float], float]:
(x1, y1), (x2, y2), (x3, y3) = points
d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2))
ux = ((x1**2 + y1**2) * (y2 - y3) + (x2**2 + y2**2) * (y3 - y1) + (x3**2 + y3**2) * (y1 - y2)) / d
uy = ((x1**2 + y1**2) * (x3 - x2) + (x2**2 + y2**2) * (x1 - x3) + (x3**2 + y3**2) * (x2 - x1)) / d
return [ux, uy], math.hypot(x1 - ux, y1 - uy)
def _circle_from_corner(corner: Sequence[Sequence[float]], radius: float) -> list[float]:
p0, p1, p2 = corner
v1 = unit([p0[0] - p1[0], p0[1] - p1[1]])
v2 = unit([p2[0] - p1[0], p2[1] - p1[1]])
bis = unit([v1[0] + v2[0], v1[1] + v2[1]])
half_ang = math.acos(max(-1.0, min(1.0, v1[0] * bis[0] + v1[1] * bis[1])))
dist = radius / math.sin(half_ang)
return [p1[0] + bis[0] * dist, p1[1] + bis[1] * dist]
def _circle_circle_intersection(
radius1: float, center1: Sequence[float], radius2: float, center2: Sequence[float]
) -> list[list[float]]:
d = math.dist(center1, center2)
if d == 0 or d > radius1 + radius2 or d < abs(radius1 - radius2):
return []
a = (radius1**2 - radius2**2 + d**2) / (2 * d)
h_sq = radius1**2 - a**2
if h_sq < 0:
return []
h = math.sqrt(h_sq)
xm = center1[0] + a * (center2[0] - center1[0]) / d
ym = center1[1] + a * (center2[1] - center1[1]) / d
dx = h * (center2[1] - center1[1]) / d
dy = h * (center2[0] - center1[0]) / d
return [[xm + dx, ym - dy], [xm - dx, ym + dy]]
def _adjacent_angle_to_hypotenuse(adjacent: float, angle: float) -> float:
return adjacent / math.cos(math.radians(angle))
def _adjacent_angle_to_opposite(adjacent: float, angle: float) -> float:
return adjacent * math.tan(math.radians(angle))
def _opposite_angle_to_adjacent(opposite: float, angle: float) -> float:
return opposite / math.tan(math.radians(angle))
def _v_theta(vec: Sequence[float]) -> float:
return math.degrees(math.atan2(vec[1], vec[0]))
def _det2(vec_a: Sequence[float] | np.ndarray, vec_b: Sequence[float] | np.ndarray) -> float:
"""The 2-D cross product a x b -- sign gives the turn direction (z of the 3-D cross)."""
return float(vec_a[0] * vec_b[1] - vec_a[1] * vec_b[0])
def _sign(value: float) -> int:
value = float(value)
return (value > 0) - (value < 0)
def _vector_angle(point_a: Sequence[float], point_b: Sequence[float], point_c: Sequence[float]) -> float:
"""The angle in degrees at vertex *b* of the corner a-b-c."""
va = np.asarray(point_a, dtype=float) - np.asarray(point_b, dtype=float)
vc = np.asarray(point_c, dtype=float) - np.asarray(point_b, dtype=float)
cosv = float(np.dot(va, vc)) / (float(np.linalg.norm(va)) * float(np.linalg.norm(vc)))
return math.degrees(math.acos(max(-1.0, min(1.0, cosv))))
def _dir2(anchor: Sequence[float]) -> list[float]:
a = list(anchor) + [0, 0, 0]
return [a[0], a[1] + a[2]]
def _anchor_offset_box(size: Sequence[float], anchor: Sequence[float]) -> list[float]:
d = _dir2(anchor)
return [-d[0] * size[0] / 2, -d[1] * size[1] / 2]
def _anchor_offset_hull(points: Sequence[Sequence[float]], anchor: Sequence[float]) -> list[float]:
d = _dir2(anchor)
if d[0] == 0 and d[1] == 0:
return [0.0, 0.0]
best = max(points, key=lambda p: p[0] * d[0] + p[1] * d[1])
return [-best[0], -best[1]]
def _finish(
shape: PyOpenSCAD,
offset: Sequence[float],
spin: float,
size: Sequence[float] | None = None,
anchor: Sequence[float] | None = None,
) -> "Bosl2Shape2D":
"""Anchor-translate and spin a freshly built native 2-D shape, then wrap it.
Every shape constructor in this file funnels through here, which is what makes them all
return a :class:`Bosl2Shape2D` rather than a bare native handle. *size*/*anchor* are the
nominal box metadata to carry on the wrapper, for the shapes that have one. A shape that is
already wrapped (``ring()`` composes two circles) is unwrapped first, never double-wrapped.
"""
shape = Bosl2Shape2D._unwrap(shape)
if offset[0] != 0 or offset[1] != 0:
shape = shape.translate(offset)
if spin:
# Native 2-D rotate needs the 3-vector form; a bare scalar is rejected.
shape = shape.rotate([0, 0, spin])
return Bosl2Shape2D(shape, size=size, anchor=anchor)
# ---------------------------------------------------------------------------
# Section: Base class
# ---------------------------------------------------------------------------
#: Anything the 2-D operators accept as a child: a :class:`Bosl2Shape2D`, a raw native 2-D handle,
#: a :class:`~bosl2.paths.Path` / :class:`~bosl2.regions.Region`, or a ``[[x, y], ...]`` point list.
#: :func:`_as_native_2d` reduces any of them to a raw native handle.
Shape2DLike = Union["Bosl2Shape2D", "PyOpenSCAD", Path, Sequence[Sequence[float]]]
[docs]
class Bosl2Shape2D(Distributable, Colorable):
"""Wraps a native PyOpenSCAD **2-D** shape, giving it the same fluent, chainable API that
:class:`~bosl2.shapes3d.Bosl2Solid` gives 3-D solids. Every shape constructor in this file
returns one of these, as do :meth:`~bosl2.paths.Path.polygon` and
:meth:`~bosl2.regions.Region.geometry`.
The 2-D specific operations live here rather than being reached for through the raw native
handle:
* :meth:`fill` -- drop every hole, keeping only the outermost outline (OpenSCAD ``fill()``).
* :meth:`hull` -- the convex hull of this shape, optionally together with more shapes/paths
(OpenSCAD ``hull()``).
* :meth:`offset` -- inset/outset, with BOSL2's ``radius=``/``delta=`` spelling (the native
``offset()`` only understands ``r=``).
* :meth:`linear_extrude` / :meth:`rotate_extrude` -- the 2-D -> 3-D operators, which return a
:class:`~bosl2.shapes3d.Bosl2Solid` so the result keeps the 3-D fluent API.
Transforms (translate/rotate/mirror/scale/multmatrix), the CSG operators (``|``, ``&``, ``-``),
the colour operators (from :class:`~bosl2.color.Colorable`) and the distributor copiers (from
:class:`~bosl2.distributors.Distributable`) all return a new ``Bosl2Shape2D``. Anything else
falls through ``__getattr__`` to the native handle, re-wrapped as 2-D when it hands back native
geometry.
Like :class:`~bosl2.shapes3d.Bosl2Solid` this is composition, not a subclass of the native
C-extension type: passing one *directly* into a native function that wants a raw handle needs
an explicit ``.shape`` (or :func:`bosl2._helpers.unwrap`).
"""
#: which realize backend produced this shape -- 2-D geometry is exact-CSG only (see
#: bosl2/_backend.py); the SDF backend has no 2-D surface.
backend = "csg"
def __init__(
self,
shape: PyOpenSCAD,
size: Sequence[float] | None = None,
anchor: "Sequence[float] | str | None" = None,
):
self.shape = shape
#: nominal [x, y] size for the shapes that have a genuine box size, else None
self.size = None if size is None else [float(v) for v in size][:2]
self.anchor = anchor if anchor is not None else CENTER
# True once a positional transform has been applied, so `size`/`anchor` no longer
# describe where the shape actually is (same flag Bosl2Solid tracks).
self._moved = False
@staticmethod
def _unwrap(x: "Bosl2Shape2D | Bosl2Solid | PyOpenSCAD") -> "PyOpenSCAD":
"""The raw native handle behind *x* (a Bosl2Shape2D/Bosl2Solid), or *x* unchanged."""
from bosl2._helpers import unwrap
return unwrap(x)
def _wrap(self, new_shape: PyOpenSCAD) -> "Bosl2Shape2D":
"""Wrap a native result, carrying size/anchor metadata (and moved-ness) forward."""
out = Bosl2Shape2D(new_shape, self.size, self.anchor)
out._moved = self._moved
return out
def _wrap_moved(self, new_shape: PyOpenSCAD) -> "Bosl2Shape2D":
"""Wrap a native result of a positional transform, flagging the tracked metadata stale."""
out = Bosl2Shape2D(new_shape, self.size, self.anchor)
out._moved = True
return out
def __getattr__(self, name: str) -> Any:
# __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, so
# copy/pickle/hasattr raise a clean AttributeError 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: Any, **kwargs: Any) -> Any:
# Re-wrap native geometry so a passed-through op keeps the fluent API instead of
# silently leaking a raw handle. Every 2-D -> 3-D operator is defined explicitly
# below, so whatever lands here is still 2-D.
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"Bosl2Shape2D({self.shape!r}, size={self.size!r}, anchor={self.anchor!r})"
# ---- geometry passthrough, preserving size/anchor metadata ----
[docs]
def translate(self, v: Sequence[float]) -> "Bosl2Shape2D":
"""Translate by *v* ([x, y], or [x, y, 0])."""
return self._wrap_moved(self.shape.translate([float(c) for c in v]))
move = translate
[docs]
def rotate(self, *a: Any, **k: Any) -> "Bosl2Shape2D":
"""Rotate about the Z axis. A bare scalar angle is accepted (BOSL2 ``rot(a)``); the native
2-D rotate only takes the 3-vector form."""
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
spin = rotate
[docs]
def mirror(self, v: Sequence[float]) -> "Bosl2Shape2D":
"""Mirror across the line through the origin normal to *v*."""
return self._wrap_moved(self.shape.mirror([float(c) for c in v]))
[docs]
def scale(self, v: "float | Sequence[float]") -> "Bosl2Shape2D":
"""Scale by *v* (a scalar, or [x, y])."""
return self._wrap_moved(self.shape.scale(v))
[docs]
def multmatrix(self, m: Sequence[Sequence[float]]) -> "Bosl2Shape2D":
return self._wrap_moved(self.shape.multmatrix(m))
# Directional translates (BOSL2 transforms.scad): right/left +/-X, back/fwd +/-Y.
[docs]
def right(self, x: float) -> "Bosl2Shape2D":
return self.translate([x, 0.0])
[docs]
def left(self, x: float) -> "Bosl2Shape2D":
return self.translate([-x, 0.0])
[docs]
def back(self, y: float) -> "Bosl2Shape2D":
return self.translate([0.0, y])
[docs]
def forward(self, y: float) -> "Bosl2Shape2D":
return self.translate([0.0, -y])
fwd = forward
[docs]
def xflip(self, x: float = 0.0) -> "Bosl2Shape2D":
"""Mirror across the vertical line at *x* (BOSL2 xflip())."""
return self.translate([-x, 0.0]).mirror([1, 0]).translate([x, 0.0])
[docs]
def yflip(self, y: float = 0.0) -> "Bosl2Shape2D":
"""Mirror across the horizontal line at *y* (BOSL2 yflip())."""
return self.translate([0.0, -y]).mirror([0, 1]).translate([0.0, y])
# ---- 2-D operators ----
[docs]
def offset(
self,
radius: float | None = None,
delta: float | None = None,
chamfer: bool = False,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> "Bosl2Shape2D":
"""Inset (negative) or outset (positive) the outline.
*radius* rounds the joins it creates, *delta* keeps them sharp (or bevels them with
``chamfer=True``) -- BOSL2's spelling of OpenSCAD's ``r=``/``delta=``. Give exactly one.
"""
assert (radius is None) != (delta is None), "offset(): give exactly one of radius= or delta=."
kw: dict[str, Any] = {"r": radius} if radius is not None else {"delta": delta, "chamfer": chamfer}
for name, value in (("fn", fn), ("fa", fa), ("fs", fs)):
if value is not None:
kw[name] = value
# The offset moves the outline, so the nominal box size no longer describes it.
return self._wrap_moved(self.shape.offset(**kw))
[docs]
def fill(self) -> "Bosl2Shape2D":
"""This shape with every hole filled in -- only the outermost outline survives
(OpenSCAD ``fill()``).
Useful for recovering the solid footprint of a shape you have already punched holes in,
e.g. to build a backing plate for it, or to close up the interior loops of ``text()``.
Examples:
.. pythonscad-example::
plate = s2.square(40) - s2.circle(radius=8)
plate.fill().linear_extrude(height=2).show()
"""
return self._wrap(_ofill(self.shape))
[docs]
def hull(self, *others: "Shape2DLike") -> "Bosl2Shape2D":
"""The convex hull of this shape (OpenSCAD ``hull()``).
With arguments, the hull of this shape *together with* each of *others* -- any mix of
``Bosl2Shape2D``, native 2-D shapes, :class:`~bosl2.paths.Path` /
:class:`~bosl2.regions.Region`, or plain ``[[x, y], ...]`` point lists.
Examples:
.. pythonscad-example::
slot = s2.circle(radius=5).hull(s2.circle(radius=5).right(30))
slot.linear_extrude(height=3).show()
"""
return Bosl2Shape2D(_ohull(self.shape, *[_as_native_2d(o) for o in others]))
# ---- 2-D -> 3-D (returns a Bosl2Solid) ----
[docs]
def linear_extrude(
self,
height: float,
center: bool = False,
twist: float = 0.0,
scale: "float | Sequence[float]" = 1,
slices: int | None = None,
convexity: int | None = None,
**kwargs: Any,
) -> "Bosl2Solid":
"""Extrude this 2-D shape *height* along +Z into a 3-D solid.
Args:
height: extrusion height
center: centre the result on z=0 rather than starting at z=0 (default False)
twist: degrees to rotate the top face relative to the bottom (default 0)
scale: scale factor of the top face, a scalar or [x, y] (default 1)
slices: number of intermediate layers (default: from the twist)
convexity: rendering hint for self-overlapping cross-sections
kwargs: any further native ``linear_extrude()`` parameter (``origin``, ``fn``, ...)
Returns:
A :class:`~bosl2.shapes3d.Bosl2Solid`.
Examples:
.. pythonscad-example::
s2.star(n=5, r=30, ir=15).linear_extrude(height=6, twist=45).show()
"""
from bosl2.shapes3d import Bosl2Solid
kw: dict[str, Any] = {
"height": height,
"center": center,
"twist": twist,
"scale": scale,
}
if slices is not None:
kw["slices"] = slices
if convexity is not None:
kw["convexity"] = convexity
kw.update(kwargs)
size = None if self.size is None else [self.size[0], self.size[1], float(height)]
return Bosl2Solid(self.shape.linear_extrude(**kw), size=size)
[docs]
def rotate_extrude(
self,
angle: float = 360.0,
convexity: int | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
**kwargs: Any,
) -> "Bosl2Solid":
"""Revolve this 2-D shape about the Y axis into a 3-D solid (OpenSCAD ``rotate_extrude()``).
The shape must lie entirely on one side of the axis. *angle* sweeps less than a full
revolution.
Returns:
A :class:`~bosl2.shapes3d.Bosl2Solid`.
"""
from bosl2.shapes3d import Bosl2Solid
kw: dict[str, Any] = {"angle": angle}
for name, value in (
("convexity", convexity),
("fn", fn),
("fa", fa),
("fs", fs),
):
if value is not None:
kw[name] = value
kw.update(kwargs)
return Bosl2Solid(self.shape.rotate_extrude(**kw))
[docs]
def path_extrude(self, path: Sequence[Sequence[float]], **kwargs: Any) -> "Bosl2Solid":
"""Sweep this 2-D shape along *path* (a :class:`~bosl2.paths.Path3D` or point list), via
the native ``path_extrude()``.
Returns:
A :class:`~bosl2.shapes3d.Bosl2Solid`.
"""
from bosl2.shapes3d import Bosl2Solid
pts = [[float(c) for c in p] for p in path]
return Bosl2Solid(self.shape.path_extrude(pts, **kwargs))
# ---- colour (bosl2/color.py) ----
def _color_native(self, c: "str | Sequence[float] | None" = None, alpha: float | None = None) -> "Bosl2Shape2D":
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) -> "Bosl2Shape2D":
return self._wrap(self.shape.highlight())
def _ghost_native(self) -> "Bosl2Shape2D":
return self._wrap(self.shape.background())
# ---- CSG ----
def __or__(self, other: "Shape2DLike") -> "Bosl2Shape2D":
_check_operand_backend("csg", other)
return self._wrap(self.shape | Bosl2Shape2D._unwrap(other))
def __and__(self, other: "Shape2DLike") -> "Bosl2Shape2D":
_check_operand_backend("csg", other)
return self._wrap(self.shape & Bosl2Shape2D._unwrap(other))
def __sub__(self, other: "Shape2DLike") -> "Bosl2Shape2D":
_check_operand_backend("csg", other)
return self._wrap(self.shape - Bosl2Shape2D._unwrap(other))
def __ror__(self, other: "Shape2DLike") -> "Bosl2Shape2D":
_check_operand_backend("csg", other)
return self._wrap(Bosl2Shape2D._unwrap(other) | self.shape)
def __rand__(self, other: "Shape2DLike") -> "Bosl2Shape2D":
_check_operand_backend("csg", other)
return self._wrap(Bosl2Shape2D._unwrap(other) & self.shape)
def __rsub__(self, other: "Shape2DLike") -> "Bosl2Shape2D":
_check_operand_backend("csg", other)
return self._wrap(Bosl2Shape2D._unwrap(other) - self.shape)
# ---- distributors (bosl2/distributors.py) ----
def _distribute(self, mats: Sequence[Sequence[Sequence[float]]]) -> "Bosl2Shape2D":
"""Union a multmatrix copy of this shape for each transform matrix (BOSL2's module form).
The copiers that lift out of the XY plane (``zcopies``, ``xrot_copies``, ...) have no
meaning for 2-D geometry and are rejected rather than silently flattened.
"""
assert len(mats), "distributor produced no copies."
out = None
for m in mats:
m4 = np.asarray(m, dtype=float)
assert abs(float(m4[2, 3])) < 1e-9 and abs(float(m4[2, 2]) - 1.0) < 1e-9, (
"this copier moves the 2-D shape out of the XY plane; extrude it to 3-D first"
)
copy = self.shape.multmatrix(m4.tolist())
out = copy if out is None else out | copy
return self._wrap_moved(out)
# ---- bounding box ----
[docs]
def bounds(self) -> "tuple[list[float], list[float]]":
"""This shape's axis-aligned bounding box as ``(center, size)`` -- both ``[x, y]`` float
lists in the shape's current frame (the 2-D form of
:meth:`~bosl2.shapes3d.Bosl2Solid.bounds`).
Prefers the native bbox, which always reflects the current geometry; falls back to the
tracked nominal size/anchor when the native accessors aren't available (the numeric test
mock) and the shape hasn't been moved since construction.
"""
try:
pos, sz = self.shape.position, self.shape.size
except AttributeError:
pos = sz = None
if pos is not None and sz is not None:
mincorner = [float(pos[i]) for i in range(2)]
size = [float(sz[i]) for i in range(2)]
return [mincorner[i] + size[i] / 2 for i in range(2)], size
if self.size is not None and not isinstance(self.anchor, str):
if self._moved:
raise ValueError(
"bounds(): no native bounding box (numeric mock) and the shape has been "
"transformed since construction, so its tracked metadata is stale."
)
size = [float(v) for v in self.size]
return _anchor_offset_box(size, self.anchor), size
raise ValueError("bounds(): the shape has no native bounding box and no tracked size metadata.")
def _as_native_2d(obj: "Shape2DLike") -> "PyOpenSCAD":
"""A raw native 2-D handle from *obj*: a Bosl2Shape2D/Bosl2Solid wrapper, a native shape, a
:class:`~bosl2.paths.Path` / :class:`~bosl2.regions.Region`, or a plain point list.
"""
from bosl2._helpers import unwrap
unwrapped = unwrap(obj)
if unwrapped is not obj: # a Bosl2Shape2D / Bosl2Solid wrapper
return unwrapped
geom = getattr(obj, "geometry", None) # Path / Region
if callable(geom):
return unwrap(geom())
if isinstance(obj, (list, tuple, np.ndarray)): # a bare [[x, y], ...] point list
return _opolygon([[float(p[0]), float(p[1])] for p in obj])
return obj
def _is_child_2d(obj: "Shape2DLike | Sequence[Shape2DLike]") -> bool:
"""True if *obj* is a single 2-D child rather than a container of children -- a wrapper or
native shape, a Path/Region (which are ``list`` subclasses), or a ``[[x, y], ...]`` list.
"""
if not isinstance(obj, (list, tuple)):
return True # a wrapper or a native handle
if callable(getattr(obj, "geometry", None)):
return True # Path / Region
return bool(len(obj)) and isinstance(obj[0], (list, tuple, np.ndarray)) and len(obj[0]) == 2
[docs]
def fill(children: "Shape2DLike") -> Bosl2Shape2D:
"""*children* with every hole filled in -- only the outermost outline survives
(OpenSCAD ``fill()``, the module form of :meth:`Bosl2Shape2D.fill`).
Args:
children: the 2-D shape to fill (a ``Bosl2Shape2D``, a native shape, a
:class:`~bosl2.paths.Path` / :class:`~bosl2.regions.Region`, or a point list)
"""
return Bosl2Shape2D(_ofill(_as_native_2d(children)))
[docs]
def hull(*children: "Shape2DLike | Sequence[Shape2DLike]") -> Bosl2Shape2D:
"""The 2-D convex hull of *children* (OpenSCAD ``hull()``, the module form of
:meth:`Bosl2Shape2D.hull`).
Args:
children: the 2-D shapes to hull -- any mix of ``Bosl2Shape2D``, native shapes,
:class:`~bosl2.paths.Path` / :class:`~bosl2.regions.Region`, or point lists.
A single list/tuple *of* shapes is also accepted.
"""
items = list(children)
if len(items) == 1 and not _is_child_2d(items[0]):
items = list(items[0]) # a single list *of* shapes
assert items, "hull(): needs at least one child."
return Bosl2Shape2D(_ohull(*[_as_native_2d(c) for c in items]))
# ---------------------------------------------------------------------------
# Section: 2D Primitives
# ---------------------------------------------------------------------------
[docs]
def square(
size: float | Sequence[float] = 1,
center: bool | None = None,
anchor: Sequence[float] = CENTER,
spin: float | None = None,
) -> Bosl2Shape2D:
"""A rectangle, built with the builtin square(), with BOSL2-style anchor/spin support.
Args:
size: size of the square; a scalar uses the same size for X and Y
center: if given, overrides anchor (True -> CENTER, False -> FRONT+LEFT)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
"""
sz = [float(size), float(size)] 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, 0]
shape = _osquare(sz, center=True)
offset = _anchor_offset_box(sz, use_anchor)
return _finish(shape, offset, spin or 0, size=sz, anchor=use_anchor)
def _rect_path(
size: Sequence[float],
rounding: float | Sequence[float] = 0,
chamfer: float | Sequence[float] = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> list[list[float]]:
sx, sy = size
rounding_l = [float(rounding)] * 4 if isinstance(rounding, (int, float)) else [float(v) for v in rounding]
chamfer_l = [float(chamfer)] * 4 if isinstance(chamfer, (int, float)) else [float(v) for v in chamfer]
if all(v == 0 for v in rounding_l) and all(v == 0 for v in chamfer_l):
return [
[sx / 2, -sy / 2],
[-sx / 2, -sy / 2],
[-sx / 2, sy / 2],
[sx / 2, sy / 2],
]
quadorder = [3, 2, 1, 0]
quadpos = [[1, 1], [-1, 1], [-1, -1], [1, -1]]
eps = 1e-9
insets = [
(chamfer_l[i] if abs(chamfer_l[i]) >= eps else (rounding_l[i] if abs(rounding_l[i]) >= eps else 0))
for i in range(4)
]
insets_x = max(insets[0] + insets[1], insets[2] + insets[3])
insets_y = max(insets[0] + insets[3], insets[1] + insets[2])
assert insets_x <= sx, "Requested roundings and/or chamfers exceed the rect width."
assert insets_y <= sy, "Requested roundings and/or chamfers exceed the rect height."
path = []
for i in range(4):
quad = quadorder[i]
qinset = insets[quad]
qpos = quadpos[quad]
qchamf = chamfer_l[quad]
qround = rounding_l[quad]
cverts = int(_quant(_frag_count(abs(qinset), fn, fa, fs), 4) / 4) if abs(qinset) >= eps else 0
step = 90.0 / cverts if cverts else 0.0
center = [(sx / 2 - qinset) * qpos[0], (sy / 2 - abs(qinset)) * qpos[1]]
if abs(qchamf) >= eps:
qpts = [[0, abs(qinset)], [qinset, 0]]
elif abs(qround) >= eps:
sign = 1 if qinset >= 0 else -1
qpts = []
for j in range(cverts + 1):
a = 90 - j * step
p = _polar_to_xy(abs(qinset), a)
qpts.append([p[0] * sign, p[1]])
else:
qpts = [[0, 0]]
qfpts = [[p[0] * qpos[0], p[1] * qpos[1]] for p in qpts]
qrpts = list(reversed(qfpts)) if qpos[0] * qpos[1] < 0 else qfpts
for p in qrpts:
path.append([p[0] + center[0], p[1] + center[1]])
return path
[docs]
def rect(
size: float | Sequence[float] = 1,
rounding: float | Sequence[float] = 0,
atype: str = "box",
chamfer: float | Sequence[float] = 0,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A rectangle with optional rounded or chamfered corners.
Note: negative rounding/chamfer (BOSL2's "external roundover spikes") is not supported here.
Args:
size: size of the rectangle; a scalar uses the same size for X and Y
rounding: corner rounding radius, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0)
atype: anchor type, "box" (bounding box) or "perim" (rounded/chamfered perimeter) (default "box")
chamfer: corner chamfer size, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
fn/fa/fs: arc smoothness overrides for rounded corners
"""
sz = [float(size), float(size)] if isinstance(size, (int, float)) else [v for v in size]
path = _rect_path(sz, rounding=rounding, chamfer=chamfer, fn=fn, fa=fa, fs=fs)
shape = _opolygon(path)
complex_shape = (rounding != 0 if isinstance(rounding, (int, float)) else any(rounding)) or (
chamfer != 0 if isinstance(chamfer, (int, float)) else any(chamfer)
)
if complex_shape and atype == "perim":
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
offset = _anchor_offset_box(sz, anchor)
return _finish(shape, offset, spin, size=sz, anchor=anchor)
[docs]
def rect_path(
size: float | Sequence[float] = 1,
rounding: float | Sequence[float] = 0,
chamfer: float | Sequence[float] = 0,
anchor: Sequence[float] = CENTER,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> list[list[float]]:
"""The *points* of a (optionally rounded/chamfered) rectangle -- BOSL2's ``rect()`` in its
function form, as opposed to :func:`rect` which returns 2-D geometry (a :class:`Bosl2Shape2D`).
Use this when the rectangle is an input to further path math (e.g. a profile fed to
:func:`base_bgtk.PolygonPrism`), not something to draw.
Usage::
rect_path([20, 4], rounding=[-3, -3, 0, 0], anchor=TOP + LEFT)
Args:
size: [x, y] size (or a single number for a square)
rounding: corner radius; a single value or per-corner list. Negative = concave.
chamfer: corner chamfer; a single value or per-corner list
anchor: BOSL2 anchor the path is translated onto (default CENTER)
Note:
For small radii this can emit one more point per corner than the real BOSL2 does
(BOSL2 rounds the corner-arc segment count, this rounds up); the arc geometry is
identical, only the sampling differs.
"""
sz = [float(size), float(size)] if isinstance(size, (int, float)) else [float(v) for v in size]
path = _rect_path(sz, rounding=rounding, chamfer=chamfer, fn=fn, fa=fa, fs=fs)
offset = _anchor_offset_box(sz, anchor)
return [[float(p[0]) + offset[0], float(p[1]) + offset[1]] for p in path]
[docs]
def arc(
count: int | None = None,
radius: float | None = None,
angle: float | Sequence[float] | None = None,
diameter: float | None = None,
center: Sequence[float] | None = None,
points: Sequence[Sequence[float]] | None = None,
corner: Sequence[Sequence[float]] | None = None,
width: float | None = None,
thickness: float | None = None,
start: float | None = None,
wedge: bool = False,
long: bool = False,
clockwise: bool = False,
counterclockwise: bool = False,
endpoint: bool = True,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Path:
"""A 2-D arc, returned as a :class:`~bosl2.paths.Path` of points (BOSL2's ``arc()``).
All of BOSL2's 2-D arc specifications are supported (3-D arcs, which project onto a plane,
are not):
* ``arc(radius=, angle=, [start=], [center=])`` -- radius about *center*, sweeping *angle* degrees from
*start* (or ``angle=[start, end]`` for an explicit range).
* ``arc(width=, thickness=)`` -- a circular segment starting and ending on the X axis.
* ``arc(center=, points=[P0, P1])`` -- around *center* from ``P0`` toward the direction of ``P1``; the
short way by default, or the long/``clockwise``/``counterclockwise`` way.
* ``arc(points=[P0, P1, P2])`` -- through three points, from ``P0`` via ``P1`` to ``P2``.
* ``arc(corner=[P0, P1, P2], radius=)`` -- the fillet arc of radius tangent to both legs of the
corner ``P0-P1-P2``.
Set ``wedge=True`` to prepend the centre point, giving a closed pie/sector path. When *count* is
omitted the point count follows OpenSCAD's $fn/$fa/$fs rules, matching BOSL2.
Args:
count: number of points (default: from $fn/$fa/$fs)
radius/diameter: radius / diameter of the arc
angle: degrees to sweep from *start*, or ``[start, end]``
center: centre point (default ``[0, 0]``)
points: two points (with *center*) or three points the arc passes through
corner: three points; the arc is the radius fillet tangent to both legs
width: chord width for the width/thickness form
thickness: height of the circular segment for the width/thickness form
start: starting angle in degrees (default 0)
wedge: prepend the centre point, producing a closed sector (default False)
long/clockwise/counterclockwise: for the two-point form, take the long way / a given handedness
endpoint: include the final point (default True)
Returns:
A :class:`~bosl2.paths.Path` (closed when *wedge* is set).
"""
# -- width + thickness: a circular segment through 3 points on/above the X axis ----------
if width is not None and thickness is not None:
assert not any(v is not None for v in (radius, center, points, angle, start)), "conflicting arc() params"
return arc(
count=count,
points=[[width / 2, 0], [0, thickness], [-width / 2, 0]],
wedge=wedge,
endpoint=endpoint,
fn=fn,
fa=fa,
fs=fs,
)
# -- corner: the fillet arc tangent to both legs of a 3-point corner ---------------------
if corner is not None:
assert len(corner) == 3, "corner= needs exactly 3 points"
assert not is_collinear(corner[0], corner[1], corner[2]), "Collinear corner does not define an arc"
rad = _pick_radius(radius=radius, diameter=diameter)
assert rad is not None and rad > 0, "arc(corner=) needs radius= or diameter="
p0, p1, p2 = (np.asarray(p, dtype=float) for p in corner)
v1, v2 = unit(p0 - p1), unit(p2 - p1)
half = math.acos(max(-1.0, min(1.0, float(np.dot(v1, v2))))) / 2
d_tan = rad / math.tan(half)
cp2 = _circle_from_corner(corner, rad)
tp1, tp2 = p1 + v1 * d_tan, p1 + v2 * d_tan
forward = _det2(p1 - p0, p2 - p1) > 0
c0, c1 = (tp1, tp2) if forward else (tp2, tp1)
ts = math.degrees(math.atan2(c0[1] - cp2[1], c0[0] - cp2[0]))
te = math.degrees(math.atan2(c1[1] - cp2[1], c1[0] - cp2[0]))
sweep = (te - ts) % 360
rng = [ts, ts + sweep] if forward else [ts + sweep, ts]
return arc(
count=count,
center=cp2,
radius=rad,
angle=rng,
wedge=wedge,
endpoint=endpoint,
fn=fn,
fa=fa,
fs=fs,
)
# -- points forms ------------------------------------------------------------------------
if points is not None:
pts = [[float(p[0]), float(p[1])] for p in points]
assert all(len(p) == 2 for p in points), "arc() port handles 2-D points only"
if len(pts) == 2:
assert center is not None, "center= is required when points has length 2"
assert pts[0] != pts[1], "arc endpoints are equal"
centre = [float(center[0]), float(center[1])]
v1 = np.asarray(pts[0]) - np.asarray(centre)
v2 = np.asarray(pts[1]) - np.asarray(centre)
angle_val = _vector_angle(pts[0], centre, pts[1])
prelim = _sign(_det2(v1, v2))
if prelim != 0:
direction = prelim
else:
assert clockwise or counterclockwise, "Collinear inputs don't define a unique arc"
direction = 1
rad = float(np.hypot(v1[0], v1[1]))
if long or (counterclockwise and direction < 0) or (clockwise and direction > 0):
final_angle = -direction * (360 - angle_val)
else:
final_angle = direction * angle_val
sa = math.degrees(math.atan2(v1[1], v1[0]))
return arc(
count=count,
center=centre,
radius=rad,
start=sa,
angle=final_angle,
wedge=wedge,
endpoint=endpoint,
fn=fn,
fa=fa,
fs=fs,
)
assert len(pts) == 3, f"arc(points=) needs 2 or 3 points, got {len(pts)}"
assert not is_collinear(pts[0], pts[1], pts[2]), "Collinear inputs do not define an arc"
centre, arc_radius = _circle_from_3pts(pts)
a0 = math.degrees(math.atan2(pts[0][1] - centre[1], pts[0][0] - centre[0]))
am = math.degrees(math.atan2(pts[1][1] - centre[1], pts[1][0] - centre[0]))
a1 = math.degrees(math.atan2(pts[2][1] - centre[1], pts[2][0] - centre[0]))
d_mid = (am - a0) % 360
d_end = (a1 - a0) % 360
delta = d_end if d_mid <= d_end else d_end - 360
point_count = (
count if count is not None else max(3, math.ceil(_frag_count(arc_radius, fn, fa, fs) * abs(delta) / 360))
)
out = _arc_points(point_count, arc_radius, a0, delta, centre, endpoint=endpoint)
if wedge:
out = [list(centre)] + out
return Path(out, closed=wedge)
# -- radius + angle (with optional [start, end] range) -----------------------------------
arc_radius: float | None = _pick_radius(radius=radius, diameter=diameter)
assert arc_radius is not None, "arc() needs radius=/diameter=, points=, corner=, or width=/thickness="
if isinstance(angle, (list, tuple, np.ndarray)):
assert start is None, "start= is not allowed with angle=[start, end]"
calc_start = float(angle[0]) # type: ignore[arg-type]
calc_angle = float(angle[1]) - float(angle[0]) # type: ignore[arg-type]
else:
calc_angle = 360.0 if angle is None else float(angle) # type: ignore[arg-type]
calc_start = 0.0 if start is None else float(start) # type: ignore[arg-type]
calc_center = (0.0, 0.0) if center is None else center
point_count = (
count if count is not None else math.ceil(_frag_count(arc_radius, fn, fa, fs) * abs(calc_angle) / 360) + 1
)
out = _arc_points(point_count, arc_radius, calc_start, calc_angle, calc_center, endpoint=endpoint)
if wedge:
out = [list(calc_center)] + out
return Path(out, closed=wedge)
[docs]
def circle(
radius: float | None = None,
diameter: float | None = None,
points: Sequence[Sequence[float]] | None = None,
corner: Sequence[Sequence[float]] | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A circle, built with the builtin circle(), by radius/diameter, or fit to points.
If `corner` is given three 2-D points, the circle is centered to be tangent to both
segments of that path, on the inside corner. If `points` is given three 2-D points,
the circle is centered and sized to pass through all three points. Anchor/spin are
ignored for the `corner`/`points` forms, matching BOSL2.
Args:
radius: radius of the circle
diameter: diameter of the circle
points: three 2-D points the circle should pass through
corner: three 2-D points defining a path the circle should be tangent to
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
fn/fa/fs: arc smoothness overrides
"""
if points is not None:
center, rad = _circle_from_3pts(points)
return _finish(_ocircle(r=rad, fn=fn, fa=fa, fs=fs), center, 0)
if corner is not None:
rad = radius if radius is not None else (diameter / 2 if diameter is not None else 1)
center = _circle_from_corner(corner, rad)
return _finish(_ocircle(r=rad, fn=fn, fa=fa, fs=fs), center, 0)
rad = radius if radius is not None else (diameter / 2 if diameter is not None else 1)
shape = _ocircle(r=rad, fn=fn, fa=fa, fs=fs)
n = _frag_count(rad, fn, fa, fs)
offset = _anchor_offset_hull(_circle_pts(rad, n), anchor)
return _finish(shape, offset, spin)
[docs]
def polygon(
path: Sequence[Sequence[float]],
anchor: Sequence[float] = CENTER,
spin: float = 0,
) -> Bosl2Shape2D:
"""A polygon, built with the builtin polygon(), with anchor/spin support.
Args:
path: polygon path
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
"""
return _finish(_opolygon(path), anchor, spin)
[docs]
def ellipse(
radius: float | Sequence[float] | None = None,
diameter: float | Sequence[float] | None = None,
realign: bool = False,
circum: bool = False,
uniform: bool = False,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""An ellipse (approximated as a polygon), built directly with polygon().
Note: `uniform` (equal-length approximating segments) is not implemented; segments are
evenly spaced by angle instead.
Args:
radius: radius of the circle, or pair of semi-axes of the ellipse
diameter: diameter of the circle, or pair giving the full X/Y axis lengths
realign: shift the first polygon point off the X+ axis (default False)
circum: circumscribe rather than inscribe the ideal ellipse (default False)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
fn/fa/fs: arc smoothness overrides
"""
if radius is not None:
rad = [float(radius), float(radius)] if isinstance(radius, (int, float)) else [float(v) for v in radius]
elif diameter is not None:
dd = [float(diameter), float(diameter)] if isinstance(diameter, (int, float)) else [float(v) for v in diameter]
rad = [dd[0] / 2, dd[1] / 2]
else:
rad = [1.0, 1.0]
n = _frag_count(max(rad), fn, fa, fs)
scale = 1.0 / math.cos(math.pi / n) if circum else 1.0
start = (360.0 / n) / 2 if realign else 0.0
path = [
[
rad[0] * scale * math.cos(math.radians(start + 360.0 * i / n)),
rad[1] * scale * math.sin(math.radians(start + 360.0 * i / n)),
]
for i in range(n)
]
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
# ---------------------------------------------------------------------------
# Section: Polygons
# ---------------------------------------------------------------------------
def _regular_ngon_path(
sides: int,
radius: float,
rounding: float = 0,
realign: bool = False,
align_tip: Sequence[float] | None = None,
align_side: Sequence[float] | None = None,
fn=None,
fa=None,
fs=None,
) -> list[list[float]]:
if not rounding:
path = _circle_pts(radius, sides)
else:
inset = rounding / math.sin(math.radians((180 - 360.0 / sides) / 2))
steps = max(1, int(_frag_count(radius, fn, fa, fs) // sides))
path2 = []
for i in range(sides):
a = 360 - i * 360.0 / sides
p = _polar_to_xy(radius - inset, a)
path2.extend(_arc_points(steps, rounding, a + 180.0 / sides, -360.0 / sides, p))
maxx_idx = max(range(len(path2)), key=lambda k: path2[k][0])
path = path2[maxx_idx:] + path2[:maxx_idx]
extra_rot = 0.0
if align_tip is not None:
extra_rot += math.degrees(math.atan2(align_tip[1], align_tip[0]))
elif align_side is not None:
extra_rot += math.degrees(math.atan2(align_side[1], align_side[0])) + 180.0 / sides
if realign:
extra_rot -= 180.0 / sides
if extra_rot:
path = [_rotate2d(p, extra_rot) for p in path]
return path
[docs]
def regular_ngon(
sides: int = 6,
radius: float | None = None,
diameter: float | None = None,
outer_radius: float | None = None,
outer_diameter: float | None = None,
inner_radius: float | None = None,
inner_diameter: float | None = None,
side: float | None = None,
rounding: float = 0,
realign: bool = False,
align_tip: Sequence[float] | None = None,
align_side: Sequence[float] | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A regular N-gon (equilateral, equiangular polygon), built directly with polygon().
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:
sides: number of sides (default 6)
radius/outer_radius: outside radius, at the points (BOSL2 `or`)
diameter/outer_diameter: outside diameter, at the points
inner_radius: inside radius, at the center of the sides
inner_diameter: inside diameter, at the center of the sides
side: length of each side
rounding: rounding radius for the tips of the polygon (default 0)
realign: put the midpoint of the last edge (instead of vertex 0) on the X+ axis (default False)
align_tip: rotate so the first vertex points in this 2-D direction (applied before spin)
align_side: rotate so the normal of side 0 points in this 2-D direction (applied before spin)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
fn/fa/fs: arc smoothness overrides for rounded tips
"""
assert sides >= 3
sc = 1 / math.cos(math.radians(180.0 / sides))
ir_s = inner_radius * sc if inner_radius is not None else None
id_s = inner_diameter * sc if inner_diameter is not None else None
side_s = side / 2 / math.sin(math.radians(180.0 / sides)) if side is not None else None
rad = _pick_radius(
radius1=ir_s,
diameter1=id_s,
radius2=outer_radius,
diameter2=outer_diameter,
radius=radius,
diameter=diameter,
dflt=side_s if side_s is not None else 0.0, # type: ignore[arg-type]
)
if rad is None:
raise ValueError(
"regular_ngon(): need to specify one of radius, diameter, outer_radius, outer_diameter, inner_radius, inner_diameter, side." # noqa: E501
)
path = _regular_ngon_path(
sides,
rad,
rounding=rounding,
realign=realign,
align_tip=align_tip,
align_side=align_side,
fn=fn,
fa=fa,
fs=fs,
)
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
[docs]
def pentagon(
radius: float | None = None,
diameter: float | None = None,
outer_radius: float | None = None,
outer_diameter: float | None = None,
inner_radius: float | None = None,
inner_diameter: float | None = None,
side: float | None = None,
rounding: float = 0,
realign: bool = False,
align_tip: Sequence[float] | None = None,
align_side: Sequence[float] | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A regular pentagon. See regular_ngon() for argument details."""
return regular_ngon(
sides=5,
radius=radius,
diameter=diameter,
outer_radius=outer_radius,
outer_diameter=outer_diameter,
inner_radius=inner_radius,
inner_diameter=inner_diameter,
side=side,
rounding=rounding,
realign=realign,
align_tip=align_tip,
align_side=align_side,
anchor=anchor,
spin=spin,
fn=fn,
fa=fa,
fs=fs,
)
[docs]
def hexagon(
radius: float | None = None,
diameter: float | None = None,
outer_radius: float | None = None,
outer_diameter: float | None = None,
inner_radius: float | None = None,
inner_diameter: float | None = None,
side: float | None = None,
rounding: float = 0,
realign: bool = False,
align_tip: Sequence[float] | None = None,
align_side: Sequence[float] | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A regular hexagon. See regular_ngon() for argument details."""
return regular_ngon(
sides=6,
radius=radius,
diameter=diameter,
outer_radius=outer_radius,
outer_diameter=outer_diameter,
inner_radius=inner_radius,
inner_diameter=inner_diameter,
side=side,
rounding=rounding,
realign=realign,
align_tip=align_tip,
align_side=align_side,
anchor=anchor,
spin=spin,
fn=fn,
fa=fa,
fs=fs,
)
[docs]
def octagon(
radius: float | None = None,
diameter: float | None = None,
outer_radius: float | None = None,
outer_diameter: float | None = None,
inner_radius: float | None = None,
inner_diameter: float | None = None,
side: float | None = None,
rounding: float = 0,
realign: bool = False,
align_tip: Sequence[float] | None = None,
align_side: Sequence[float] | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A regular octagon. See regular_ngon() for argument details."""
return regular_ngon(
sides=8,
radius=radius,
diameter=diameter,
outer_radius=outer_radius,
outer_diameter=outer_diameter,
inner_radius=inner_radius,
inner_diameter=inner_diameter,
side=side,
rounding=rounding,
realign=realign,
align_tip=align_tip,
align_side=align_side,
anchor=anchor,
spin=spin,
fn=fn,
fa=fa,
fs=fs,
)
[docs]
def right_triangle(
size: Sequence[float] = [1, 1],
center: bool | None = None,
anchor: Sequence[float] | None = None,
spin: float = 0,
) -> Bosl2Shape2D:
"""A right triangle, built directly with polygon().
Args:
size: [width, length] of the right triangle
center: True forces anchor=CENTER, False forces anchor=[-1,-1] (default: use anchor=)
anchor: anchor point (default: [-1,-1], the right-angle corner)
spin: Z-axis rotation in degrees after anchor (default 0)
"""
sz = [size, size] if isinstance(size, (int, float)) else list(size)
if anchor is not None:
use_anchor = anchor
elif center:
use_anchor = CENTER
else:
use_anchor = [-1, -1, 0]
path = [[sz[0] / 2, -sz[1] / 2], [-sz[0] / 2, -sz[1] / 2], [-sz[0] / 2, sz[1] / 2]]
shape = _opolygon(path)
offset = _anchor_offset_box(sz, use_anchor)
return _finish(shape, offset, spin, size=sz, anchor=use_anchor)
def _trapezoid_path(
height: float,
width1: float,
width2: float,
shift: float,
chamfer,
rounding,
flip: bool,
fn=None,
fa=None,
fs=None,
) -> list[list[float]]:
chamfs = list(chamfer) if isinstance(chamfer, (list, tuple)) else [chamfer] * 4
rounds = list(rounding) if isinstance(rounding, (list, tuple)) else [rounding] * 4
srads = [rounds[i] if rounds[i] else chamfs[i] for i in range(4)]
rads = [abs(s) for s in srads]
base = [
[width2 / 2 + shift, height / 2],
[-width2 / 2 + shift, height / 2],
[-width1 / 2, -height / 2],
[width1 / 2, -height / 2],
]
angle1 = _v_theta([base[0][0] - base[3][0], base[0][1] - base[3][1]]) - 90
angle2 = _v_theta([base[1][0] - base[2][0], base[1][1] - base[2][1]]) - 90
angles = [angle1, angle2, angle2, angle1]
qdirs = [[1, 1], [-1, 1], [-1, -1], [1, -1]]
angle_pairs = [
{
"pos": (angles[0], 90),
"flip": (angles[0], -90),
"neg": (180 + angles[0], 90),
},
{
"pos": (90, 180 + angles[1]),
"flip": (270, 180 + angles[1]),
"neg": (90, angles[1]),
},
{
"pos": (180 + angles[2], 270),
"flip": (180 + angles[2], 90),
"neg": (angles[2], -90),
},
{
"pos": (-90, angles[3]),
"flip": (90, angles[3]),
"neg": (270, 180 + angles[3]),
},
]
cpath = []
for i in range(4):
if rads[i] == 0:
cpath.append(base[i])
continue
hyp = _adjacent_angle_to_hypotenuse(rads[i], angles[i])
xoff = _adjacent_angle_to_opposite(rads[i], angles[i])
sign_a = -1 if (srads[i] < 0 and flip) else 1
a = [xoff * qdirs[i][1] * sign_a, -rads[i] * qdirs[i][1] * sign_a]
sign_b = 1 if (srads[i] < 0 and not flip) else -1
b = [a[0] + hyp * qdirs[i][0] * sign_b, a[1]]
center = [base[i][0] + b[0], base[i][1] + b[1]]
if srads[i] > 0:
a0, a1 = angle_pairs[i]["pos"] # type: ignore[index]
elif flip:
a0, a1 = angle_pairs[i]["flip"] # type: ignore[index]
else:
a0, a1 = angle_pairs[i]["neg"] # type: ignore[index]
point_count = max(3, math.ceil(_frag_count(rads[i], fn, fa, fs) * abs(a1 - a0) / 360)) if rounds[i] else 2
cpath.extend(_arc_points(point_count, rads[i], a0, a1 - a0, center))
return list(reversed(cpath))
[docs]
def trapezoid(
height: float | None = None,
width1: float | None = None,
width2: float | None = None,
angle: float | None = None,
shift: float = 0,
chamfer: float | Sequence[float] = 0,
rounding: float | Sequence[float] = 0,
flip: bool = False,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A trapezoid with parallel front and back sides, built directly with polygon().
Args:
height: Y-axis height of the trapezoid
width1: X-axis width of the front end
width2: X-axis width of the back end
angle: if given in place of height/width1/width2, the missing value is derived from this angle
shift: X-axis shift of the back of the trapezoid (default 0)
rounding: corner rounding radius, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0)
chamfer: corner chamfer length, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0)
flip: point negative roundings/chamfers forward/back instead of left/right (default False)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
fn/fa/fs: arc smoothness overrides for rounded corners
"""
defined = sum(x is not None for x in (height, width1, width2, angle))
assert defined == 3, "Must give exactly 3 of the arguments height, width1, width2, and angle."
if height is None:
assert width1 is not None and width2 is not None and angle is not None
height = _opposite_angle_to_adjacent(abs(width2 - width1) / 2, abs(angle))
if width1 is None:
assert width2 is not None and angle is not None
width1 = width2 + 2 * (_adjacent_angle_to_opposite(height, angle) + shift)
if width2 is None:
assert width1 is not None and angle is not None
width2 = width1 - 2 * (_adjacent_angle_to_opposite(height, angle) + shift)
assert width1 >= 0 and width2 >= 0 and height > 0 and width1 + width2 > 0, "Degenerate trapezoid geometry."
path = _trapezoid_path(height, width1, width2, shift, chamfer, rounding, flip, fn, fa, fs)
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
[docs]
def star(
tips: int | None = None,
radius: float | None = None,
inner_radius: float | None = None,
diameter: float | None = None,
outer_radius: float | None = None,
outer_diameter: float | None = None,
inner_diameter: float | None = None,
step: int | None = None,
realign: bool = False,
align_tip: Sequence[float] | None = None,
align_pit: Sequence[float] | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
atype: str = "hull",
) -> Bosl2Shape2D:
"""An N-pointed star polygon, built directly with polygon().
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:
tips: number of stellate tips
radius/outer_radius: radius to the tips of the star (BOSL2 `or`)
inner_radius: radius to the inner corners of the star
diameter/outer_diameter: diameter to the tips of the star
inner_diameter: diameter to the inner corners of the star
step: compute inner radius by virtually drawing a line `step` tips around the star (2 <= step <
tips/2)
realign: put the midpoint of the last edge (instead of vertex 0) on the X+ axis (default False)
align_tip: rotate so the first tip points in this 2-D direction (applied before spin)
align_pit: rotate so the first inner corner points in this 2-D direction (applied before spin)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
atype: anchor method; only "hull" is implemented here (default "hull")
"""
rad = _pick_radius(radius1=outer_radius, diameter1=outer_diameter, radius=radius, diameter=diameter)
if rad is None:
raise ValueError("star(): must specify a radius (radius, diameter, outer_radius or outer_diameter).")
assert tips is not None, "star(): must specify tips"
if step is not None:
stepr = rad * math.cos(math.radians(180 * step / tips)) / math.cos(math.radians(180 * (step - 1) / tips))
else:
stepr = rad
inner_rad = _pick_radius(radius=inner_radius, diameter=inner_diameter, dflt=stepr)
path1 = []
for i in range(2 * tips, 0, -1):
theta = math.radians(180.0 * i / tips)
path_radius = inner_rad if i % 2 else rad
path1.append([path_radius * math.cos(theta), path_radius * math.sin(theta)])
extra_rot = 0.0
if align_tip is not None:
extra_rot += math.degrees(math.atan2(align_tip[1], align_tip[0]))
elif align_pit is not None:
extra_rot += math.degrees(math.atan2(align_pit[1], align_pit[0])) + 180.0 / tips
if realign:
extra_rot -= 180.0 / tips
path = [_rotate2d(p, extra_rot) for p in path1] if extra_rot else path1
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
# ---------------------------------------------------------------------------
# Section: Curved 2D Shapes
# ---------------------------------------------------------------------------
[docs]
def jittered_poly(path: Sequence[Sequence[float]], dist: float = 1 / 512) -> list[list[float]]:
"""Adds tiny random jitter to a path's points.
Used to work around rendering artifacts from exactly-overlapping coplanar faces.
Args:
path: the path to add jitter to
dist: the amount to jitter points by (default 1/512)
"""
return [[p[0] + random.uniform(-dist, dist), p[1] + random.uniform(-dist, dist)] for p in path]
[docs]
def teardrop2d(
radius: float | None = None,
angle: float = 45,
cap_height: float | None = None,
diameter: float | None = None,
circum: bool = False,
realign: bool = False,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A 2-D teardrop shape, useful for 3D-printable horizontal holes, built directly with polygon().
Note: `circum` is approximated the same way as the inscribed case here (BOSL2's exact
ray-intersection construction for `circum=True` is not reproduced).
Args:
radius: radius of the circular part (default 1)
angle: angle of the hat walls from the Y axis in degrees (default 45)
cap_height: height above center to truncate the shape (default: no truncation)
diameter: diameter of the circular portion (alternative to radius)
circum: produce a circumscribing teardrop (default False)
realign: flip whether the bottom is a point or a flat (default False)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
fn/fa/fs: arc smoothness overrides
"""
rad = radius if radius is not None else (diameter / 2 if diameter is not None else 1)
minheight = rad * math.sin(math.radians(angle))
maxheight = rad / math.sin(math.radians(angle))
if cap_height is not None:
assert cap_height >= minheight, f"cap_height cannot be less than {minheight} but it is {cap_height}"
pointy = cap_height is None or cap_height >= maxheight
if cap_height is None or pointy:
cap_top = [0.0, maxheight]
else:
cap_top = [(maxheight - cap_height) * math.tan(math.radians(angle)), cap_height]
cap_bot = [rad * math.cos(math.radians(angle)), rad * math.sin(math.radians(angle))]
n = _frag_count(rad, fn, fa, fs)
start = 90.0 + (180.0 / n if realign else 0.0)
fullcircle = _circle_pts(rad, n, start=start)
seglen = math.dist(fullcircle[0], fullcircle[1]) if len(fullcircle) > 1 else 0.0
skipfactor = 15 if len(fullcircle) == 6 else 3
path = [cap_top, cap_bot]
for p in fullcircle:
if p[1] < cap_bot[1] - 1e-9 and math.hypot(abs(p[0]) - cap_bot[0], p[1] - cap_bot[1]) > seglen / skipfactor:
path.append(p)
path.append([-cap_bot[0], cap_bot[1]])
if not pointy:
path.append([-cap_top[0], cap_top[1]])
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
[docs]
def egg(
length: float | None = None,
radius1: float | None = None,
radius2: float | None = None,
arc_radius: float | None = None,
diameter1: float | None = None,
diameter2: float | None = None,
arc_diameter: float | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""An egg-shaped 2-D outline, made of two circles joined by tangent arcs, built directly with polygon().
Args:
length: length of the egg
radius1: radius of the left-hand circle
radius2: radius of the right-hand circle
arc_radius: radius of the joining arcs
diameter1: diameter of the left-hand circle (alternative to radius1)
diameter2: diameter of the right-hand circle (alternative to radius2)
arc_diameter: diameter of the joining arcs (alternative to arc_radius)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
fn/fa/fs: arc smoothness overrides
"""
radius1 = radius1 if radius1 is not None else (diameter1 / 2 if diameter1 is not None else None)
if radius1 is None:
raise ValueError("egg(): must give radius1 or diameter1")
radius2 = radius2 if radius2 is not None else (diameter2 / 2 if diameter2 is not None else None)
if radius2 is None:
raise ValueError("egg(): must give radius2 or diameter2")
R = arc_radius if arc_radius is not None else (arc_diameter / 2 if arc_diameter is not None else None)
if R is None:
raise ValueError("egg(): must give arc_radius or arc_diameter")
assert length is not None, "egg(): must give length"
path = _egg_path(length, radius1, radius2, R, fn, fa, fs)
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
def _egg_path(
length: float,
radius1: float,
radius2: float,
arc_radius: float,
fn=None,
fa=None,
fs=None,
) -> list[list[float]]:
assert length > 0
assert arc_radius > length / 2, "Side radius must be larger than length/2"
assert length > radius1 + radius2, "Length must be longer than radius1+radius2"
c1 = [-length / 2 + radius1, 0.0]
c2 = [length / 2 - radius2, 0.0]
m_pts = list(reversed(_circle_circle_intersection(arc_radius - radius1, c1, arc_radius - radius2, c2)))
assert len(m_pts) == 2, "egg(): circles do not intersect for the given length/radius1/radius2/arc_radius."
arcparms = []
for m in m_pts:
u1 = unit([c1[0] - m[0], c1[1] - m[1]])
u2 = unit([c2[0] - m[0], c2[1] - m[1]])
arcparms.append(
[
m,
[c1[0] + radius1 * u1[0], c1[1] + radius1 * u1[1]],
[c2[0] + radius2 * u2[0], c2[1] + radius2 * u2[1]],
]
)
kw = {"fn": fn, "fa": fa, "fs": fs}
path = []
path += _arc_between_points(c2, [length / 2, 0.0], arcparms[0][2], radius2, endpoint=False, **kw)
path += _arc_between_points(arcparms[0][0], arcparms[0][2], arcparms[0][1], arc_radius, endpoint=False, **kw)
path += _arc_through_3(
c1,
radius1,
arcparms[0][1],
[-length / 2, 0.0],
arcparms[1][1],
endpoint=False,
**kw,
)
path += _arc_between_points(arcparms[1][0], arcparms[1][1], arcparms[1][2], arc_radius, endpoint=False, **kw)
path += _arc_between_points(c2, arcparms[1][2], [length / 2, 0.0], radius2, endpoint=False, **kw)
return path
[docs]
def glued_circles(
radius: float | None = None,
spread: float = 10,
tangent: float = 30,
diameter: float | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""Two circles joined by a curved waist, like a dumbbell, built directly with polygon().
Args:
radius: radius of the end circles
spread: distance between the centers of the end circles (default 10)
tangent: angle in degrees of the tangent point of the joining arcs, from the Y axis (default 30)
diameter: diameter of the end circles (alternative to radius)
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
fn/fa/fs: arc smoothness overrides
"""
rad = radius if radius is not None else (diameter / 2 if diameter is not None else 10)
cp1 = [spread / 2, 0.0]
sa1 = 90 - tangent
ea1 = 270 + tangent
lobearc = ea1 - sa1
lobesegs = math.ceil(_frag_count(rad, fn, fa, fs) * lobearc / 360)
if tangent == 0:
# radius2/cp2 (the inner waist arc) are undefined and unused in this case: the two end
# circles' own arcs already meet with no separate waist curve needed.
path = _arc_points(lobesegs + 1, rad, sa1, ea1 - sa1, [-cp1[0], -cp1[1]]) + _arc_points(
lobesegs + 1, rad, sa1 + 180, ea1 - sa1, cp1
)
else:
radius2 = (spread / 2 / math.sin(math.radians(tangent))) - rad
cp2 = [0.0, (rad + radius2) * math.cos(math.radians(tangent))]
sa2 = 270 - tangent
ea2 = 270 + tangent
subarc = ea2 - sa2
arcsegs = math.ceil(_frag_count(radius2, fn, fa, fs) * abs(subarc) / 360)
part1 = _arc_points(lobesegs, rad, sa1, ea1 - sa1, [-cp1[0], -cp1[1]], endpoint=False)
part2 = []
for k in range(arcsegs):
theta = (ea2 + 180) + k * ((ea2 - subarc + 180) - (ea2 + 180)) / arcsegs
part2.append(
[
radius2 * math.cos(math.radians(theta)) - cp2[0],
radius2 * math.sin(math.radians(theta)) - cp2[1],
]
)
part3 = _arc_points(lobesegs, rad, sa1 + 180, ea1 - sa1, cp1, endpoint=False)
part4 = []
for k in range(arcsegs):
theta = ea2 + k * ((ea2 - subarc) - ea2) / arcsegs
part4.append(
[
radius2 * math.cos(math.radians(theta)) + cp2[0],
radius2 * math.sin(math.radians(theta)) + cp2[1],
]
)
path = part1 + part2 + part3 + part4
maxx_idx = max(range(len(path)), key=lambda i: path[i][0])
path = list(reversed(path[maxx_idx:] + path[:maxx_idx]))
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
def _superformula(
theta: float,
m1: float,
m2: float,
n1: float,
n2: float,
n3: float,
a: float,
b: float,
) -> float:
t1 = abs(math.cos(math.radians(m1 * theta / 4)) / a) ** n2
t2 = abs(math.sin(math.radians(m2 * theta / 4)) / b) ** n3
return (t1 + t2) ** (-1.0 / n1)
[docs]
def supershape(
step: float = 0.5,
count: int | None = None,
m1: float = 4,
m2: float | None = None,
n1: float | None = None,
n2: float | None = None,
n3: float | None = None,
a: float = 1,
b: float | None = None,
radius: float | None = None,
diameter: float | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
atype: str = "hull",
) -> Bosl2Shape2D:
"""A 2-D shape from the superformula, built directly with polygon().
Args:
step: angle step size for sampling the superformula (smaller = slower, more accurate) (default 0.5)
count: number of output points, an alternative to step
m1: superformula m1 argument (default 4)
m2: superformula m2 argument (default: same as m1)
n1: superformula n1 argument (default 1)
n2: superformula n2 argument (default: same as n1)
n3: superformula n3 argument (default: same as n2)
a: superformula a argument (default 1)
b: superformula b argument (default: same as a)
radius: scale the shape to fit in a circle of this radius
diameter: scale the shape to fit in a circle of this diameter
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
atype: anchor method; only "hull" is implemented here (default "hull")
"""
n_pts = count if count is not None else math.ceil(360.0 / step)
n1v = n1 if n1 is not None else 1
m2v = m2 if m2 is not None else m1
n2v = n2 if n2 is not None else n1v
n3v = n3 if n3 is not None else n2v
bv = b if b is not None else a
angles = [360.0 - i * 360.0 / n_pts for i in range(n_pts)]
rvals = [_superformula(t, m1, m2v, n1v, n2v, n3v, a, bv) for t in angles]
target_radius = radius if radius is not None else (diameter / 2 if diameter is not None else None)
scale = (target_radius / max(rvals)) if target_radius is not None else 1.0
path = [
[
scale * rvals[i] * math.cos(math.radians(angles[i])),
scale * rvals[i] * math.sin(math.radians(angles[i])),
]
for i in range(n_pts)
]
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
def _linearize_squareness(squareness: float) -> float:
# Chamberlain Fong (2016), "Squircular Calculations", arXiv:1604.02174v5.
c = 2 - 2 * math.sqrt(2)
d = 1 - 0.5 * c * squareness
return 2 * math.sqrt((1 + c) * squareness * squareness - c * squareness) / (d * d)
[docs]
def squircle_radius_fg(squareness: float, radius: float, angle: float) -> float:
"""
The Fong-Garcia squircle radius at *angle* degrees for squareness *squareness* and size
*radius*.
"""
s2a = abs(squareness * math.sin(math.radians(2 * angle)))
return radius * math.sqrt(2) / s2a * math.sqrt(1 - math.sqrt(1 - s2a * s2a)) if s2a > 0 else radius
def _squircle_fg_path(size, squareness, fn, fa, fs) -> list:
sq = _linearize_squareness(squareness)
aspect = size[1] / size[0]
r = 0.5 * size[0]
fn = _frag_count(r, fn, fa, fs)
astep = 90.0 / round(fn / 4) if fn >= 12 else 360.0 / 48
pts = []
a = 360.0
while a > 0.01:
theta = a + sq * math.sin(math.radians(4 * a)) * 30 / math.pi
p = squircle_radius_fg(sq, r, theta)
pts.append(
[
p * math.cos(math.radians(theta)),
p * aspect * math.sin(math.radians(theta)),
]
)
a -= astep
return pts
[docs]
def squircle(
size,
squareness: float = 0.5,
style: str = "fg",
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A squircle -- a rounded square that morphs between a square and a circle (BOSL2 squircle()).
*squareness* runs 0 (a circle) to 1 (a square). Only the default ``"fg"`` (Fong-Garcia) style
is ported; the ``"superellipse"`` and ``"bezier"`` styles are not.
Args:
size: scalar or [x, y] size of the bounding box
squareness: 0 (circle) .. 1 (square); default 0.5
style: only "fg" is supported
anchor/spin: standard BOSL2 2-D anchor / spin
fn/fa/fs: smoothness overrides
Examples:
.. pythonscad-example::
s2.squircle(40, squareness=0.7).linear_extrude(height=5).show()
"""
assert 0 <= squareness <= 1, "squircle(): squareness must be between 0 and 1."
sz = [float(size), float(size)] if isinstance(size, (int, float)) else [float(size[0]), float(size[1])]
assert style == "fg", 'squircle(): only the default "fg" style is ported.'
path = _squircle_fg_path(sz, squareness, fn, fa, fs)
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
[docs]
def keyhole(
length: float | None = None,
radius1: float | None = None,
radius2: float | None = None,
shoulder_radius: float = 0,
diameter1: float | None = None,
diameter2: float | None = None,
_length=None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A keyhole slot -- a small circle joined to a larger one by tangent shoulders (BOSL2 keyhole()).
Args:
length: overall length between the two circle centers (default 15)
radius1/diameter1: radius/diameter of the small (bottom) circle (default 5)
radius2/diameter2: radius/diameter of the large (top) circle (default 10)
shoulder_radius: fillet radius where the shoulders meet the circles (default 0)
anchor/spin: standard BOSL2 2-D anchor / spin
Examples:
.. pythonscad-example::
s2.keyhole(length=25, radius1=4, radius2=9, shoulder_radius=2).linear_extrude(height=4).show()
"""
lv = float(length if length is not None else (_length if _length is not None else 15))
r1v = float(radius1 if radius1 is not None else (diameter1 / 2 if diameter1 is not None else 5))
r2v = float(radius2 if radius2 is not None else (diameter2 / 2 if diameter2 is not None else 10))
assert lv > 0 and lv >= max(r1v, r2v), "keyhole(): length must be positive and at least max(radius1, radius2)."
shoulder_radius = float(shoulder_radius) if shoulder_radius is not None else min(r1v, r2v) / 2
cp1, cp2 = [0.0, 0.0], [0.0, -lv]
minr, maxr = min(r1v, r2v) + shoulder_radius, max(r1v, r2v) + shoulder_radius
dy = math.sqrt(maxr * maxr - minr * minr)
spt1 = [cp1[0] + minr, cp1[1] - dy] if r1v > r2v else [cp2[0] + minr, cp2[1] + dy]
spt2 = [-spt1[0], spt1[1]]
base = cp1 if r1v > r2v else cp2
ds = [spt1[0] - base[0], spt1[1] - base[1]]
angle = math.degrees(math.atan2(abs(ds[1]), abs(ds[0])))
def _arc(**kw):
return arc(endpoint=False, fn=fn, fa=fa, fs=fs, **kw)
path: list[Any] = []
if r1v > r2v:
path += (
[spt1]
if shoulder_radius <= 0
else _arc(radius=shoulder_radius, center=spt1, start=180 - angle, angle=angle)
)
path += _arc(radius=r2v, center=cp2, start=0, angle=-180)
path += [spt2] if shoulder_radius <= 0 else _arc(radius=shoulder_radius, center=spt2, start=0, angle=angle)
path += _arc(radius=r1v, center=cp1, start=180 + angle, angle=-180 - 2 * angle)
else:
path += [spt1] if shoulder_radius <= 0 else _arc(radius=shoulder_radius, center=spt1, start=180, angle=angle)
path += _arc(radius=r2v, center=cp2, start=angle, angle=-180 - 2 * angle)
path += (
[spt2]
if shoulder_radius <= 0
else _arc(radius=shoulder_radius, center=spt2, start=360 - angle, angle=angle)
)
path += _arc(radius=r1v, center=cp1, start=180, angle=-180)
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
[docs]
def ring(
sides: int | None = None,
ring_width: float | None = None,
radius: float | None = None,
radius1: float | None = None,
radius2: float | None = None,
diameter: float | None = None,
diameter1: float | None = None,
diameter2: float | None = None,
angle: float | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A 2-D ring (annulus) between two concentric radii (BOSL2 ring(), full-annulus form).
Give either both radii (*radius1*/*radius2* or *diameter1*/*diameter2*) or one radius plus
*ring_width*. The arc / 3-point / corner / width+thickness forms of BOSL2 ``ring()`` are
not ported.
Args:
radius1/radius2 (or diameter1/diameter2): the two radii/diameters
radius/diameter + ring_width: one radius plus the wall width
sides: number of sides (overrides the smoothness overrides)
anchor/spin: standard BOSL2 2-D anchor / spin
Examples:
.. pythonscad-example::
s2.ring(radius=20, ring_width=4).linear_extrude(height=5).show()
"""
assert angle is None, "ring(): only the full-annulus form is ported (no angle=)."
r1v = radius1 if radius1 is not None else (diameter1 / 2 if diameter1 is not None else None)
r2v = radius2 if radius2 is not None else (diameter2 / 2 if diameter2 is not None else None)
rv = radius if radius is not None else (diameter / 2 if diameter is not None else None)
if r1v is not None and r2v is not None:
inner, outer = min(r1v, r2v), max(r1v, r2v)
else:
assert rv is not None and ring_width is not None, (
"ring(): give (radius1 and radius2) or (radius and ring_width)."
)
inner, outer = min(rv, rv + ring_width), max(rv, rv + ring_width)
assert inner != outer and outer > 0, "ring(): zero (or invalid) width."
fnv = sides if sides is not None else fn
shape = circle(radius=outer, fn=fnv, fa=fa, fs=fs) - circle(radius=inner, fn=fnv, fa=fa, fs=fs)
offset = _anchor_offset_box([2 * outer, 2 * outer], anchor)
return _finish(shape, offset, spin, size=[2 * outer, 2 * outer], anchor=anchor)
[docs]
def reuleaux_polygon(
sides: int = 3,
radius: float | None = None,
diameter: float | None = None,
anchor: Sequence[float] = CENTER,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""A Reuleaux polygon (constant-width curved-side shape), built directly with polygon().
Args:
sides: number of "sides"; must be an odd positive number (default 3)
radius: scale the shape to fit in a circle of this radius
diameter: scale the shape to fit in a circle of this diameter
anchor: anchor point (default CENTER)
spin: Z-axis rotation in degrees after anchor (default 0)
fn/fa/fs: arc smoothness overrides
"""
assert sides >= 3 and sides % 2 == 1
rad = radius if radius is not None else (diameter / 2 if diameter is not None else 1)
ssegs = max(3, math.ceil(_frag_count(rad, fn, fa, fs) / sides))
slen = math.dist(_polar_to_xy(rad, 0), _polar_to_xy(rad, 180 - 180.0 / sides))
path = []
for i in range(sides):
ca = 180 - (i + 0.5) * 360.0 / sides
sa = ca + 180 + 90.0 / sides
ea = ca + 180 - 90.0 / sides
center = _polar_to_xy(rad, ca)
path += _arc_points(ssegs - 1, slen, sa, ea - sa, center, endpoint=False)
shape = _opolygon(path)
offset = _anchor_offset_hull(path, anchor)
return _finish(shape, offset, spin)
# ---------------------------------------------------------------------------
# Section: Text
# ---------------------------------------------------------------------------
[docs]
def text(
text: str,
size: float = 10,
font: str = "Liberation Sans",
halign: str | None = None,
valign: str | None = None,
spacing: float = 1.0,
direction: str = "ltr",
language: str = "en",
script: str = "latin",
anchor: str = "baseline",
spin: float = 0,
) -> Bosl2Shape2D:
"""2-D text, built directly with the builtin text() (which already supports halign/valign).
Args:
text: text to create
size: font size (default 10)
font: font to use (default "Liberation Sans")
halign: horizontal alignment: "left", "center", "right" (default "center")
valign: vertical alignment: "top", "center", "baseline", "bottom" (default: `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: vertical alignment fallback used when valign isn't given (default "baseline")
spin: Z-axis rotation in degrees (default 0)
"""
h = halign if halign is not None else "center"
v = valign if valign is not None else anchor
shape = _otext(
text,
size=size,
font=font,
halign=h,
valign=v,
spacing=spacing,
direction=direction,
language=language,
script=script,
)
return _finish(shape, [0.0, 0.0], spin)
# ---------------------------------------------------------------------------
# Section: Rounding 2D shapes
# ---------------------------------------------------------------------------
[docs]
def round2d(
radius: float | None = None,
outer_radius: float | None = None,
inner_radius: float | None = None,
children: "Bosl2Shape2D | PyOpenSCAD | None" = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""Rounds the concave and/or convex corners of arbitrary 2-D children, via chained .offset() calls.
Giving `radius` rounds all corners; `inner_radius` alone rounds only concave corners;
`outer_radius` alone rounds only convex corners; giving both rounds each to a different
radius.
Note: BOSL2's outer-radius parameter is named `or`, exposed here as `outer_radius`.
Args:
radius: radius to round all concave and convex corners to
outer_radius: radius to round only convex (outside) corners to (BOSL2 `or`)
inner_radius: radius to round only concave (inside) corners to
children: the 2-D solid(s) to round
fn/fa/fs: arc smoothness overrides
"""
orad = outer_radius if outer_radius is not None else (radius if radius is not None else 0)
irad = inner_radius if inner_radius is not None else (radius if radius is not None else 0)
assert children is not None, "round2d(): must give children"
shape = Bosl2Shape2D(_as_native_2d(children))
shape = shape.offset(delta=irad, chamfer=True)
shape = shape.offset(delta=-(irad + orad))
return shape.offset(radius=orad, fn=fn, fa=fa, fs=fs)
[docs]
def shell2d(
thickness: float | Sequence[float] | None = None,
outer_radius: float | Sequence[float] = 0,
inner_radius: float | Sequence[float] = 0,
children: "Bosl2Shape2D | PyOpenSCAD | None" = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""Creates a hollow shell from 2-D children, with optional rounding.
Note: BOSL2's outer-radius parameter is named `or`, exposed here as `outer_radius`.
Args:
thickness: shell thickness; positive expands outward, negative shrinks inward,
or a 2-element list to do both
outer_radius: rounding radius for outside corners of the shell (BOSL2 `or`); a
[CONVEX,CONCAVE] pair rounds those corner types separately (default 0)
inner_radius: rounding radius for inside corners of the shell; a [CONVEX,CONCAVE]
pair rounds those corner types separately (default 0)
children: the 2-D solid(s) to shell
fn/fa/fs: arc smoothness overrides
"""
assert thickness is not None, "shell2d(): must give thickness"
assert children is not None, "shell2d(): must give children"
if isinstance(thickness, (int, float)):
th = [float(thickness), 0.0] if thickness < 0 else [0.0, float(thickness)]
else:
tl = [float(v) for v in thickness]
th = [tl[1], tl[0]] if tl[0] > tl[1] else tl
orad = (
[float(outer_radius), float(outer_radius)]
if isinstance(outer_radius, (int, float))
else [float(v) for v in outer_radius]
)
irad = (
[float(inner_radius), float(inner_radius)]
if isinstance(inner_radius, (int, float))
else [float(v) for v in inner_radius]
)
base = Bosl2Shape2D(_as_native_2d(children))
outer_shape = round2d(
outer_radius=orad[0],
inner_radius=orad[1],
children=base.offset(delta=th[1], fn=fn, fa=fa, fs=fs),
fn=fn,
fa=fa,
fs=fs, # type: ignore[arg-type]
)
inner_shape = round2d(
outer_radius=irad[1],
inner_radius=irad[0],
children=base.offset(delta=th[0], fn=fn, fa=fa, fs=fs),
fn=fn,
fa=fa,
fs=fs, # type: ignore[arg-type]
)
return outer_shape - inner_shape