# 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
# Backend selection for the dual CSG / SDF solid system (default: "csg").
#
# pybosl2 realizes solids through one of two backends:
# * "csg" -- exact CSG via PythonSCAD's native primitives (the default; today's Bosl2Solid).
# * "sdf" -- F-Rep / signed-distance via libfive.
#
# The active backend is a thread/async-safe context value (a contextvars.ContextVar) so it can be
# overridden for a block with ``use_backend("sdf")`` or changed globally with
# ``set_default_backend``. Shape constructors dispatch on :func:`current_backend`; operands of a
# boolean/transform must share a backend (see :class:`~pybosl2.exceptions.CrossBackendError`), and a
# call a backend cannot express raises :class:`~pybosl2.exceptions.UnsupportedByBackendError`.
#
# This module holds only the *selection* machinery and the shared :class:`Solid` /
# :class:`SolidBackend` contracts -- it imports neither native runtime, so it stays FFI-free.
#
from __future__ import annotations
import contextlib
import contextvars
from typing import TYPE_CHECKING, Any, Iterator, Protocol, runtime_checkable
from pybosl2.exceptions import Bosl2Error, UnsupportedByBackendError
if TYPE_CHECKING:
from collections.abc import Mapping
from pybosl2.caps import CapSpec
from pybosl2.path3d import Path3D
[docs]
def given_arguments(arguments: dict[str, Any]) -> dict[str, Any]:
"""Just the arguments the caller actually gave.
The backends take different parameter sets, so only what was asked for is forwarded through
:meth:`SolidBackend.construct` -- a backend never sees an option it has no notion of, and
each keeps its own defaults.
"""
return {name: value for name, value in arguments.items() if value is not None}
__all__ = [
"BackendName",
"given_arguments",
"current_backend",
"set_default_backend",
"use_backend",
"register_backend",
"get_backend",
"known_backends",
"check_operand_backend",
"supports",
"unsupported_feature",
"Solid",
"SolidBackend",
]
# Features one backend has that the other cannot faithfully express. Calling one of these on the
# wrong backend raises UnsupportedByBackendError (see the wrappers' __getattr__) instead of a confusing
# AttributeError -- or, for the SDF backend, instead of meshing just to fail.
CSG_ONLY_FEATURES = frozenset(
{ # BOSL2's attachment / anchor system -- no SDF equivalent
"attach",
"anchor_point",
"reanchor",
"position",
"align",
"reorient",
"orient",
"edge_mask",
"edge_profile",
"edge_profile_asym",
"corner_profile",
"face_profile",
"tag",
"tag_this",
"diff",
"intersect",
"realize",
# 2-D geometry: only the CSG backend has a 2-D shape object (Bosl2Shape2D). An SDF is a
# field over 3-space, with no 2-D shadow and no outline to fill.
"projection",
"fill",
}
)
SDF_ONLY_FEATURES = frozenset(
{ # implicit-surface edge treatments -- CSG rounds via rounding=/chamfer= params, not a method
"round",
"chamfer",
}
)
def supports(backend: str, feature: str) -> bool:
"""Whether *backend* can do *feature*. Backend-exclusive features are False on the other side;
everything else (the shared surface) is assumed supported.
"""
if feature in CSG_ONLY_FEATURES:
return backend == "csg"
if feature in SDF_ONLY_FEATURES:
return backend == "sdf"
return True
def unsupported_feature(backend: str, name: str) -> "UnsupportedByBackendError | None":
"""The :class:`~pybosl2.exceptions.UnsupportedByBackendError` to raise if *name* is exclusive to the
OTHER backend, else ``None`` (so the caller can fall through to normal attribute handling).
"""
from pybosl2.exceptions import UnsupportedByBackendError
if backend == "sdf" and name in CSG_ONLY_FEATURES:
hint = "attachment/anchoring is a CSG-backend feature; build it with the default (csg) backend."
if name in ("projection", "fill"):
hint = (
f"{name}() produces or consumes 2-D geometry, which only the csg backend has "
"(pybosl2.shapes2d.Bosl2Shape2D). Convert first with .to_csg(), or build the shape "
"on the default (csg) backend."
)
return UnsupportedByBackendError(name, "sdf", hint=hint)
if backend == "csg" and name in SDF_ONLY_FEATURES:
return UnsupportedByBackendError(
name,
"csg",
hint=f"the csg backend has no implicit {name}(); use the rounding=/chamfer= "
"parameters on cuboid()/cyl(), or build the shape under use_backend('sdf').",
)
return None
def check_operand_backend(self_backend: str, other: Any) -> None:
"""Raise :class:`~pybosl2.exceptions.CrossBackendError` if *other* is a Solid on a different backend.
Called by every boolean operator so ``csg_solid | sdf_solid`` fails loudly with conversion
guidance instead of producing nonsense. A raw native shape (no ``backend`` attribute) is treated
as same-backend so existing native interop keeps working.
"""
other_backend = getattr(other, "backend", None)
if other_backend is not None and other_backend != self_backend:
from pybosl2.exceptions import CrossBackendError
raise CrossBackendError(self_backend, other_backend)
BackendName = str # "csg" | "sdf" (kept a plain str so third-party backends can register too)
_KNOWN: set[str] = {"csg", "sdf"}
_default: str = "csg"
_registry: dict[str, "SolidBackend"] = {}
# None => fall through to the module-level default (which set_default_backend can change).
_current: contextvars.ContextVar[str | None] = contextvars.ContextVar("bosl2_backend", default=None)
def _validate(name: str) -> None:
if name not in _KNOWN:
raise Bosl2Error(f"unknown backend {name!r}; known backends: {sorted(_KNOWN)}")
def known_backends() -> tuple[str, ...]:
"""The registered backend names."""
return tuple(sorted(_KNOWN))
[docs]
def current_backend() -> str:
"""The backend active in this context (default ``"csg"``)."""
return _current.get() or _default
[docs]
def set_default_backend(name: str) -> None:
"""Change the process-wide default backend (outside any :func:`use_backend` block)."""
_validate(name)
global _default
_default = name
[docs]
@contextlib.contextmanager
def use_backend(name: str) -> Iterator[None]:
"""Make *name* the active backend for the duration of the ``with`` block (nestable, thread-safe)."""
_validate(name)
token = _current.set(name)
try:
yield
finally:
_current.reset(token)
def register_backend(name: str, impl: "SolidBackend") -> None:
"""Register a :class:`SolidBackend` implementation under *name* (also makes it a known backend)."""
_KNOWN.add(name)
_registry[name] = impl
def get_backend(name: str | None = None) -> "SolidBackend":
"""The :class:`SolidBackend` implementation for *name* (default: the active backend).
The two built-in backends register themselves on first use (importing them is FFI-free -- the
native runtime is only touched when geometry is actually realized).
"""
key = name or current_backend()
if key not in _registry:
if key == "csg":
import pybosl2._csg
elif key == "sdf":
import pybosl2._sdf # noqa: F401 -- registers the SDF backend on import
try:
return _registry[key]
except KeyError:
raise Bosl2Error(
f"backend {key!r} is selected but not registered/available "
f"(is its native dependency installed?). Registered: {sorted(_registry)}"
) from None
[docs]
@runtime_checkable
class Solid(Protocol):
"""The common solid contract both backend wrappers satisfy.
A ``Solid`` carries a ``backend`` tag; booleans/transforms return a ``Solid`` on the *same*
backend, and combining solids from two backends raises
:class:`~pybosl2.exceptions.CrossBackendError`. ``.to_csg()`` / ``.to_sdf()`` convert between them.
"""
backend: str
def __or__(self, other: "Solid") -> "Solid": ...
def __and__(self, other: "Solid") -> "Solid": ...
def __sub__(self, other: "Solid") -> "Solid": ...
[docs]
def translate(self, v: Any) -> "Solid": ...
[docs]
def rotate(self, a: Any = None, v: Any = None) -> "Solid": ...
[docs]
def scale(self, v: Any) -> "Solid": ...
[docs]
def mirror(self, v: Any) -> "Solid": ...
[docs]
def bounds(self) -> Any: ...
class SolidBackend(Protocol):
"""The small 'realize' surface a backend implements: the primitives shape constructors build on.
Both backends expose the same names; each realizes them in its own idiom (the CSG backend calls
PythonSCAD's ``cube``/``cylinder``/...; the SDF backend builds the equivalent libfive fields).
Shape functions in the shared layer call these, not the native ops directly.
"""
name: str
def construct(self, shape: str, arguments: Mapping[str, Any]) -> Solid:
"""Build the named shape constructor (e.g. ``"torus"``) in this backend's idiom.
*arguments* holds just the parameters the caller gave, so a backend never has to accept
an option it has no notion of (see :func:`pybosl2.solid._given`).
"""
...
def polyhedron(self, points: Any, faces: Any = None, convexity: int | None = None) -> Solid: ...
def union(self, solids: Any) -> Solid: ...
def difference(self, solids: Any) -> Solid: ...
def intersection(self, solids: Any) -> Solid: ...
def linear_extrude(self, paths: Any, height: float, arguments: Mapping[str, Any]) -> Solid:
"""Extrude 2-D outlines (a list of ``[[x, y], ...]`` paths) *height* along +Z.
This is the one 2-D -> 3-D entry point both backends can express, and it takes raw point
paths rather than a 2-D shape object deliberately: 2-D *geometry* is a CSG-only notion
(:class:`~pybosl2.shapes2d.Bosl2Shape2D`), whereas a path is backend-neutral. It is what
:meth:`pybosl2.paths.Path2D.linear_extrude` dispatches through, so the same call yields a
Bosl2Solid on the CSG backend and a PyShape on the SDF one.
"""
...
def stroke(
self,
path: Path3D,
width: float = 1,
closed: bool | None = None,
endcap1: CapSpec | None = None,
endcap2: CapSpec | None = None,
) -> Solid:
"""3-D tube along *path*."""
...