Source code for pybosl2.shapes2d.curves

# 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/shapes2d/curves.py
# FileSummary: Stars, teardrops, eggs, squircles and supershapes.
# DocCategory: Foundational
# FileGroup: BOSL2

from __future__ import annotations

import math
import random
from collections.abc import Sequence
from typing import TYPE_CHECKING, Union

import numpy as np

# Import base class and helper functions from shapes2d.base
from pybosl2._helpers import (
    AnchorType,
)
from pybosl2._helpers import (
    anchor_offset_generic as _anchor_offset_generic,
)
from pybosl2._helpers import (
    anchor_offset_hull as _anchor_offset_hull,
)
from pybosl2._helpers import (
    circle_pts as _circle_pts,
)
from pybosl2._helpers import (
    frag_count as _frag_count,
)
from pybosl2._helpers import (
    pick_radius as _pick_radius,
)
from pybosl2._helpers import (
    rotate2d as _rotate2d,
)
from pybosl2._native import native
from pybosl2.constants import CENTER
from pybosl2.vectors import unit

from .base import (
    Bosl2Shape2D,
    _arc_between_points,
    _arc_through_3,
    _circle_circle_intersection,
    _finish,
)

if TYPE_CHECKING:
    from openscad import PyOpenSCAD

    from pybosl2._edges_lang import Anchor
    from pybosl2.path2d import Path2D


Shape2DLike = Union["Bosl2Shape2D", "PyOpenSCAD", "Path2D", Sequence[Sequence[float]], np.ndarray]

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")


[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: Anchor | Sequence[float] = CENTER, spin: float = 0, atype: str | AnchorType = AnchorType.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: radius to the tips of the star outer_radius: radius to the tips of the star (BOSL2 `or`); alias for radius inner_radius: radius to the inner corners of the star diameter: diameter to the tips of the star outer_diameter: diameter to the tips of the star; alias for diameter 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) 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 (default AnchorType.HULL) Examples: .. pythonscad-example:: from pybosl2 import shapes2d as s2 s2.star(tips=5, radius=20, inner_radius=8).linear_extrude(height=5).show() """ 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_generic(path, anchor, atype) return _finish(shape, offset, spin)
[docs] def teardrop2d( radius: float | None = None, angle: float = 45, cap_height: float | None = None, diameter: float | None = None, circumscribe: bool = False, realign: bool = False, anchor: 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: `circumscribe` is approximated the same way as the inscribed case here. 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) circumscribe: 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: arc smoothness overrides fa: arc smoothness overrides fs: arc smoothness overrides Examples: .. pythonscad-example:: from pybosl2 import shapes2d as s2 s2.teardrop2d(radius=15, angle=45).linear_extrude(height=5).show() """ rad = radius if radius is not None else (diameter / 2 if diameter is not None else 1) if circumscribe: n = _frag_count(rad, fn, fa, fs) rad /= math.cos(math.pi / n) 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: 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: arc smoothness overrides fa: arc smoothness overrides fs: arc smoothness overrides Examples: .. pythonscad-example:: from pybosl2 import shapes2d as s2 s2.egg(length=30, radius1=10, radius2=8, arc_radius=20).linear_extrude(height=5).show() """ 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") arc_r = arc_radius if arc_radius is not None else (arc_diameter / 2 if arc_diameter is not None else None) if arc_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, arc_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: int | None = None, fa: float | None = None, fs: float | None = 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]], ] ) path: list[list[float]] = [] path += _arc_between_points(c2, [length / 2, 0.0], arcparms[0][2], radius2, endpoint=False, fn=fn, fa=fa, fs=fs) path += _arc_between_points( arcparms[0][0], arcparms[0][2], arcparms[0][1], arc_radius, endpoint=False, fn=fn, fa=fa, fs=fs ) path += _arc_through_3( c1, radius1, arcparms[0][1], [-length / 2, 0.0], arcparms[1][1], endpoint=False, fn=fn, fa=fa, fs=fs, ) path += _arc_between_points( arcparms[1][0], arcparms[1][1], arcparms[1][2], arc_radius, endpoint=False, fn=fn, fa=fa, fs=fs ) path += _arc_between_points(c2, arcparms[1][2], [length / 2, 0.0], radius2, endpoint=False, fn=fn, fa=fa, fs=fs) return path 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) # type: ignore[no-any-return]
[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: Anchor | Sequence[float] = CENTER, spin: float = 0, atype: str | AnchorType = AnchorType.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 (default AnchorType.HULL) Examples: .. pythonscad-example:: from pybosl2 import shapes2d as s2 s2.supershape(m1=3, radius=20).linear_extrude(height=5).show() """ 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_generic(path, anchor, atype) 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: Sequence[float], squareness: float, fn: int | None, fa: float | None, fs: float | None, ) -> list[list[float]]: 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: float | Sequence[float], squareness: float = 0.5, style: str = "fg", anchor: 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: standard BOSL2 2-D anchor spin: standard BOSL2 2-D spin fn: smoothness overrides fa: smoothness overrides fs: smoothness overrides Examples: .. pythonscad-example:: from pybosl2 import shapes2d as s2 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)
# --------------------------------------------------------------------------- # 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]