Source code for simvx.graphics.render2d.clip_scope

"""The clip + transform scope table (design §2.5).

Today, clip/transform nesting is implicit in walk recursion plus push/pop
stacks. Once items live in one flat sorted list the recursion is gone, so the
nesting must be *encoded*. This module is that encoding (the model, §2.5 -- not
yet wired to a real walk):

- Each ``push_clip``/``pop_clip`` (and the synthesised per-child Control wrap,
  §2.5) opens/closes a :class:`ClipScope`. A scope's effective scissor is the
  **intersection of its own rect with its parent's effective scissor**, computed
  once at open time and stored with a parent pointer.
- Every Item records the :class:`ClipScopeId` active at emission. After sort,
  the scissor is read straight off the item's scope -- no draw-time clip stack.
- A scope also carries a ``transform`` id so the Control walk's implicit
  per-child translate (§2.5) is reproduced as a scope entry rather than baked.

The hard invariant (§2.5): clipping is a *filter*, never an ordering input. A
scope id travels with its item through the sort; banding never moves an item
across a clip boundary. So the table only needs to reproduce, for any item, the
exact effective scissor + transform its recursive push/pop would have produced.
"""

from __future__ import annotations

from typing import NamedTuple

__all__ = ["ROOT_CLIP_SCOPE", "ClipScope", "ClipScopeTable"]

# A 2D clip rect ``(x, y, w, h)`` in integer pixels, matching the legacy
# ``Op.clip`` tuple shape. ``None`` means "no clip" (unbounded scissor).
Rect = tuple[int, int, int, int]

ROOT_CLIP_SCOPE = 0  # the always-present unclipped root scope id


[docs] class ClipScope(NamedTuple): """One entry in the :class:`ClipScopeTable` (design §2.5). ``scissor`` is the effective rect (already intersected up the parent chain), so a batch sets the scissor straight from ``scissor`` with no walk. ``None`` means unbounded. ``transform`` is an index into the per-item transform column (-1 = inherit parent / identity), reproducing the Control per-child translate that the walk synthesises. """ parent: int # parent scope id (-1 for the root) scissor: Rect | None # effective (intersected) scissor rect transform: int # transform-column index, or -1
def _intersect(a: Rect | None, b: Rect | None) -> Rect | None: """Return the rectangle intersection, or ``None`` if either is unbounded. Mirrors the legacy clip-stack semantics: a child clip is the intersection of its rect with the active clip. A degenerate (empty) intersection is returned as a zero-area rect so it still clips everything (nothing leaks). """ if a is None: return b if b is None: return a ax, ay, aw, ah = a bx, by, bw, bh = b x0 = max(ax, bx) y0 = max(ay, by) x1 = min(ax + aw, bx + bw) y1 = min(ay + ah, by + bh) return (x0, y0, max(0, x1 - x0), max(0, y1 - y0))
[docs] class ClipScopeTable: """Builds + stores nested clip/transform scopes for a flat item list (§2.5). Usage models the collection pass: maintain an open-scope cursor, and :meth:`push` / :meth:`pop` around clipped subtrees (or call :meth:`open` directly with an explicit parent). Items record :attr:`current` at emission. After collection the table is a flat array indexed by ``ClipScopeId``; the effective scissor + transform are read with O(1) :meth:`scissor` / :meth:`get`. The root scope (id :data:`ROOT_CLIP_SCOPE`) is always present, unclipped, identity transform. """ __slots__ = ("_scopes", "_stack") def __init__(self) -> None: # Scope 0 is the unclipped, identity-transform root. self._scopes: list[ClipScope] = [ClipScope(parent=-1, scissor=None, transform=-1)] self._stack: list[int] = [ROOT_CLIP_SCOPE]
[docs] def __len__(self) -> int: return len(self._scopes)
[docs] @property def current(self) -> int: """The currently-open scope id (what an emitted item would record).""" return self._stack[-1]
[docs] def open(self, parent: int, clip: Rect | None = None, transform: int = -1) -> int: """Create a child scope under ``parent`` and return its id. The new scope's effective scissor is the intersection of ``clip`` with ``parent``'s effective scissor (§2.5), so nesting is resolved once at open time. Does not change :attr:`current` -- use :meth:`push` for the stack-style collection cursor. """ if not 0 <= parent < len(self._scopes): raise IndexError(f"clip-scope parent {parent} out of range") effective = _intersect(self._scopes[parent].scissor, clip) scope_id = len(self._scopes) self._scopes.append(ClipScope(parent=parent, scissor=effective, transform=transform)) return scope_id
[docs] def push(self, clip: Rect | None = None, transform: int = -1) -> int: """Open a child scope under :attr:`current`, make it current, return id.""" scope_id = self.open(self.current, clip, transform) self._stack.append(scope_id) return scope_id
[docs] def pop(self) -> int: """Close the current scope, restoring its parent as current; returns the scope that was just closed (it remains stored in the table).""" if len(self._stack) == 1: raise IndexError("cannot pop the root clip scope") return self._stack.pop()
[docs] def get(self, scope_id: int) -> ClipScope: """Return the :class:`ClipScope` record for ``scope_id``.""" if not 0 <= scope_id < len(self._scopes): raise IndexError(f"clip-scope id {scope_id} out of range") return self._scopes[scope_id]
[docs] def scissor(self, scope_id: int) -> Rect | None: """Return the effective (intersected) scissor for ``scope_id`` (§2.5).""" return self.get(scope_id).scissor
[docs] def ancestry(self, scope_id: int) -> list[int]: """Return ``[scope_id, parent, ..., root]`` -- the nesting chain (§2.5). Proves the flat table reproduces push/pop nesting: walking parent pointers recovers exactly the recursive scope stack that was open when the scope was created. """ chain: list[int] = [] cur = scope_id while cur != -1: chain.append(cur) cur = self.get(cur).parent return chain