Source code for pybosl2.textures

# 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

"""The texture() named-texture engine (BOSL2 skin.scad)."""

# LibFile: pybosl2/textures.py
#    Port of BOSL2's texture() engine from skin.scad: the named-texture table that
#    :func:`~pybosl2.shapes3d.textured_tile` (and, in BOSL2, the textured sweeps) build from.
#    :func:`texture` resolves a texture *name* to its data -- either a height-field (a 2-D array of
#    heights in ``[0, 1]``) or a VNF tile ``(verts, faces)`` describing one unit cell of the surface.
#
#    All of BOSL2's height-field and VNF-tile textures are ported (9 height-field + 12 VNF), including
#    the ``$fn``-parametric ``cones``/``dots``/``hex_grid`` (pass *fn* for their resolution). A few VNF
#    tiles whose exact geometry can't be tiled watertight by :func:`vnf_tile_to_solid` (``bricks_vnf``,
#    ``checkers``, ``trunc_diamonds`` -- pinch points / interior holes) fall back to a sampled
#    height-field (:func:`rasterize_vnf_texture`), which flattens their vertical faces slightly. The
#    ``bricks``/``rough`` textures use this package's RNG (cosmetic difference), and ``cones`` defaults
#    to a small positive ``border`` (BOSL2 uses 0) so its tile seams watertight.
#
# FileSummary: The texture() named-texture engine.
# DocCategory: Foundational
# FileGroup: BOSL2

from __future__ import annotations

import math
from enum import Enum
from typing import TYPE_CHECKING, Any, TypeAlias, cast

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

    from pybosl2.vnf import VNF

import numpy as np

from pybosl2._helpers import quantup as _quantup_float
from pybosl2.exceptions import Bosl2ValueError

#: A built texture: a height field (rows of heights in 0..1) or a VNF tile as a
#: ``(vertices, faces)`` pair. `texture()` returns one; every consumer takes either (SPEC S-34).
TextureData: TypeAlias = "list[list[float]] | tuple[list[list[float]], list[list[int]]]"

__all__ = [
    "TEXTURES",
    "TextureData",
    "TextureType",
    "default_tex_reps",
    "height_field",
    "is_heightfield_texture",
    "is_vnf_texture",
    "texture",
    "texture_grid",
    "textured_cylinder_vnf",
]


# --- small helpers mirroring the BOSL2 list utilities texture() uses ----------


def _lerpn(a: float, b: float, sides: int, endpoint: bool = True) -> list[float]:
    """*sides* evenly spaced values from *a* to *b* (BOSL2 lerpn); *endpoint* includes *b*."""
    sides = int(sides)
    if sides <= 0:
        return []
    if endpoint:
        return list(np.linspace(a, b, sides))
    return [a + (b - a) * i / sides for i in range(sides)]


def quantup(x: float, m: int) -> int:
    """Round *x* up to the next multiple of *m* (BOSL2 quantup).

    Args:
        x: The X coordinate.
        m: The height field.

    """
    return int(_quantup_float(x, m))


def _sel(lst: list[float], i: int) -> float:
    """BOSL2 select(): index with wraparound."""
    return lst[i % len(lst)]


def _rands(lo: float, hi: float, sides: int, seed: int) -> list[float]:
    """*sides* uniform randoms in ``[lo, hi]`` (BOSL2 rands; this port's RNG, so values differ)."""
    return list(np.random.default_rng(seed).uniform(lo, hi, sides))


# --- height-field textures (return a rows x cols array of heights in [0,1]) ---


def _tex_ribs(sides: int | None = None, **_: object) -> list[list[float]]:
    sides = quantup(sides if sides is not None else 2, 2)
    return [_lerpn(1, 0, sides // 2, False) + _lerpn(0, 1, sides // 2, False)]


def _tex_trunc_ribs(sides: int | None = None, **_: object) -> list[list[float]]:
    sides = quantup(sides if sides is not None else 4, 4)
    q = sides // 4
    return [[0.0] * q + _lerpn(0, 1, q, False) + [1.0] * q + _lerpn(1, 0, q, False)]


def _tex_wave_ribs(sides: int | None = None, **_: object) -> list[list[float]]:
    sides = max(6, (sides if sides is not None else 8))
    return [[(math.cos(math.radians(a)) + 1) / 2 for a in np.arange(0, 360 - 1e-9, 360 / sides)]]


def _tex_diamonds(sides: int | None = None, **_: object) -> list[list[float]]:
    sides = quantup(sides if sides is not None else 2, 2)
    path = _lerpn(0, 1, sides // 2, False) + _lerpn(1, 0, sides // 2, False)
    return [[min(_sel(path, i + j), _sel(path, i - j)) for j in range(sides)] for i in range(sides)]


def _tex_pyramids(sides: int | None = None, **_: object) -> list[list[float]]:
    sides = quantup(sides if sides is not None else 2, 2)
    return [
        [1 - (max(abs(i - sides / 2), abs(j - sides / 2)) / (sides / 2)) for j in range(sides)] for i in range(sides)
    ]


def _tex_trunc_pyramids(sides: int | None = None, **_: object) -> list[list[float]]:
    sides = quantup(sides if sides is not None else 6, 3)
    return [
        [(1 - (max(sides / 6, abs(i - sides / 2), abs(j - sides / 2)) / (sides / 2))) * 1.5 for j in range(sides)]
        for i in range(sides)
    ]


def _tex_hills(sides: int | None = None, **_: object) -> list[list[float]]:
    sides = sides if sides is not None else 12
    angs = list(np.arange(0, 359.999, 360 / sides))
    return [[(math.cos(math.radians(a)) * math.cos(math.radians(b)) + 1) / 2 for b in angs] for a in angs]


def _tex_bricks(sides: int | None = None, roughness: float | None = None, **_: object) -> list[list[float]]:
    sides = quantup(sides if sides is not None else 24, 2)
    rough = roughness if roughness is not None else 0.1
    thin = max(1, sides / 16)
    out = []
    for y in range(sides):
        rand = _rands(1 - rough, 1, sides, 12345 + y * 678)
        row = []
        for x in range(sides):
            if y % (sides // 2) <= thin:
                row.append(0.0)
            else:
                even = sides // 2 if (y // (sides // 2)) % 2 else 0
                row.append(0.0 if (x + even) % sides <= thin else rand[x])
        out.append(row)
    return out


def _tex_rough(sides: int | None = None, **_: object) -> list[list[float]]:
    sides = sides if sides is not None else 32
    return [_rands(0, 1, sides, 123456 + 29 * y) for y in range(sides)]


# --- VNF-tile textures (return (verts, faces); one unit cell over [0,1]x[0,1]) ---


def _sq(s: float, z: float = 0.0) -> list[list[float]]:
    """path3d of a square of side *s* anchored at the origin, at height *z* (BOSL2 square())."""
    return [[0.0, 0.0, z], [s, 0.0, z], [s, s, z], [0.0, s, z]]


def _rect(w: float, height: float, z: float = 0.0) -> list[list[float]]:
    """path3d of a *w* x *height* rectangle centred at the origin, at height *z* (BOSL2 rect())."""
    return [
        [-w / 2, -height / 2, z],
        [w / 2, -height / 2, z],
        [w / 2, height / 2, z],
        [-w / 2, height / 2, z],
    ]


def _mv(off: list[float], pts: list[list[float]]) -> list[list[float]]:
    o = list(off) + [0.0] * (3 - len(off))
    return [[p[0] + o[0], p[1] + o[1], p[2] + o[2]] for p in pts]


def _sqr(size: float | list[float], z: float = 0.0) -> list[list[float]]:
    """path3d of a square/rect anchored at the origin (BOSL2 square(), scalar or ``[w, height]``)."""
    w, height = (size, size) if isinstance(size, (int, float)) else (size[0], size[1])
    return [[0.0, 0.0, z], [w, 0.0, z], [w, height, z], [0.0, height, z]]


def _zrot2(pts: list[list[float]], deg: float) -> list[list[float]]:
    """Rotate points about Z by *deg* degrees, preserving z (BOSL2 zrot())."""
    c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
    return [[c * p[0] - s * p[1], s * p[0] + c * p[1], (p[2] if len(p) > 2 else 0.0)] for p in pts]


def _tex_diamonds_vnf(**_: object) -> tuple[list[list[float]], list[list[int]]]:
    verts: list[list[float]] = [
        [0, 1, 1],
        [0.5, 1, 0],
        [1, 1, 1],
        [0, 0.5, 0],
        [0.5, 0.5, 1],
        [1, 0.5, 0],
        [0, 0, 1],
        [0.5, 0, 0],
        [1, 0, 1],
    ]
    faces = [
        [0, 1, 3],
        [2, 5, 1],
        [8, 7, 5],
        [6, 3, 7],
        [1, 5, 4],
        [5, 7, 4],
        [7, 3, 4],
        [4, 3, 1],
    ]
    return verts, faces


def _tex_pyramids_vnf(**_: object) -> tuple[list[list[float]], list[list[int]]]:
    verts: list[list[float]] = [[0, 1, 0], [1, 1, 0], [0.5, 0.5, 1], [0, 0, 0], [1, 0, 0]]
    faces = [[2, 0, 1], [2, 1, 4], [2, 4, 3], [2, 3, 0]]
    return verts, faces


def _tex_trunc_pyramids_vnf(border: float | None = None, **_: object) -> tuple[list[list[float]], list[list[int]]]:
    b = border if border is not None else 0.1
    if not (0 < b < 0.5):
        raise Bosl2ValueError("trunc_pyramids_vnf texture requires border in (0, 0.5).")
    verts = _sq(1) + _mv([0.5, 0.5, 1], _rect(1 - 2 * b, 1 - 2 * b))
    faces = [[i, (i + 1) % 4, (i + 1) % 4 + 4, i + 4] for i in range(4)] + [[4, 5, 6, 7]]
    return verts, faces


def _tex_cubes_vnf(**_: object) -> tuple[list[list[float]], list[list[int]]]:
    verts = [
        [0, 1, 0.5],
        [1, 1, 0.5],
        [0.5, 5 / 6, 1],
        [0, 4 / 6, 0],
        [1, 4 / 6, 0],
        [0.5, 3 / 6, 0.5],
        [0, 2 / 6, 1],
        [1, 2 / 6, 1],
        [0.5, 1 / 6, 0],
        [0, 0, 0.5],
        [1, 0, 0.5],
    ]
    faces = [
        [0, 1, 2],
        [0, 2, 3],
        [1, 4, 2],
        [2, 5, 3],
        [2, 4, 5],
        [6, 3, 5],
        [4, 7, 5],
        [7, 8, 5],
        [6, 5, 8],
        [10, 8, 7],
        [9, 6, 8],
        [10, 9, 8],
    ]
    return verts, faces


def _tex_trunc_ribs_vnf(
    border: float | None = None, gap: float | None = None, **_: object
) -> tuple[list[list[float]], list[list[int]]]:
    b = (border if border is not None else 0.25) * 2
    g = gap if gap is not None else 0.25
    if not (b >= 0):
        raise Bosl2ValueError("trunc_ribs_vnf requires gap>=0 and border>=0.")
    if not (g >= 0):
        raise Bosl2ValueError("trunc_ribs_vnf requires gap>=0 and border>=0.")
    if not (g + b <= 1):
        raise Bosl2ValueError("trunc_ribs_vnf requires 2*border+gap <= 1.")
    verts = _mv([0.5, 0.5], _rect(1 - g, 1, 0)) + _mv([0.5, 0.5], _rect(1 - g - b, 1, 1)) + _sq(1)
    faces = [[4, 7, 3, 0], [1, 2, 6, 5]]
    if g + b < 1 - 1e-9:
        faces.append([4, 5, 6, 7])
    if g > 1e-9:
        faces += [[1, 9, 10, 2], [0, 3, 11, 8]]
    return verts, faces


def _tex_bricks_vnf(
    border: float | None = None, gap: float | None = None, **_: object
) -> tuple[list[list[float]], list[list[int]]]:
    b = border if border is not None else 0.05
    g = gap if gap is not None else 0.05
    if not (b >= 0):
        raise Bosl2ValueError("bricks_vnf requires border>=0, gap>0, gap+border<0.5.")
    if not (g > 0):
        raise Bosl2ValueError("bricks_vnf requires border>=0, gap>0, gap+border<0.5.")
    if not (g + b < 0.5):
        raise Bosl2ValueError("bricks_vnf requires border>=0, gap>0, gap+border<0.5.")
    verts = (
        _sqr(1)
        + _mv([g / 2, g / 2, 0], _sqr([1 - g, 0.5 - g]))
        + _mv([g / 2 + b / 2, g / 2 + b / 2, 1], _sqr([1 - g - b, 0.5 - g - b]))
        + _mv([0, 0.5 + g / 2, 0], _sqr([0.5 - g / 2, 0.5 - g]))
        + _mv([0, 0.5 + g / 2 + b / 2, 1], _sqr([0.5 - g / 2 - b / 2, 0.5 - g - b]))
        + _mv([0.5 + g / 2, 0.5 + g / 2, 0], _sqr([0.5 - g / 2, 0.5 - g]))
        + _mv(
            [0.5 + g / 2 + b / 2, 0.5 + g / 2 + b / 2, 1],
            _sqr([0.5 - g / 2 - b / 2, 0.5 - g - b]),
        )
    )
    faces = [
        [0, 4, 7, 20],
        [4, 8, 11, 7],
        [9, 8, 4, 5],
        [4, 0, 1, 5],
        [10, 9, 5, 6],
        [20, 7, 6, 13, 12, 21],
        [2, 3, 23, 22, 15, 14],
        [15, 19, 18, 14],
        [22, 23, 27, 26],
        [16, 19, 15, 12],
        [13, 6, 5, 1],
        [26, 25, 21, 22],
        [8, 9, 10, 11],
        [7, 11, 10, 6],
        [17, 16, 12, 13],
        [22, 21, 12, 15],
        [16, 17, 18, 19],
        [24, 25, 26, 27],
        [25, 24, 20, 21],
    ]
    return verts, faces


def _tex_checkers_vnf(border: float | None = None, **_: object) -> tuple[list[list[float]], list[list[int]]]:
    b = border if border is not None else 0.05
    if not (0 < b < 0.5):
        raise Bosl2ValueError("checkers texture requires border in (0, 0.5).")
    verts = (
        _mv([0, 0], _sqr(0.5 - b, 1))
        + _mv([0, 0.5], _sqr(0.5 - b))
        + _mv([0.5, 0], _sqr(0.5 - b))
        + _mv([0.5, 0.5], _sqr(0.5 - b, 1))
        + [
            [0.5 - b / 2, 0.5 - b / 2, 0.5],
            [0, 1, 1],
            [0.5 - b, 1, 1],
            [0.5, 1, 0],
            [1 - b, 1, 0],
            [1, 0, 1],
            [1, 0.5 - b, 1],
            [1, 0.5, 0],
            [1, 1 - b, 0],
            [1, 1, 1],
            [0.5 - b / 2, 1 - b / 2, 0.5],
            [1 - b / 2, 1 - b / 2, 0.5],
            [1 - b / 2, 0.5 - b / 2, 0.5],
        ]
    )
    faces = [[i, i + 1, i + 2, i + 3] for i in (0, 4, 8, 12)] + [
        [10, 16, 13, 12, 28, 11],
        [9, 0, 3, 16, 10],
        [11, 28, 22, 21, 8],
        [4, 7, 26, 14, 13, 16],
        [7, 6, 17, 18, 26],
        [5, 4, 16, 3, 2],
        [19, 20, 27, 15, 14, 26],
        [20, 25, 27],
        [19, 26, 18],
        [23, 28, 12, 15, 27, 24],
        [23, 22, 28],
        [24, 27, 25],
    ]
    return verts, faces


def _tex_trunc_diamonds_vnf(border: float | None = None, **_: object) -> tuple[list[list[float]], list[list[int]]]:
    b = (border if border is not None else 0.1) / math.sqrt(2) * 2
    if not (0 < b < 0.5):
        raise Bosl2ValueError("trunc_diamonds texture requires border in (0, 0.5/sqrt(2)).")
    diameter1 = [[p[0], p[1], 0.0] for p in _circle_xy(1, 4)]
    diameter2 = [[p[0], p[1], 0.0] for p in _circle_xy(1 - b * 2, 4)]
    verts = _mv([0.5, 0.5, 0], diameter1) + _mv([0.5, 0.5, 1], diameter2)
    for a in (0, 90, 180, 270):
        verts += _mv([0.5, 0.5], _zrot2([[0.5, b, 1], [b, 0.5, 1], [0.5, 0.5, 1]], -a))
    faces = []
    for i in range(4):
        j = i * 3 + 8
        faces += [
            [i, (i + 1) % 4, (i + 1) % 4 + 4, i + 4],
            [j, j + 1, j + 2],
            [i, (i + 3) % 4, j + 1, j],
        ]
    faces.append([4, 5, 6, 7])
    return verts, faces


def _tex_tri_grid_vnf(border: float | None = None, **_: object) -> tuple[list[list[float]], list[list[int]]]:
    b = (border if border is not None else 0.05) * math.sqrt(3)
    if not (0 < b < math.sqrt(3) / 6):
        raise Bosl2ValueError("tri_grid texture requires border in (0, 1/6).")
    adj = b / math.tan(math.radians(30))  # opp_ang_to_adj(border, 30)
    y1 = b / math.tan(math.radians(60))  # border / adj_ang_to_opp(1, 60)
    y2, y3, y4, y5, y6 = 2 * y1, 0.5 - y1, 0.5 + y1, 1 - 2 * y1, 1 - y1
    verts: list[list[float]] = [
        [0, 0, 0],
        [1, 0, 0],
        [adj, y1, 1],
        [1 - adj, y1, 1],
        [0, y2, 1],
        [1, y2, 1],
        [0.5, 0.5 - y2, 1],
        [0, y3, 1],
        [0.5 - adj, y3, 1],
        [0.5 + adj, y3, 1],
        [1, y3, 1],
        [0, 0.5, 0],
        [0.5, 0.5, 0],
        [1, 0.5, 0],
        [0, y4, 1],
        [0.5 - adj, y4, 1],
        [0.5 + adj, y4, 1],
        [1, y4, 1],
        [0.5, 0.5 + y2, 1],
        [0, y5, 1],
        [1, y5, 1],
        [adj, y6, 1],
        [1 - adj, y6, 1],
        [0, 1, 0],
        [1, 1, 0],
    ]
    faces = [
        [0, 2, 3, 1],
        [21, 23, 24, 22],
        [2, 6, 3],
        [0, 12, 6, 2],
        [1, 3, 6, 12],
        [0, 4, 8, 12],
        [4, 7, 8],
        [8, 7, 11, 12],
        [1, 12, 9, 5],
        [5, 9, 10],
        [10, 9, 12, 13],
        [11, 14, 15, 12],
        [19, 15, 14],
        [19, 23, 12, 15],
        [16, 17, 13, 12],
        [16, 20, 17],
        [12, 24, 20, 16],
        [21, 22, 18],
        [12, 23, 21, 18],
        [12, 18, 22, 24],
    ]
    return verts, faces


_TEX_FN_DEFAULT = 16  # BOSL2 _tex_fn_default()


def _circle_xy(d: float, n: int) -> list[list[float]]:
    """Generate *n* points of a circle of diameter *d* centred at the origin, starting east.

    BOSL2 circle().
    """
    return [
        [
            d / 2 * math.cos(math.radians(360 * i / n)),
            d / 2 * math.sin(math.radians(360 * i / n)),
        ]
        for i in range(n)
    ]


def _square_pts(border: float) -> list[list[float]]:
    """Generate the tile base: the unit square, subdivided by *border*.

    Subdivided to 8 points if *border*>0 else its 4 corners.
    """
    if border > 0:  # subdivide_path(square(1), refine=2)
        return [[0, 0], [0.5, 0], [1, 0], [1, 0.5], [1, 1], [0.5, 1], [0, 1], [0, 0.5]]
    return [[0, 0], [1, 0], [1, 1], [0, 1]]


def _sph(r: float, theta: float, phi: float) -> list[float]:
    """BOSL2 spherical_to_xyz(r, theta, phi)."""
    t, p = math.radians(theta), math.radians(phi)
    return [
        r * math.cos(t) * math.sin(p),
        r * math.sin(t) * math.sin(p),
        r * math.cos(p),
    ]


def _base_faces(n: int, base0: int, border: float) -> list[list[int]]:
    """Generate the faces joining a quarter of the *n*-point rim to each base-square region.

    *base0* is the index of the first base-square vertex (BOSL2's cones/dots base connection).
    """
    out = []
    for i in range(4):
        arc = [j % n for j in range((i + 1) * n // 4, i * n // 4 - 1, -1)]
        if border > 0:
            out.append(
                arc
                + [
                    (2 * i + 7) % 8 + base0,
                    (2 * i) % 8 + base0,
                    (2 * i + 1) % 8 + base0,
                ]
            )
        else:
            out.append(arc + [i + base0])
    return out


def _tex_cones_vnf(
    fn: int | None = None, border: float | None = None, **_: object
) -> tuple[list[list[float]], list[list[int]]]:
    # BOSL2 defaults border=0, but a zero border leaves the tile's rim on the cell edge, which this
    # port's weld-and-close tiler can't seam watertight -- so default to a small positive border.
    b = border if border is not None else 0.05
    sides = quantup(fn, 4) if fn else _TEX_FN_DEFAULT
    if not (0 < b < 0.5):
        raise Bosl2ValueError("this port's cones texture requires border in (0, 0.5).")
    rim = [[0.5 + x, 0.5 + y, 0.0] for x, y in _circle_xy(1 - 2 * b, sides)]
    verts = rim + [[0.5, 0.5, 1.0]] + [[x, y, 0.0] for x, y in _square_pts(b)]
    faces = [[i, (i + 1) % sides, sides] for i in range(sides)] + _base_faces(sides, sides + 1, b)
    return verts, faces


def _tex_dots_vnf(
    fn: int | None = None, border: float | None = None, **_: object
) -> tuple[list[list[float]], list[list[int]]]:
    b = border if border is not None else 0.05
    sides = quantup(fn, 4) if fn else _TEX_FN_DEFAULT
    if not (0 <= b < 0.5):
        raise Bosl2ValueError("dots texture requires border in [0, 0.5).")
    rows = math.ceil(sides / 4)
    radius = (0.5 - b) / math.cos(math.radians(45))  # adj_ang_to_hyp(0.5-border, 45)
    cpz = -radius * math.sin(math.radians(45))
    sc = 1 / (radius - abs(cpz))
    uv = []
    for p in range(rows):
        phi = 45 - 45 * p / rows
        for ti in range(sides):
            s = _sph(radius, -360 * ti / sides, phi)
            uv.append([0.5 + s[0], 0.5 + s[1], cpz + s[2]])
    uv.append([0.5, 0.5, cpz + radius])  # dome apex
    uv += [[x, y, 0.0] for x, y in _square_pts(b)]
    verts = [[v[0], v[1], v[2] * sc] for v in uv]  # zscale(sc)
    faces = []
    for i in range(rows - 1):
        for j in range(sides):
            faces.append(
                [
                    i * sides + j,
                    i * sides + (j + 1) % sides,
                    (i + 1) * sides + (j + 1) % sides,
                    (i + 1) * sides + j,
                ]
            )
    for i in range(sides):
        faces.append([(rows - 1) * sides + i, (rows - 1) * sides + (i + 1) % sides, rows * sides])
    faces += _base_faces(sides, rows * sides + 1, b)
    return verts, faces


def _tex_hex_grid_vnf(border: float | None = None, **_: object) -> tuple[list[list[float]], list[list[int]]]:
    b = border if border is not None else 0.1
    if not (0 < b < 0.5):
        raise Bosl2ValueError("hex_grid texture requires border in (0, 0.5).")
    diag = b / math.sin(math.radians(60))  # opp_ang_to_hyp(border, 60)
    hyp = 0.5 / math.cos(math.radians(30))  # adj_ang_to_hyp(0.5, 30)
    sc = 1 / 3 / hyp
    hex_ = [
        [1, 2 / 6, 0],
        [0.5, 1 / 6, 0],
        [0, 2 / 6, 0],
        [0, 4 / 6, 0],
        [0.5, 5 / 6, 0],
        [1, 4 / 6, 0],
    ]
    vertex_radius = (0.5 - b) / math.cos(math.radians(30))
    top = [
        [
            0.5 + vertex_radius * math.cos(math.radians(-30 + 60 * i)),
            0.5 + vertex_radius * math.sin(math.radians(-30 + 60 * i)) * sc,
            1.0,
        ]
        for i in range(6)
    ]

    def cyl(rad: float, angle: float) -> list[float]:  # yscale(sc, cylindrical_to_xyz(rad, angle, 1))
        return [
            rad * math.cos(math.radians(angle)),
            rad * math.sin(math.radians(angle)) * sc,
            1.0,
        ]

    def add(a: list[float], b3: list[float]) -> list[float]:
        return [a[0] + b3[0], a[1] + b3[1], a[2] + b3[2]]

    verts = (
        [list(height) for height in hex_]
        + top
        + [
            add(hex_[0], [0, -diag * sc, 1]),
            add(hex_[1], cyl(diag, 270 + 60)),
            add(hex_[1], cyl(diag, 270 - 60)),
            add(hex_[2], [0, -diag * sc, 1]),
            [0, 0, 1],
            [0.5 - b, 0, 1],
            [0.5, 0, 0],
            [0.5 + b, 0, 1],
            [1, 0, 1],
            add(hex_[3], [0, diag * sc, 1]),
            add(hex_[4], cyl(diag, 90 + 60)),
            add(hex_[4], cyl(diag, 90 - 60)),
            add(hex_[5], [0, diag * sc, 1]),
            [0, 1, 1],
            [0.5 - b, 1, 1],
            [0.5, 1, 0],
            [0.5 + b, 1, 1],
            [1, 1, 1],
        ]
    )
    faces = [[6, 7, 8, 9, 10, 11]]
    faces += [[i, (i + 1) % 6, (i + 1) % 6 + 6, i + 6] for i in range(6)]
    faces += [
        [20, 19, 13, 12],
        [17, 16, 15, 14],
        [21, 25, 26, 22],
        [23, 28, 29, 24],
        [0, 12, 13, 1],
        [1, 14, 15, 2],
        [3, 21, 22, 4],
        [4, 23, 24, 5],
        [1, 13, 19, 18],
        [1, 18, 17, 14],
        [4, 22, 26, 27],
        [4, 27, 28, 23],
    ]
    return verts, faces


# name -> (builder, kind) where kind is "heightfield" or "vnf"
TEXTURES: dict[str, tuple[Callable[..., list[list[float]] | tuple[list[list[float]], list[list[int]]]], str]] = {
    "ribs": (_tex_ribs, "heightfield"),
    "trunc_ribs": (_tex_trunc_ribs, "heightfield"),
    "wave_ribs": (_tex_wave_ribs, "heightfield"),
    "diamonds": (_tex_diamonds, "heightfield"),
    "pyramids": (_tex_pyramids, "heightfield"),
    "trunc_pyramids": (_tex_trunc_pyramids, "heightfield"),
    "hills": (_tex_hills, "heightfield"),
    "bricks": (_tex_bricks, "heightfield"),
    "rough": (_tex_rough, "heightfield"),
    "diamonds_vnf": (_tex_diamonds_vnf, "vnf"),
    "pyramids_vnf": (_tex_pyramids_vnf, "vnf"),
    "trunc_pyramids_vnf": (_tex_trunc_pyramids_vnf, "vnf"),
    "cubes": (_tex_cubes_vnf, "vnf"),
    "trunc_ribs_vnf": (_tex_trunc_ribs_vnf, "vnf"),
    "cones": (_tex_cones_vnf, "vnf"),
    "dots": (_tex_dots_vnf, "vnf"),
    "hex_grid": (_tex_hex_grid_vnf, "vnf"),
    "bricks_vnf": (_tex_bricks_vnf, "vnf"),
    "checkers": (_tex_checkers_vnf, "vnf"),
    "trunc_diamonds": (_tex_trunc_diamonds_vnf, "vnf"),
    "tri_grid": (_tex_tri_grid_vnf, "vnf"),
}


class TextureType(Enum):
    """Enumeration of named texture types."""

    RIBS = "ribs"
    TRUNC_RIBS = "trunc_ribs"
    WAVE_RIBS = "wave_ribs"
    DIAMONDS = "diamonds"
    PYRAMIDS = "pyramids"
    TRUNC_PYRAMIDS = "trunc_pyramids"
    HILLS = "hills"
    BRICKS = "bricks"
    ROUGH = "rough"
    DIAMONDS_VNF = "diamonds_vnf"
    PYRAMIDS_VNF = "pyramids_vnf"
    TRUNC_PYRAMIDS_VNF = "trunc_pyramids_vnf"
    CUBES = "cubes"
    TRUNC_RIBS_VNF = "trunc_ribs_vnf"
    CONES = "cones"
    DOTS = "dots"
    HEX_GRID = "hex_grid"
    BRICKS_VNF = "bricks_vnf"
    CHECKERS = "checkers"
    TRUNC_DIAMONDS = "trunc_diamonds"
    TRI_GRID = "tri_grid"


def texture(
    tex: str | TextureType,
    sides: int | None = None,
    border: float | None = None,
    gap: float | None = None,
    roughness: float | None = None,
    inset: float | None = None,
    fn: int | None = None,
) -> list[list[float]] | tuple[list[list[float]], list[list[int]]]:
    """Look up the named texture *tex*.

    Returns a height-field array or a VNF tile ``(verts, faces)``.

    BOSL2 texture(). *sides* sets the resolution of the parametric height-field textures;
    *border*/*gap* shape the VNF-tile textures; *roughness* perturbs ``bricks``. Pass a name
    from :data:`TEXTURES`. See the module docstring for which textures are ported.

    Returns:
    -------
         list[list[float]]:
            The named texture *tex* -- a height-field array or a VNF tile ``(verts, faces)`` (BOSL2 texture()).

    Raises:
    ------
        ValueError: If the texture name is not found or if both 'border' and 'inset' are provided.

    See Also:
    --------
        pybosl2.paths.Path2D.texture()
        pybosl2.paths.Path2D.texture_v()
        pybosl2.shapes3d.Bosl2Solid.texture()
        pybosl2.shapes3d.Bosl2Solid.texture_v()

    Args:
        tex: The texture name to look up, or an already-built texture to pass through.
        sides: Number of sides for the textures that have a polygon count.
        border: Flat border left around each tile.
        gap: Gap left between tiles.
        roughness: Amplitude of the random variation, for the rough textures.
        inset: How far the pattern is inset into the surface.
        fn: Fixed fragment count for curved surfaces. Omitted, the ambient ``use_defaults(fn=...)`` value applies;
            ``fn=0`` opts back out to fa/fs.

    """
    if inset is not None and border is not None:
        raise Bosl2ValueError("texture(): give 'border' or 'inset', not both.")
    if inset is not None:
        border = inset
    key = tex.value if isinstance(tex, TextureType) else tex
    if key not in TEXTURES:
        raise Bosl2ValueError(f"Unrecognized (or unported) texture name: {key!r}; available: {sorted(TEXTURES)}")
    builder, _kind = TEXTURES[key]
    return builder(sides=sides, border=border, gap=gap, roughness=roughness, fn=fn)


def _weld(
    verts: list[list[float]], faces: list[list[int]], tol: float = 1e-6
) -> tuple[list[list[float]], list[list[int]]]:
    """Merge coincident vertices (so tiled cells stitch along shared edges); drop degenerate faces."""
    idx: dict[tuple[int, int, int], int] = {}
    new_verts: list[list[float]] = []
    remap: list[int] = []
    for p in verts:
        k = (round(p[0] / tol), round(p[1] / tol), round(p[2] / tol))
        if k not in idx:
            idx[k] = len(new_verts)
            new_verts.append([float(p[0]), float(p[1]), float(p[2])])
        remap.append(idx[k])
    new_faces = [[remap[i] for i in f] for f in faces]
    new_faces = [f for f in new_faces if len({*f}) >= 3]
    return new_verts, new_faces


def _close_to_base(
    verts: list[list[float]], faces: list[list[int]], bottom: float
) -> tuple[list[list[float]], list[list[int]]]:
    """Close an open (top-only) surface into a solid.

    Drops boundary loops to z=*bottom* with side walls and a flat bottom cap.
    """
    verts = [list(p) for p in verts]
    faces = [list(f) for f in faces]
    halfedges = set()
    for f in faces:
        for i in range(len(f)):
            halfedges.add((f[i], f[(i + 1) % len(f)]))
    nxt = {a: b for (a, b) in halfedges if (b, a) not in halfedges}  # boundary half-edges, directed
    visited = set()
    for start in list(nxt):
        if start in visited:
            continue
        loop, a = [], start
        while a in nxt and a not in visited:
            visited.add(a)
            loop.append(a)
            a = nxt[a]
        if len(loop) < 3:
            continue
        base = {}
        for vi in loop:
            base[vi] = len(verts)
            verts.append([verts[vi][0], verts[vi][1], bottom])
        nloop = len(loop)
        for k in range(nloop):  # side walls
            a, b = loop[k], loop[(k + 1) % nloop]
            faces.append([a, b, base[b], base[a]])
        bl = [base[vi] for vi in loop]  # bottom cap (fan, faces down)
        for k in range(1, nloop - 1):
            faces.append([bl[0], bl[k + 1], bl[k]])
    return verts, faces


def rasterize_vnf_texture(tile: "VNF", sides: int = 24) -> list[list[float]]:
    """Sample a VNF texture tile's top surface to an *sides* x *sides* height-field over ``[0,1]x[0,1]``.

    A robust fallback for VNF tiles whose exact geometry can't be tiled watertight (pinch points,
    interior holes): the top (max-z) surface is captured; overhangs/undercuts are flattened.

    Args:
        tile: The texture tile as a :class:`~pybosl2.vnf.VNF` (SPEC C-8).
        sides: Samples per axis.

    Returns:
        An *sides* x *sides* height-field.

    """
    verts, faces = tile.vertices, tile.faces
    verts_arr = np.asarray([[float(p[0]), float(p[1]), float(p[2])] for p in verts])
    tris = [[f[0], f[k], f[k + 1]] for f in faces for k in range(1, len(f) - 1)]
    tris_arr = np.asarray(tris)
    tri_a, tri_b, tri_c = verts_arr[tris_arr[:, 0]], verts_arr[tris_arr[:, 1]], verts_arr[tris_arr[:, 2]]
    ax, ay = tri_a[:, 0], tri_a[:, 1]
    bx, by = tri_b[:, 0], tri_b[:, 1]
    cx, cy = tri_c[:, 0], tri_c[:, 1]
    den = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
    den = np.where(np.abs(den) < 1e-12, 1e-12, den)
    heightmap = [[0.0] * sides for _ in range(sides)]
    for gy in range(sides):
        vy = (gy + 0.5) / sides
        for gx in range(sides):
            ux = (gx + 0.5) / sides
            l1 = ((by - cy) * (ux - cx) + (cx - bx) * (vy - cy)) / den
            l2 = ((cy - ay) * (ux - cx) + (ax - cx) * (vy - cy)) / den
            l3 = 1 - l1 - l2
            inside = (l1 >= -1e-9) & (l2 >= -1e-9) & (l3 >= -1e-9)
            if inside.any():
                z = l1 * tri_a[:, 2] + l2 * tri_b[:, 2] + l3 * tri_c[:, 2]
                heightmap[gy][gx] = float(z[inside].max())
    return heightmap


[docs] def vnf_tile_to_solid( tile: "VNF", size: Sequence[float], reps: Sequence[int], tex_depth: float = 1.0, inset: float = 0.0, ) -> "VNF": """Tile a VNF texture cell over a *size* ``[x, y]`` rectangle and close into a watertight solid. Args: tile: The texture cell as a :class:`~pybosl2.vnf.VNF` (SPEC C-8). size: The ``[x, y]`` rectangle to cover. reps: Repetitions along each axis. tex_depth: Scale applied to the tile's z. inset: Subtracted from the tile's z before scaling. Returns: The closed solid. Ask :meth:`~pybosl2.vnf.VNF.is_watertight` whether the tiling closed cleanly -- a tile with pinch points or interior holes will not. """ from pybosl2.vnf import VNF verts, faces = tile.vertices, tile.faces sx, sy = float(size[0]), float(size[1]) nx, ny = int(reps[0]), int(reps[1]) v: list[list[float]] = [] f: list[list[int]] = [] for i in range(nx): for j in range(ny): off = len(v) for vx, vy, vz in verts: v.append([(i + vx) / nx * sx, (j + vy) / ny * sy, (vz - inset) * tex_depth]) for face in faces: f.append([off + k for k in face]) v, f = _weld(v, f) bottom = min(p[2] for p in v) - 0.1 return VNF(*_close_to_base(v, f, bottom))
def is_heightfield_texture(tex: list[list[float]] | tuple[list[list[float]], list[list[int]]]) -> bool: """Check if *tex* is a height-field: a 2-D array whose entries are plain numbers. Args: tex: The texture, by name or already built. """ try: row = tex[0] return not isinstance(row[0], (list, tuple, np.ndarray)) except (TypeError, IndexError): return False def is_vnf_texture(tex: object) -> bool: """Check if *tex* is a VNF tile: ``(verts, faces)`` with verts a list of 3-vectors. Args: tex: The texture, by name or already built. """ try: verts: Any faces: Any verts, faces = tex # type: ignore[misc] return len(verts[0]) == 3 and hasattr(faces[0], "__len__") except (TypeError, ValueError, IndexError): return False def height_field(tex: "str | TextureType | TextureData", sides: int = 24) -> list[list[float]]: """Return *tex* as a height field: rows of heights in 0..1, whatever kind it started as. A named texture is looked up first. A VNF tile is rasterised (:func:`rasterize_vnf_texture`), so both kinds of texture reduce to one thing a surface can be displaced by -- which is what lets `cyl(texture=...)` take either without the caller knowing which it got (SPEC S-34). Args: tex: A texture name, a height field, or a VNF tile. sides: Raster resolution used when *tex* is a VNF tile. Returns: The height field, as a list of rows. Raises: Bosl2ValueError: if *tex* is neither kind of texture. Examples: >>> from pybosl2.textures import height_field >>> height_field("ribs") [[1.0, 0.0]] """ tile = texture(tex) if isinstance(tex, str | TextureType) else tex if is_heightfield_texture(tile): return [[float(v) for v in row] for row in cast("list[list[float]]", tile)] if is_vnf_texture(tile): from pybosl2.vnf import VNF # local: vnf imports this module for its texture tiles vertices, faces = cast("tuple[list[list[float]], list[list[int]]]", tile) return rasterize_vnf_texture(VNF(vertices, faces), sides=sides) raise Bosl2ValueError( f"height_field(): {tex!r} is neither a height field nor a VNF tile. Pass a name from " f"the texture registry, a 2-D array of heights, or a (verts, faces) tile." ) def _sample(field: "Sequence[Sequence[float]]", row: int, column: int) -> float: """Return one cell of a tiling height field, wrapping in both directions.""" return float(field[row % len(field)][column % len(field[0])]) def texture_grid( field: "Sequence[Sequence[float]]", reps_around: int, reps_along: int, ) -> list[list[float]]: """Tile *field* into the sampling grid for one wrapped surface. The grid has one column per texture cell around the surface and one row per cell along it, plus a closing row so the two ends are sampled exactly rather than interpolated. Columns wrap; rows do not. Args: field: The height field for one tile. reps_around: How many times the tile repeats around the surface. reps_along: How many times it repeats along the surface. Returns: The sampled heights, one list per row. Raises: Bosl2ValueError: if either repeat count is not positive. """ if reps_around < 1 or reps_along < 1: raise Bosl2ValueError( f"texture_grid(): repeats must be at least 1, got around={reps_around}, along={reps_along}. " f"Give a smaller tex_size, or a tex_reps of 1 or more." ) rows, columns = len(field), len(field[0]) return [[_sample(field, r, c) for c in range(columns * reps_around)] for r in range(rows * reps_along + 1)] def default_tex_reps(height: float, radius1: float, radius2: float) -> list[int]: """Return the repeat counts to use when the caller gave neither *tex_size* nor *tex_reps*. SPEC D-4: neither given is "decide for me", not an error. The rule is to repeat the tile so one tile comes out roughly square in world space -- as many around as the circumference holds at the cylinder's own height. One tile wrapped around a whole cylinder is not a texture, and making the caller say so is what P-1 exists to avoid. It lives here because *both* backends need it and neither owns it. It was written twice for one turn -- once in the CSG `_textured_cyl` and once in the SDF one -- which is how two backends come to answer the same undecorated call with two different surfaces (SPEC C-21, PAR-5). The same duplication, in the same shape, that `center=` had. Args: height: Height of the cylinder. radius1: Radius at the bottom. radius2: Radius at the top. Returns: The ``[around, along]`` counts. Examples: >>> from pybosl2.textures import default_tex_reps >>> default_tex_reps(20.0, 10.0, 10.0) [3, 1] """ if height <= 0: return [1, 1] circumference = 2.0 * math.pi * max(radius1, radius2) return [max(1, round(circumference / height)), 1] def _repeat_counts( height: float, radius: float, tex_size: "float | Sequence[float] | None", tex_reps: "int | Sequence[int] | None", ) -> tuple[int, int]: """Return how many times a tile repeats around a surface and along it. Args: height: Height of the surface. radius: Largest radius, which sets the circumference the tiles wrap. tex_size: Size of one tile in millimetres, one number or ``[around, along]``. tex_reps: The counts directly, one number or ``[around, along]``. Returns: The ``(around, along)`` counts, each at least 1. """ if tex_reps is not None: counts = [tex_reps, tex_reps] if isinstance(tex_reps, int) else list(tex_reps) return max(1, int(counts[0])), max(1, int(counts[1])) size = [tex_size, tex_size] if isinstance(tex_size, int | float) else list(cast("Sequence[float]", tex_size)) circumference = 2.0 * math.pi * radius return max(1, round(circumference / float(size[0]))), max(1, round(height / float(size[1]))) def textured_cylinder_vnf( height: float, radius1: float, radius2: float, tex: "str | TextureType | TextureData", *, tex_size: "float | Sequence[float] | None" = None, tex_reps: "int | Sequence[int] | None" = None, tex_depth: float = 1.0, tex_inset: float | bool = False, sides: int = 24, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "VNF": """Return a cylinder's surface with *tex* displacing it radially, as a mesh. The side is sampled on a grid with one column per texture cell around it and one row per cell along it, each vertex pushed out (or in) by the texture's height there. Both kinds of texture work: a VNF tile is rasterised to a height field first (:func:`height_field`), so a caller passing ``texture("dots")`` and one passing ``texture("ribs")`` get the same treatment, which is what SPEC S-34 means by "the caller does not need to know which". Give *tex_size* or *tex_reps*, not both: the first says how big one tile is in millimetres and the repeat counts follow from the cylinder, the second says the counts directly. Args: height: Height of the cylinder. radius1: Radius at the bottom. radius2: Radius at the top. tex: A texture name, a height field, or a VNF tile. tex_size: Size of one tile as ``[around, along]`` in millimetres, or one number for both. tex_reps: Repeat counts as ``[around, along]``, or one number for both. tex_depth: How far the texture displaces the surface. Negative sinks it in. tex_inset: How far the surface is sunk before the texture is added, so the texture's valleys sit flush rather than proud. ``True`` means one full *tex_depth*. sides: Raster resolution used when *tex* is a VNF tile. fn: Fixed fragment count for the cylinder itself. The texture's own cells set a minimum number of columns; this raises it, so a coarse texture does not make a coarse cylinder (SPEC R-1). Omitted, the ambient ``use_defaults(fn=...)`` value applies. fa: Minimum fragment angle in degrees. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Minimum fragment size in millimetres. Omitted, the ambient ``use_defaults(fs=...)`` value applies. Returns: The textured surface as a closed :class:`~pybosl2.vnf.VNF`, capped at both ends. Raises: Bosl2ValueError: if both *tex_size* and *tex_reps* are given, or neither, or if the cylinder's dimensions are not positive. Examples: >>> from pybosl2.textures import textured_cylinder_vnf >>> mesh = textured_cylinder_vnf(20, 10, 10, "ribs", tex_reps=[12, 1]) >>> mesh.is_watertight() True """ from pybosl2.path3d import Path3D from pybosl2.vnf import VNF if height <= 0 or radius1 < 0 or radius2 < 0 or (radius1 == 0 and radius2 == 0): raise Bosl2ValueError( f"textured_cylinder_vnf(): needs a positive height and radius, got height={height}, " f"radius1={radius1}, radius2={radius2}." ) if (tex_size is None) == (tex_reps is None): raise Bosl2ValueError( "textured_cylinder_vnf(): give tex_size or tex_reps, not both and not neither. " "tex_size is the tile's size in millimetres; tex_reps is how many times it repeats." ) field = height_field(tex, sides=sides) around, along = _repeat_counts(height, max(radius1, radius2), tex_size, tex_reps) grid = texture_grid(field, reps_around=around, reps_along=along) # The texture's cells set the column count, and that is also the cylinder's facet count -- so # a two-cell texture would give a two-sided cylinder. The facet controls raise it: each cell # is repeated over as many columns as it takes to reach the roundness asked for, which keeps # the texture crisp (a cell stays a constant-height patch) while the curve gets smooth # (SPEC R-1). from pybosl2._helpers import frag_count wanted = frag_count(max(radius1, radius2), fn, fa, fs) oversample = max(1, -(-wanted // len(grid[0]))) if oversample > 1: grid = [[h for h in row for _ in range(oversample)] for row in grid] inset = float(tex_depth) if tex_inset is True else float(tex_inset) rows: list[Path3D] = [] row_count = len(grid) columns = len(grid[0]) for r, heights in enumerate(grid): v = r / (row_count - 1) z = -height / 2.0 + v * height base = radius1 + (radius2 - radius1) * v points: list[list[float]] = [] for c, h in enumerate(heights): angle = 2.0 * math.pi * c / columns radius = base - inset + float(h) * float(tex_depth) points.append([radius * math.cos(angle), radius * math.sin(angle), z]) rows.append(Path3D(points, closed=True)) from pybosl2.caps import CapType return VNF.vertex_array(rows, col_wrap=True, caps=(CapType.BUTT, CapType.BUTT))