Source code for pybosl2.parts.polyhedra

# 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/parts/polyhedra.py
#    The five Platonic solids from BOSL2's polyhedra.scad, built as watertight polyhedra.
#    :class:`RegularPolyhedron` builds any :class:`PlatonicSolid`, sized by
#    circumradius, diameter, inradius, or side length (there are named convenience methods
#    too). :class:`PolyhedronInfo` returns vertex/face data.
#
#    The Archimedean, Catalan and stellated families from the full BOSL2 module are not ported.
#
# FileSummary: The five Platonic solids as watertight polyhedra.
# DocCategory: Parts library
# FileGroup: BOSL2

"""The five Platonic solids as watertight polyhedra."""

from __future__ import annotations

import math
from dataclasses import dataclass
from enum import StrEnum

import numpy as np

from pybosl2.shapes3d import Bosl2Solid
from pybosl2.vnf import VNF

__all__ = ["RegularPolyhedron", "PolyhedronInfo", "PlatonicSolid"]


class PlatonicSolid(StrEnum):
    """Platonic solid type."""

    TETRAHEDRON = "tetrahedron"
    CUBE = "cube"
    OCTAHEDRON = "octahedron"
    DODECAHEDRON = "dodecahedron"
    ICOSAHEDRON = "icosahedron"


_PHI = (1 + math.sqrt(5)) / 2


def _normalize(verts: list[tuple[float, float, float]]) -> list[list[float]]:
    """Scale a vertex list so its circumradius (max |v|) is 1."""
    arr = np.asarray(verts, dtype=float)
    return (arr / np.linalg.norm(arr, axis=1).max()).tolist()  # type: ignore[no-any-return]


def _dual(
    verts: list[list[float]],
    faces: list[list[int]],
) -> tuple[list[list[float]], list[list[int]]]:
    """Return the dual polyhedron: new vertices are the (normalized) face centroids, new faces are the.

    rings of faces around each original vertex. Used to derive the dodecahedron from the icosahedron.
    """
    verts_arr = np.asarray(verts, dtype=float)
    centroids = np.array([verts_arr[f].mean(axis=0) for f in faces])
    centroids = centroids / np.linalg.norm(centroids, axis=1)[:, None]
    newfaces = []
    for vi in range(len(verts_arr)):
        adj = [fi for fi, f in enumerate(faces) if vi in f]
        sides = verts_arr[vi] / np.linalg.norm(verts_arr[vi])
        t = np.cross(sides, [0, 0, 1.0])
        if np.linalg.norm(t) < 1e-6:
            t = np.cross(sides, [0, 1.0, 0])
        t = t / np.linalg.norm(t)
        b = np.cross(sides, t)

        def angle(fi: int, sides: "np.ndarray" = sides, b: "np.ndarray" = b, t: "np.ndarray" = t) -> float:
            diameter = centroids[fi] - sides * float(np.dot(centroids[fi], sides))
            return math.atan2(float(np.dot(diameter, b)), float(np.dot(diameter, t)))

        newfaces.append(sorted(adj, key=angle))
    return centroids.tolist(), newfaces


# --- the five Platonic solids, unit circumradius -----------------------------

_TETRA_V = _normalize([(1, 1, 1), (-1, -1, 1), (-1, 1, -1), (1, -1, -1)])
_TETRA_F = [[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]]

_CUBE_V = _normalize(
    [
        (-1, -1, -1),
        (1, -1, -1),
        (1, 1, -1),
        (-1, 1, -1),
        (-1, -1, 1),
        (1, -1, 1),
        (1, 1, 1),
        (-1, 1, 1),
    ]
)
_CUBE_F = [
    [0, 1, 2, 3],
    [4, 7, 6, 5],
    [0, 4, 5, 1],
    [1, 5, 6, 2],
    [2, 6, 7, 3],
    [3, 7, 4, 0],
]

_OCTA_V = _normalize([(1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)])
_OCTA_F = [
    [4, 0, 2],
    [4, 2, 1],
    [4, 1, 3],
    [4, 3, 0],
    [5, 2, 0],
    [5, 1, 2],
    [5, 3, 1],
    [5, 0, 3],
]

_ICOSA_V = _normalize(
    [
        (-1, _PHI, 0),
        (1, _PHI, 0),
        (-1, -_PHI, 0),
        (1, -_PHI, 0),
        (0, -1, _PHI),
        (0, 1, _PHI),
        (0, -1, -_PHI),
        (0, 1, -_PHI),
        (_PHI, 0, -1),
        (_PHI, 0, 1),
        (-_PHI, 0, -1),
        (-_PHI, 0, 1),
    ]
)
_ICOSA_F = [
    [0, 11, 5],
    [0, 5, 1],
    [0, 1, 7],
    [0, 7, 10],
    [0, 10, 11],
    [1, 5, 9],
    [5, 11, 4],
    [11, 10, 2],
    [10, 7, 6],
    [7, 1, 8],
    [3, 9, 4],
    [3, 4, 2],
    [3, 2, 6],
    [3, 6, 8],
    [3, 8, 9],
    [4, 9, 5],
    [2, 4, 11],
    [6, 2, 10],
    [8, 6, 7],
    [9, 8, 1],
]

_DODECA_V, _DODECA_F = _dual(_ICOSA_V, _ICOSA_F)

# PlatonicSolid -> (unit-circumradius vertices, faces, circumradius/side ratio)
_SOLIDS: dict[PlatonicSolid, tuple[list[list[float]], list[list[int]], float]] = {
    PlatonicSolid.TETRAHEDRON: (_TETRA_V, _TETRA_F, math.sqrt(6) / 4),
    PlatonicSolid.CUBE: (_CUBE_V, _CUBE_F, math.sqrt(3) / 2),
    PlatonicSolid.OCTAHEDRON: (_OCTA_V, _OCTA_F, math.sqrt(2) / 2),
    PlatonicSolid.DODECAHEDRON: (_DODECA_V, _DODECA_F, math.sqrt(3) / 4 * (1 + math.sqrt(5))),
    PlatonicSolid.ICOSAHEDRON: (_ICOSA_V, _ICOSA_F, math.sqrt(10 + 2 * math.sqrt(5)) / 4),
}


def _inradius_ratio(name: PlatonicSolid) -> float:
    """Inradius / circumradius for the unit solid (min face-plane distance)."""
    verts, faces, _ = _SOLIDS[name]
    verts_arr = np.asarray(verts)
    return min(float(np.linalg.norm(verts_arr[f].mean(axis=0))) for f in faces)


# ---------------------------------------------------------------------------
# Section: public API
# ---------------------------------------------------------------------------


[docs] @dataclass class PolyhedronInfo: """Vertex and face data for a Platonic solid. The constructor takes a :class:`PlatonicSolid` enum and looks up the geometry, equivalent to the old ``Polyhedra.regular_polyhedron_info()``. """ name: str vertices: list[list[float]] faces: list[list[int]] num_vertices: int num_faces: int def __init__(self, solid: PlatonicSolid) -> None: """Look up vertex/face data for the given Platonic solid. Args: solid: A :class:`PlatonicSolid` enum value identifying the polyhedron. Returns: None. """ verts, faces, _ratio = _SOLIDS[solid] self.name = solid.value self.vertices = [list(v) for v in verts] self.faces = [list(f) for f in faces] self.num_vertices = len(verts) self.num_faces = len(faces)
[docs] class RegularPolyhedron: """A regular Platonic solid, sized by circumradius, diameter, inradius, or side. *name* is a :class:`PlatonicSolid` enum. The convenience class methods :meth:`tetrahedron`, :meth:`cube`, :meth:`octahedron`, :meth:`dodecahedron` and :meth:`icosahedron` construct the corresponding solid without requiring the enum. Examples: A dodecahedron: .. pythonscad-example:: from pybosl2.parts.polyhedra import RegularPolyhedron, PlatonicSolid RegularPolyhedron(PlatonicSolid.DODECAHEDRON, side=12).show() """ def __init__( self, name: PlatonicSolid = PlatonicSolid.CUBE, radius: float | None = None, diameter: float | None = None, inner_radius: float | None = None, side: float | None = None, ) -> None: """Create a regular polyhedron, sized by one of radius/diameter/inradius/side. Args: name: A :class:`PlatonicSolid` enum value identifying the polyhedron type. radius: Circumscribing sphere radius. diameter: Circumscribing sphere diameter. inner_radius: Insphere radius (distance from centre to mid-face). side: Edge length. Returns: None. Raises: KeyError: If *name* is not a valid Platonic solid. """ _ = _SOLIDS[name] # validate early self._name: PlatonicSolid = name self._radius: float | None = radius self._diameter: float | None = diameter self._inner_radius: float | None = inner_radius self._side: float | None = side self._solid: Bosl2Solid | None = None @property def name(self) -> PlatonicSolid: """The Platonic solid type.""" return self._name
[docs] def info(self) -> PolyhedronInfo: """Return vertex/face data for this solid. Returns: A :class:`PolyhedronInfo` object containing vertex and face lists. """ return PolyhedronInfo(self._name)
[docs] def shape(self) -> Bosl2Solid: """Build and return the polyhedron geometry (cached).""" if self._solid is not None: return self._solid verts, faces, ratio = _SOLIDS[self._name] if self._side is not None: scale = self._side * ratio elif self._diameter is not None: scale = self._diameter / 2 elif self._inner_radius is not None: scale = self._inner_radius / _inradius_ratio(self._name) elif self._radius is not None: scale = self._radius else: scale = 1.0 sv = [[x * scale, y * scale, z * scale] for x, y, z in verts] solid = VNF(sv, faces).polyhedron() self._solid = Bosl2Solid(solid, size=[2 * scale, 2 * scale, 2 * scale]) return self._solid
[docs] def show(self) -> None: """Display the polyhedron in the viewer.""" self.shape().show()
[docs] @classmethod def tetrahedron( cls, radius: float | None = None, diameter: float | None = None, inner_radius: float | None = None, side: float | None = None, ) -> RegularPolyhedron: """Return a regular tetrahedron (4 triangular faces). Args: radius: Circumscribing sphere radius. diameter: Circumscribing sphere diameter. inner_radius: Insphere radius (distance from centre to mid-face). side: Edge length. Returns: A :class:`RegularPolyhedron` configured as a tetrahedron. """ return cls(PlatonicSolid.TETRAHEDRON, radius=radius, diameter=diameter, inner_radius=inner_radius, side=side)
[docs] @classmethod def cube( cls, radius: float | None = None, diameter: float | None = None, inner_radius: float | None = None, side: float | None = None, ) -> RegularPolyhedron: """Return a cube / regular hexahedron (6 square faces). Args: radius: Circumscribing sphere radius. diameter: Circumscribing sphere diameter. inner_radius: Insphere radius (distance from centre to mid-face). side: Edge length. Returns: A :class:`RegularPolyhedron` configured as a cube. """ return cls(PlatonicSolid.CUBE, radius=radius, diameter=diameter, inner_radius=inner_radius, side=side)
[docs] @classmethod def octahedron( cls, radius: float | None = None, diameter: float | None = None, inner_radius: float | None = None, side: float | None = None, ) -> RegularPolyhedron: """Return a regular octahedron (8 triangular faces). Args: radius: Circumscribing sphere radius. diameter: Circumscribing sphere diameter. inner_radius: Insphere radius (distance from centre to mid-face). side: Edge length. Returns: A :class:`RegularPolyhedron` configured as an octahedron. """ return cls(PlatonicSolid.OCTAHEDRON, radius=radius, diameter=diameter, inner_radius=inner_radius, side=side)
[docs] @classmethod def dodecahedron( cls, radius: float | None = None, diameter: float | None = None, inner_radius: float | None = None, side: float | None = None, ) -> RegularPolyhedron: """Return a regular dodecahedron (12 pentagonal faces). Args: radius: Circumscribing sphere radius. diameter: Circumscribing sphere diameter. inner_radius: Insphere radius (distance from centre to mid-face). side: Edge length. Returns: A :class:`RegularPolyhedron` configured as a dodecahedron. """ return cls(PlatonicSolid.DODECAHEDRON, radius=radius, diameter=diameter, inner_radius=inner_radius, side=side)
[docs] @classmethod def icosahedron( cls, radius: float | None = None, diameter: float | None = None, inner_radius: float | None = None, side: float | None = None, ) -> RegularPolyhedron: """Return a regular icosahedron (20 triangular faces). Args: radius: Circumscribing sphere radius. diameter: Circumscribing sphere diameter. inner_radius: Insphere radius (distance from centre to mid-face). side: Edge length. Returns: A :class:`RegularPolyhedron` configured as an icosahedron. Examples: .. pythonscad-example:: from pybosl2.parts.polyhedra import RegularPolyhedron RegularPolyhedron.icosahedron(side=20).show() """ return cls(PlatonicSolid.ICOSAHEDRON, radius=radius, diameter=diameter, inner_radius=inner_radius, side=side)