Source code for pybosl2.defaults

# 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/defaults.py
#    Ambient resolution defaults -- the smoothness a shape gets when the caller says nothing.
#
#    Curve resolution is never a required argument in pybosl2 (SPEC.md R-2): every constructor
#    takes ``fn``/``fa``/``fs`` (CSG) or ``res`` (SDF) as ``None``, meaning "decide for me". This
#    module is what decides. Set it once for a block instead of threading the same numbers through
#    every call:
#
#        from pybosl2.defaults import use_defaults
#
#        with use_defaults(fn=64):
#            part = cyl(height=10, radius=4)   # 64-sided, without saying so
#
#    The active values live in a ``contextvars.ContextVar``, so a ``with`` block is thread- and
#    async-safe and nests, exactly like ``use_backend()``. Values are read at CONSTRUCTION time
#    (SPEC.md R-6) -- a shape's smoothness is fixed by where it was built, never re-resolved later.
#
#    Anything the caller passes explicitly always wins (SPEC.md P-6), and `fn=0` is how a single
#    call opts OUT of an ambient `fn` and back to fa/fs -- the meaning OpenSCAD's own $fn=0 has
#    (SPEC.md R-5):
#
#        with use_defaults(fn=64):
#            smooth = cyl(height=10, radius=4)            # 64 sides
#            adaptive = cyl(height=10, radius=4, fn=0)    # back to fa/fs
#
#    With nothing set anywhere, behaviour is unchanged from before this module existed:
#    OpenSCAD's own $fa=12 / $fs=2.
#
# FileSummary: Ambient curve-resolution defaults (fn/fa/fs/res) for a block or a session.
# DocCategory: Foundational
# FileGroup: BOSL2

"""Ambient curve-resolution defaults (fn/fa/fs/res) for a block or a session."""

from __future__ import annotations

import contextlib
import contextvars
from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Iterator

__all__ = [
    "Resolution",
    "current_defaults",
    "set_defaults",
    "reset_defaults",
    "use_defaults",
    "resolve_facets",
    "resolve_res",
]


[docs] @dataclass(frozen=True, slots=True) class Resolution: """The ambient curve-resolution settings. Mirrors OpenSCAD's ``$fn``/``$fa``/``$fs`` special variables plus the SDF backend's ``res``. A field left at ``None`` means "not set" -- the renderer's own default applies. """ #: Fixed number of fragments per full circle; overrides fa/fs when 3 or more, and ``0`` means #: "ignore any ambient fn, use fa/fs" (SPEC R-5). fn: int | None = None #: Minimum fragment angle in degrees. fa: float | None = None #: Minimum fragment size in millimetres. fs: float | None = None #: Sampling resolution for the SDF backend. res: int | None = None
_EMPTY = Resolution() _current: contextvars.ContextVar[Resolution | None] = contextvars.ContextVar("bosl2_defaults", default=None) #: Process-wide fallback, set by :func:`set_defaults`; the ContextVar shadows it inside a block. _global: Resolution = _EMPTY
[docs] def current_defaults() -> Resolution: """Return the resolution settings in effect right here. Returns: The block-scoped settings if inside a :func:`use_defaults` block, otherwise the ones from :func:`set_defaults`, otherwise an all-``None`` :class:`Resolution`. """ active = _current.get() return active if active is not None else _global
[docs] def set_defaults( fn: int | None = None, fa: float | None = None, fs: float | None = None, res: int | None = None, ) -> None: """Set the process-wide resolution defaults. Only the arguments given are changed; passing ``None`` leaves that setting as it was, so ``set_defaults(fn=64)`` does not disturb an existing ``fs``. Use :func:`reset_defaults` to clear. Prefer :func:`use_defaults` in library code -- a global default reaches other people's shapes too. Args: fn: Fixed number of fragments per full circle. fa: Minimum fragment angle in degrees. fs: Minimum fragment size in millimetres. res: Sampling resolution for the SDF backend. Returns: None. """ global _global _global = _merge(_global, fn, fa, fs, res)
[docs] def reset_defaults() -> None: """Clear the process-wide resolution defaults, restoring the renderer's own behaviour.""" global _global _global = _EMPTY
[docs] @contextlib.contextmanager def use_defaults( fn: int | None = None, fa: float | None = None, fs: float | None = None, res: int | None = None, ) -> Iterator[Resolution]: """Apply resolution defaults to every shape built inside the block. Settings nest: an inner block inherits the outer one and overrides only what it names. The block is thread- and async-safe (a :class:`~contextvars.ContextVar` holds the value). Args: fn: Fixed number of fragments per full circle. fa: Minimum fragment angle in degrees. fs: Minimum fragment size in millimetres. res: Sampling resolution for the SDF backend. Yields: The :class:`Resolution` in effect inside the block. Note: A single call opts out of an ambient ``fn`` by passing ``fn=0``, which means "use fa/fs" exactly as OpenSCAD's ``$fn=0`` does. Examples: .. pythonscad-example:: from pybosl2 import cyl from pybosl2.defaults import use_defaults with use_defaults(fn=64): cyl(height=20, radius=8).show() """ token = _current.set(_merge(current_defaults(), fn, fa, fs, res)) try: yield current_defaults() finally: _current.reset(token)
[docs] def resolve_facets( fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> tuple[int | None, float | None, float | None]: """Fill in whichever of *fn*, *fa*, *fs* the caller left unset from the ambient defaults. Args: fn: Caller-supplied fragment count, or None. fa: Caller-supplied fragment angle, or None. fs: Caller-supplied fragment size, or None. Returns: The three values with any ``None`` replaced by the ambient setting (still ``None`` when nothing is set anywhere). ``fn=0`` passes through unchanged: it is the caller opting out of an ambient ``fn``, and :func:`~pybosl2._helpers.frag_count` reads any ``fn`` below 3 as "use fa/fs" (SPEC R-5). Note: The rule itself lives in :meth:`~pybosl2.groups.Facets.resolved`, which this and :func:`resolve_res` both call. They were two implementations of one rule (SPEC R-1). """ from pybosl2.groups import Facets # local: groups reads the ambient defaults from here resolved = Facets.resolved(fn=fn, fa=fa, fs=fs) return resolved.fn, resolved.fa, resolved.fs
[docs] def resolve_res(res: int | None = None) -> int | None: """Fill in the SDF sampling resolution from the ambient defaults when the caller left it unset. Args: res: Caller-supplied resolution, or None. Returns: The resolution to use, or ``None`` when nothing is set anywhere. """ from pybosl2.groups import Facets # local: groups reads the ambient defaults from here return Facets.resolved(res=res).res
def _merge( base: Resolution, fn: int | None, fa: float | None, fs: float | None, res: int | None, ) -> Resolution: """Return *base* with each non-None argument applied over it.""" return Resolution( fn=fn if fn is not None else base.fn, fa=fa if fa is not None else base.fa, fs=fs if fs is not None else base.fs, res=res if res is not None else base.res, )