# 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/caps.py
# FileSummary: Stroke and sweep end caps: the CapType styles and the CapSpec value.
# DocCategory: Foundational
# FileGroup: BOSL2
"""End-cap specifications shared by sweep, skin, bezier, and stroke drawing.
Provides the :class:`CapType` enum, the :class:`CapSpec` dataclass for
controlling cap appearance, and the normaliser shared by :mod:`pybosl2.skin`,
:mod:`pybosl2.beziers`, and :mod:`pybosl2.drawing`.
Cap types
``NONE`` -- no cap (open end)
``BUTT`` -- default flat end cap (``FLAT`` is a module-level backward-compatible alias)
``ROUND`` / ``SPHERE`` -- spherical end cap (planned)
``CIRCLE`` -- round-over end cap (planned)
``ARROW`` / ``DIAMOND`` / ``DOT`` ... -- stroke endcap styles
``CUSTOM`` -- user-supplied path shape (requires *path* on CapSpec)
.. note::
Fancy sweep cap shapes (``ROUND``, ``SPHERE``, ``CIRCLE``) are
scaffolding only -- they resolve to flat caps. Full BOSL2 cap profiles
need the sweep's 3-D end-profile geometry exported into
:class:`~pybosl2.vnf.VNF` and are not yet ported.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, Sequence, TypeAlias, cast
from pybosl2.exceptions import Bosl2NotImplementedError, Bosl2ValueError
if TYPE_CHECKING:
from pybosl2._backend import Solid
from pybosl2.path2d import Path2D
from pybosl2.vnf import VNF
__all__ = [
"CapType",
"CapSpec",
"CapsSpec",
"endcap_polys",
"endcap_trim",
"has_decorative_caps",
"norm_caps",
"vnf_with_decorative_caps",
]
[docs]
class CapType(Enum):
"""End-cap or stroke-endcap style.
Sweep/skin cap types:
``NONE`` -- no cap (open end)
``BUTT`` -- flat end cap
``ROUND`` / ``SPHERE`` -- spherical (planned)
``CIRCLE`` -- round-over (planned)
``CUSTOM`` -- user-supplied :attr:`CapSpec.path` shape
Stroke endcap/joint types:
``ARROW`` / ``ARROW2`` / ``ARROW3`` -- arrow heads
``DIAMOND`` -- diamond shape
``DOT`` -- circular dot
``BLOCK`` / ``SQUARE`` -- rectangular block
``CHISEL`` -- chisel edge
``TAIL`` / ``TAIL2`` -- tail shapes
``CROSS`` / ``X`` / ``LINE`` -- line markers
Examples:
.. pythonscad-example::
from pybosl2 import Path3D, CapType
spine = Path3D([[0,0,0],[0,0,30],[30,0,30]], closed=False)
spine.stroke(width=4, endcaps=CapType.ARROW).show()
"""
NONE = "none"
# Sweep cap types
BUTT = "butt"
ROUND = "round"
SPHERE = "sphere"
CIRCLE = "circle"
CUSTOM = "custom"
# Stroke endcap/joint types
ARROW = "arrow"
ARROW2 = "arrow2"
ARROW3 = "arrow3"
BLOCK = "block"
CHISEL = "chisel"
CROSS = "cross"
DIAMOND = "diamond"
DOT = "dot"
LINE = "line"
SQUARE = "square"
TAIL = "tail"
TAIL2 = "tail2"
X = "x"
#: A cap specification used by sweep/skin entry points. Can be:
#:
#: * a single :class:`CapType` enum member (same cap on both ends)
#: * a :class:`CapSpec` with custom dimensions
#: * a ``Sequence[CapType | CapSpec]`` pair (per-end caps)
#:
#: Use ``CapType.NONE`` to request no cap; ``CapType.BUTT`` for a flat cap.
CapsSpec: TypeAlias = "CapType | CapSpec | str | Sequence[CapType | CapSpec | str]"
#: The default cap type used when no explicit cap is requested.
DEFAULT_CAP = CapType.BUTT
[docs]
@dataclass(frozen=True)
class CapSpec:
"""Customisable end-cap specification.
Used wherever a cap type is accepted. The *cap_type* field selects the
shape; *length*, *width*, and *height* control the dimensions; *angle*
rotates the cap; *color* overrides the path colour when set.
When *cap_type* is :attr:`CapType.CUSTOM`, the *path* field must hold
a custom 2-D polygon to use as the endcap shape.
Args:
cap_type: The :class:`CapType` style.
length: Cap length multiplier (along the path direction).
width: Cap width multiplier (perpendicular scale).
height: Cap height multiplier (0 means use the computed default from width/length).
extent: Extent multiplier for the cap shape.
angle: Rotation angle of the cap in degrees.
color: Override colour for the cap, or ``None`` for the path colour.
path: Custom polygon path for :attr:`CapType.CUSTOM`; ignored otherwise.
Examples:
.. pythonscad-example::
from pybosl2 import Path3D, CapSpec, CapType
spine = Path3D([[0,0,0],[0,0,40]], closed=False)
cap = CapSpec(CapType.ARROW, length=2, width=3)
spine.stroke(width=4, endcaps=cap).show()
"""
cap_type: CapType = DEFAULT_CAP
length: float = 0.0
width: float = 0.0
height: float = 0.0
extent: float = 0.0
angle: float = 0.0
color: str | None = None
path: Sequence[Sequence[float]] | None = None
def __post_init__(self) -> None:
"""Post-initialization hook."""
# ---------------------------------------------------------------------------
# Default CapSpec for each stroke endcap/joint CapType (BOSL2 _shape_defaults).
# Used by endcap_polys via normalize_one; the caller's fields override.
# ---------------------------------------------------------------------------
_DEFAULTS: dict[CapType, CapSpec] = {
CapType.NONE: CapSpec(cap_type=CapType.NONE, length=1.0, width=0.0, extent=0.0),
CapType.BUTT: CapSpec(cap_type=CapType.BUTT, length=1.0, width=0.0, extent=0.0),
CapType.ROUND: CapSpec(cap_type=CapType.ROUND, length=1.0, width=1.0, extent=0.0),
CapType.CHISEL: CapSpec(cap_type=CapType.CHISEL, length=1.0, width=1.0, extent=0.0),
CapType.SQUARE: CapSpec(cap_type=CapType.SQUARE, length=1.0, width=1.0, extent=0.0),
CapType.BLOCK: CapSpec(cap_type=CapType.BLOCK, length=2.0, width=1.0, extent=0.0),
CapType.DIAMOND: CapSpec(cap_type=CapType.DIAMOND, length=2.5, width=1.0, extent=0.0),
CapType.DOT: CapSpec(cap_type=CapType.DOT, length=2.0, width=1.0, extent=0.0),
CapType.X: CapSpec(cap_type=CapType.X, length=2.5, width=0.4, extent=0.0, angle=45.0),
CapType.CROSS: CapSpec(cap_type=CapType.CROSS, length=3.0, width=0.33, extent=0.0),
CapType.LINE: CapSpec(cap_type=CapType.LINE, length=3.5, width=0.22, extent=0.0),
CapType.ARROW: CapSpec(cap_type=CapType.ARROW, length=3.5, width=0.4, extent=0.5),
CapType.ARROW2: CapSpec(cap_type=CapType.ARROW2, length=3.5, width=1.0, extent=0.14),
CapType.ARROW3: CapSpec(cap_type=CapType.ARROW3, length=3.5, width=1.0, extent=0.0),
CapType.TAIL: CapSpec(cap_type=CapType.TAIL, length=3.5, width=0.47, extent=0.5),
CapType.TAIL2: CapSpec(cap_type=CapType.TAIL2, length=3.5, width=0.28, extent=0.5),
CapType.CUSTOM: CapSpec(cap_type=CapType.CUSTOM, length=1.0, width=0.0, extent=0.0),
CapType.SPHERE: CapSpec(cap_type=CapType.SPHERE, length=1.0, width=1.0, extent=0.0),
CapType.CIRCLE: CapSpec(cap_type=CapType.CIRCLE, length=1.0, width=1.0, extent=0.0),
}
# ---------------------------------------------------------------------------
# Cap normalisation helpers
# ---------------------------------------------------------------------------
[docs]
def norm_caps(caps: CapsSpec, closed: bool = False) -> list[CapSpec]:
"""Normalize a :data:`CapsSpec` to a ``[CapSpec, CapSpec]`` pair.
Returns a list of two fully-resolved :class:`CapSpec` objects for the
start and end caps. ``CapSpec(cap_type=CapType.NONE)`` means no cap.
When *closed* is True, both caps are ``CapType.NONE``.
Args:
caps: The cap specification to normalize.
closed: Whether the sweep is closed (no caps on either end).
Returns:
A ``[CapSpec, CapSpec]`` pair.
"""
if closed:
return [CapSpec(cap_type=CapType.NONE), CapSpec(cap_type=CapType.NONE)]
if isinstance(caps, (list, tuple)):
return [normalize_one(c) for c in caps[:2]]
result = normalize_one(caps) # type: ignore[arg-type]
return [result, result]
def normalize_one(cap: CapType | CapSpec | str) -> CapSpec:
"""Normalize a single cap value to a fully-resolved :class:`CapSpec`.
If given a raw :class:`CapType`, looks up the default :class:`CapSpec`
from :data:`_DEFAULTS`. If given a :class:`CapSpec` already, returns it
unchanged.
Args:
cap: The cap to build.
"""
if isinstance(cap, CapSpec):
return cap
if isinstance(cap, str):
try:
cap = CapType(cap)
except ValueError:
return _DEFAULTS[CapType.BUTT]
if isinstance(cap, CapType):
return _DEFAULTS.get(cap, _DEFAULTS[CapType.NONE])
return _DEFAULTS[CapType.BUTT]
[docs]
def has_decorative_caps(cap_specs: list[CapSpec]) -> bool:
"""Return True if any endcap is a decorative (non-flat/non-dome/non-none) type.
Args:
cap_specs: The caps for each end and the joints.
"""
_basic = frozenset({CapType.NONE, CapType.BUTT, CapType.ROUND, CapType.SPHERE})
return any(cs.cap_type not in _basic for cs in cap_specs)
[docs]
def vnf_with_decorative_caps(
vnf: VNF,
cap_specs: list[CapSpec],
closed: bool,
profile_centers: list[Sequence[float]],
profile_outdirs: list[Sequence[float]],
profile_radius: float,
) -> "Solid":
"""Convert VNF to CSG polyhedron, add decorative endcaps, return Bosl2Solid.
Args:
vnf: The body VNF (already volume-checked and corrected).
cap_specs: Normalised cap pair.
closed: Whether the sweep is closed (no caps expected).
profile_centers: Centroids of the first and last profiles.
profile_outdirs: Outward directions for the first and last caps.
profile_radius: Bounding radius of the profile (half the *width* passed to endcap geometry).
Returns:
A Bosl2Solid with the body polyhedron and any decorative endcaps unioned.
"""
from pybosl2._stroke3d import endcap_geometry_3d
if closed or not cap_specs:
return vnf.polyhedron()
body = vnf.polyhedron()
width = profile_radius * 2
for spec, center, outdir in [
(cap_specs[0], profile_centers[0], profile_outdirs[0]),
(cap_specs[1], profile_centers[1], profile_outdirs[1]),
]:
if spec.cap_type not in (CapType.NONE, CapType.BUTT, CapType.ROUND, CapType.SPHERE):
ec = endcap_geometry_3d(spec, list(center), list(outdir), width)
if ec is not None:
body = body | ec
return body
# ---------------------------------------------------------------------------
# Stroke endcap polygon generation (shared by drawing.py and caps.py)
# ---------------------------------------------------------------------------
[docs]
def endcap_polys(spec: CapSpec, lw: float) -> list[list[list[float]]]:
"""Return the local-frame polygon(s) for an endcap (BOSL2 ``_shape_path()``).
Dimensions are taken directly from the :class:`CapSpec` which has
already been resolved by :func:`normalize_one` against
:data:`_DEFAULTS`.
Args:
spec: The resolved cap specification.
lw: The line width (stroke width) to scale the polygons.
Returns:
A list of (N,2) polygon point lists in the endcap's local frame
(X is the line direction, Y is perpendicular).
"""
if spec.cap_type in (CapType.NONE, CapType.BUTT):
return []
if spec.cap_type == CapType.CUSTOM:
if not (spec.path is not None):
raise Bosl2ValueError("CapType.CUSTOM requires path= on the CapSpec")
return [[[float(c) for c in pt] for pt in spec.path]]
if spec.cap_type == CapType.CIRCLE:
raise Bosl2NotImplementedError(
"CapType.CIRCLE is not built in this port yet. Use CapType.ROUND for a rounded end, or "
"CapSpec with a custom path for an exact circle."
)
w = spec.width
length = spec.length * spec.width
l2 = spec.extent * spec.width
w2 = w - l2
s = (lw / 2) / w if w else lw / 2
ss = s * w2
style = spec.cap_type
poly: list[list[list[float]]] = []
if style == CapType.ROUND:
th = [i * math.pi / 16 for i in range(16)]
poly.append([[-math.cos(t) * s, math.sin(t) * s] for t in th])
elif style == CapType.CHISEL:
poly.append([[0, -s], [s * length, 0], [0, s]])
elif style == CapType.SQUARE:
poly.append([[0, -s], [s * length, -s], [s * length, s], [0, s]])
elif style == CapType.BLOCK:
p = s * length
poly.append([[0, -s], [p, -s], [p, s], [0, s]])
elif style == CapType.DIAMOND:
p = s * length
poly.append([[-p / 2, 0], [0, -s], [p / 2, 0], [0, s]])
elif style == CapType.DOT:
th = [i * 2 * math.pi / 16 for i in range(16)]
poly.append([[math.cos(t) * s, math.sin(t) * s] for t in th])
elif style == CapType.X:
p = s * length
poly.append([[0, -ss], [p, -s]])
poly.append([[p, -s], [0, ss]])
elif style == CapType.CROSS:
p = s * length
poly.append([[0, -ss], [p, 0], [0, ss]])
elif style == CapType.LINE:
poly.append([[0, 0], [s * length, 0]])
elif style == CapType.ARROW:
p = s * length
pp = s * (length - 0.5)
poly.append([[0, -s], [pp, -s], [p, 0], [pp, s], [0, s]])
elif style == CapType.ARROW2:
p = s * length
pp = s * 0.75
poly.append([[0, -ss], [p - pp, -ss], [p - pp, -s], [p, 0], [p - pp, s], [p - pp, ss], [0, ss]])
elif style == CapType.ARROW3:
p = s * length
pp = s * 0.5
poly.append([[0, -ss], [p - pp, -ss], [p - pp, -s], [p, 0], [p - pp, s], [p - pp, ss], [0, ss]])
elif style == CapType.TAIL:
p = s * length
pp = s * (length - 0.5)
poly.append([[0, -s], [p - pp, -s], [p, 0], [p - pp, s], [0, s]])
elif style == CapType.TAIL2:
p = s * length
pp = s * (length - 0.17)
poly.append([[0, -ss], [p - pp, -ss], [p - pp, -s], [p, 0], [p - pp, s], [p - pp, ss], [0, ss]])
if spec.angle != 0.0:
cos_a = math.cos(math.radians(spec.angle))
sin_a = math.sin(math.radians(spec.angle))
poly = [[[pt[0] * cos_a - pt[1] * sin_a, pt[0] * sin_a + pt[1] * cos_a] for pt in p] for p in poly]
return poly
[docs]
def endcap_trim(spec: CapSpec, width: float) -> float:
"""How far to pull the line back under an arrow endcap so it doesn't poke through the tip.
Args:
spec: The resolved cap specification.
width: The stroke line width.
Returns:
The trim distance in world units (0.0 for non-arrow styles).
"""
s = (width / 2) / spec.width if spec.width else width / 2
if spec.cap_type in (CapType.ARROW, CapType.ARROW3):
return s * (spec.length * spec.width - 0.01)
if spec.cap_type == CapType.ARROW2:
return s * (spec.length * spec.width * 3 / 4)
return 0.0
def place(poly: "Path2D", theta_deg: float, at: Sequence[float]) -> list[list[float]]:
"""Rotate a local polygon by *theta_deg* and translate it to point *at*.
Args:
poly: The cap's outline.
theta_deg: The cap's angle in degrees.
at: Where along the path the cap sits.
"""
# Imported here, not at module scope: `pybosl2.paths` imports CapSpec from this module, so a
# top-level import closes the cycle and the package stops importing at all.
from pybosl2.path2d import Path2D
from pybosl2.paths import require_path
poly = cast("Path2D", require_path(poly, "poly", "place", Path2D))
radius = math.radians(theta_deg)
c, s = math.cos(radius), math.sin(radius)
return [[c * p[0] - s * p[1] + at[0], s * p[0] + c * p[1] + at[1]] for p in poly]
def trim_ends(body: list[list[float]], trim1: float, trim2: float) -> list[list[float]]:
"""Shorten the open *body* path at each end by trim1/trim2 (clamped within the end segment).
Args:
body: The stroke body the cap is attached to.
trim1: How much to trim from the start so the cap keeps the requested end position.
trim2: How much to trim from the end.
"""
body = [list(map(float, p)) for p in body]
if len(body) >= 2 and trim1 > 0:
a0, a1 = float(body[0][0]), float(body[0][1])
b0, b1 = float(body[1][0]), float(body[1][1])
dx, dy = b0 - a0, b1 - a1
seglen = math.hypot(dx, dy) or 1.0
t = min(trim1, 0.99 * seglen) / seglen
body[0] = [a0 + dx * t, a1 + dy * t]
if len(body) >= 2 and trim2 > 0:
a0, a1 = float(body[-1][0]), float(body[-1][1])
b0, b1 = float(body[-2][0]), float(body[-2][1])
dx, dy = b0 - a0, b1 - a1
seglen = math.hypot(dx, dy) or 1.0
t = min(trim2, 0.99 * seglen) / seglen
body[-1] = [a0 + dx * t, a1 + dy * t]
return body
def oriented_to(shape: Any, outdir: Sequence[float], at: Sequence[float]) -> Any:
"""Rotate a Z-up solid so +Z points along 3-D *outdir*, then translate it to *at*.
Uses ``rotate(angle, axis)`` rather than a 4x4 ``multmatrix`` so it works on either backend's
solid -- an SDF PyShape rotates its field in closed form, but has no multmatrix.
Args:
shape: The shape being capped.
outdir: Direction the cap points.
at: Where along the path the cap sits.
"""
from pybosl2.transforms import rot_from_to
angle, axis = rot_from_to([0, 0, 1], outdir)
return shape.rotate(float(angle), [float(c) for c in axis]).translate([float(c) for c in at])