Source code for pybosl2.shapes3d.sphere

# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause

# LibFile: pybosl2/shapes3d/sphere.py
# FileSummary: Spheres, spheroids, onions and teardrops.
# DocCategory: Foundational
# FileGroup: BOSL2

"""Spheres, spheroids, onions and teardrops."""

from __future__ import annotations

import math
from typing import TYPE_CHECKING

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

if TYPE_CHECKING:
    from collections.abc import Sequence


from pybosl2._helpers import frag_count as _frag_count
from pybosl2._helpers import pick_radius as _pick_radius

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

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


def _anchor_offset_sphere(radius: float, anchor: Anchor | Sequence[float]) -> list[float]:
    a = anchor.vector if isinstance(anchor, Anchor) else list(anchor)
    sides = math.hypot(*a)
    if sides == 0:
        return [0.0, 0.0, 0.0]
    return [-a[i] / sides * radius for i in range(3)]


# ---------------------------------------------------------------------------
# Section: Other Round Objects
# ---------------------------------------------------------------------------


[docs] def sphere( radius: float | None = None, diameter: float | None = None, circumscribe: bool = False, anchor: Anchor | Sequence[float] = Anchor.CENTER, spin: float = 0, orient: Anchor | Sequence[float] = Anchor.TOP, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """Return a sphere, built with the builtin sphere(), with BOSL2-style anchor/spin/orient support. Note: `style=` is accepted for signature compatibility but not applied; the builtin sphere() is used directly. Args: radius: radius of the sphere diameter: diameter of the sphere circumscribe: circumscribe rather than inscribe the sphere (default False) anchor: anchor point (default CENTER) spin: Z-axis rotation in degrees after anchor (default 0) orient: direction to rotate the top towards, after spin (default UP) fn: arc smoothness overrides fa: arc smoothness overrides fs: arc smoothness overrides Examples: .. pythonscad-example:: from pybosl2.solid import sphere shape = sphere(radius=15) shape.show() """ rad = radius if radius is not None else (diameter / 2 if diameter is not None else 1) if circumscribe: sides = _frag_count(rad, fn, fa, fs) rad /= math.cos(math.pi / sides) shape = _osphere(radius=rad, fn=fn, fa=fa, fs=fs) offset = _anchor_offset_sphere(rad, anchor) return _finish3(shape, offset, spin, orient, size=None, anchor=anchor)
[docs] def spheroid( radius: float | None = None, diameter: float | None = None, circumscribe: bool = False, anchor: Anchor | Sequence[float] = Anchor.CENTER, spin: float = 0, orient: Anchor | Sequence[float] = Anchor.TOP, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """Return an approximate sphere; this pure-Python port just builds a plain sphere() (style/dual are ignored). Args: radius: radius of the spheroid diameter: diameter of the spheroid circumscribe: circumscribe rather than inscribe the spheroid (default False) anchor: anchor point (default CENTER) spin: Z-axis rotation in degrees after anchor (default 0) orient: direction to rotate the top towards, after spin (default UP) fn: arc smoothness overrides fa: arc smoothness overrides fs: arc smoothness overrides Examples: .. pythonscad-example:: from pybosl2.solid import spheroid spheroid(radius=15).show() """ return sphere( radius=radius, diameter=diameter, circumscribe=circumscribe, anchor=anchor, spin=spin, orient=orient, fn=fn, fa=fa, fs=fs, )
def _teardrop2d_path( radius: float, angle: float, cap_height: float | None, circum: bool, realign: bool, sides: int, ) -> list[list[float]]: """Return the 2-D (X,Y) outline of a BOSL2-style teardrop2d(): a circle of radius *radius* capped by a. point (or, if *cap_height* truncates it, a flat top) formed by two walls tangent to the circle at +-*angle* degrees from the Y axis. *sides* is the segment count for a full circle of this radius (as from _frag_count()); *realign* is approximated by toggling the parity of the round section's vertex count, since a vertex landing exactly at the bottom gives a "point" and a vertex straddling it gives a "flat" bottom -- the same effect BOSL2 gets from its own $fn discretization. """ from pybosl2._helpers import arc_points as _arc_points rad = radius / math.cos(math.pi / sides) if circum else radius maxheight = rad / math.sin(math.radians(angle)) minheight = rad * math.sin(math.radians(angle)) assert cap_height is None or cap_height >= minheight - 1e-9, ( "teardrop2d(): cap_height cannot be less than radius*sin(angle)." ) pointy = cap_height is None or cap_height >= maxheight sweep = 180 + 2 * angle pts = max(2, round(sides * sweep / 360)) + 1 if realign == (pts % 2 == 1): pts += 1 arc = _arc_points(pts, rad, angle, -sweep, [0.0, 0.0]) if pointy or cap_height is None: return [[0.0, maxheight]] + arc cap_x = (maxheight - cap_height) * math.tan(math.radians(angle)) return [[cap_x, cap_height]] + arc + [[-cap_x, cap_height]]
[docs] def teardrop( height: float | None = None, radius: float | None = None, angle: float = 45, cap_height: float | None = None, circumscribe: bool = False, radius1: float | None = None, radius2: float | None = None, diameter: float | None = None, diameter1: float | None = None, diameter2: float | None = None, cap_h1: float | None = None, cap_h2: float | None = None, chamfer: float = 0, chamfer1: float = 0, chamfer2: float = 0, realign: bool = False, anchor: Anchor | Sequence[float] = Anchor.CENTER, spin: float = 0, orient: Anchor | Sequence[float] = Anchor.TOP, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """BOSL2 teardrop() -- a teardrop shape, useful for 3D-printable horizontal holes. Args: height: thickness of the teardrop (default 1) radius: radius of the circular part (default 1) angle: angle of the hat walls from the Z axis in degrees (default 45) cap_height: height above center to truncate the shape (default: no truncation) circumscribe: produce a circumscribing teardrop shape (default False) radius1: radius of the circular portion of the front end radius2: radius of the circular portion of the back end diameter: diameter of the circular portion diameter1: diameter of the front end diameter2: diameter of the back end cap_h1: truncation height on the front side cap_h2: truncation height on the back side chamfer: chamfer size along the bottom/top faces (overall) (default 0) chamfer1: chamfer size along the bottom face (default 0) chamfer2: chamfer size along the top face (default 0) realign: shift face alignment, passed to teardrop2d (default False) anchor: anchor point (default CENTER) spin: Z-axis rotation in degrees after anchor (default 0) orient: direction to rotate the top towards, after spin (default UP) fn: arc smoothness overrides fa: arc smoothness overrides fs: arc smoothness overrides Examples: .. pythonscad-example:: from pybosl2.solid import teardrop shape = teardrop(radius=8, angle=45, height=15) shape.show() """ length = height if height is not None else 1.0 rad1 = _pick_radius(radius1=radius1, diameter1=diameter1, radius=radius, diameter=diameter, dflt=1) rad2 = _pick_radius(radius1=radius2, diameter1=diameter2, radius=radius, diameter=diameter, dflt=1) cap_h1v = cap_h1 if cap_h1 is not None else cap_height cap_h2v = cap_h2 if cap_h2 is not None else cap_height c1 = chamfer1 if chamfer1 else chamfer c2 = chamfer2 if chamfer2 else chamfer sides = _frag_count(max(rad1, rad2), fn, fa, fs) def section(rad: float, cap_hv: float | None, y: float) -> list[list[float]]: path = _teardrop2d_path(rad, angle, cap_hv, circumscribe, realign, sides) return [[p[0], y, p[1]] for p in path] front_y, back_y = -length / 2, length / 2 slices = [] if c1: cap_hv = (cap_h1v - c1) if cap_h1v is not None else None slices.append(section(max(0.001, rad1 - c1), cap_hv, front_y)) front_y += abs(c1) slices.append(section(rad1, cap_h1v, front_y)) if c2: back_y -= abs(c2) slices.append(section(rad2, cap_h2v, back_y)) if c2: cap_hv = (cap_h2v - c2) if cap_h2v is not None else None slices.append(section(max(0.001, rad2 - c2), cap_hv, back_y + abs(c2))) solids = [_opolyhedron(pts, [list(range(len(pts)))]) for pts in slices] shape = solids[0] for a, b in zip(solids, solids[1:], strict=False): piece = _ohull(a, b) shape = piece if shape is solids[0] else (shape | piece) offset = _anchor_offset_cyl(rad1, rad2, length, anchor, axis=1) return _finish3(shape, offset, spin, orient, size=None, anchor=anchor)
[docs] def onion( radius: float | None = None, angle: float = 45, cap_height: float | None = None, circumscribe: bool = False, diameter: float | None = None, anchor: Anchor | Sequence[float] = Anchor.CENTER, spin: float = 0, orient: Anchor | Sequence[float] = Anchor.TOP, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """BOSL2 onion() -- an onion-dome shape (a sphere with a conical cap). Args: radius: radius of the spherical portion of the bottom (default 1) angle: angle of the cone from vertical in degrees (default 45) cap_height: height above the sphere center to truncate the shape (default: no truncation) circumscribe: circumscribe rather than inscribe the given radius/diameter (default False) diameter: diameter of the spherical portion of the bottom anchor: anchor point (default CENTER) spin: Z-axis rotation in degrees after anchor (default 0) orient: direction to rotate the top towards, after spin (default UP) fn: arc smoothness overrides fa: arc smoothness overrides fs: arc smoothness overrides Examples: .. pythonscad-example:: from pybosl2.solid import onion onion(radius=15).show() """ from pybosl2._helpers import arc_points as _arc_points from pybosl2._native import native _opolygon = native("polygon") rad = _pick_radius(radius=radius, diameter=diameter, dflt=1) sides = _frag_count(rad, fn, fa, fs) scaled = rad / math.cos(math.pi / sides) if circumscribe else rad maxheight = scaled / math.sin(math.radians(angle)) top_z = min(cap_height, maxheight) if cap_height is not None else maxheight pointy = top_z >= maxheight - 1e-9 sweep = 90 + angle pts = max(2, round(sides * sweep / 360)) + 1 arc = list(reversed(_arc_points(pts, scaled, angle, -sweep, [0.0, 0.0]))) if pointy: profile = arc + [[0.0, top_z]] else: cap_x = (maxheight - top_z) * math.tan(math.radians(angle)) profile = arc + [[cap_x, top_z], [0.0, top_z]] shape = _orotate_extrude(_opolygon(profile), fn=fn, fa=fa, fs=fs) a = list(anchor) off_z = 0.0 if a[2] == 0 else (scaled if a[2] < 0 else -top_z) rn = math.hypot(a[0], a[1]) off_xy = [-a[0] / rn * scaled, -a[1] / rn * scaled] if rn > 0 else [0.0, 0.0] offset = [off_xy[0], off_xy[1], off_z] return _finish3(shape, offset, spin, orient, size=None, anchor=anchor)