Source code for simvx.core.nodes_2d.canvas

"""Canvas layer nodes: the screen-/world-space CanvasLayer band."""

import logging
import math
from enum import IntEnum

import numpy as np

from .._drawable2d import Drawable2D
from ..descriptors import Property
from ..node import Node

log = logging.getLogger(__name__)


[docs] class CanvasLayer(Drawable2D, Node): """Renders children at a fixed Z-layer with optional transform offset. Children of a CanvasLayer are drawn together at the specified layer order. Higher layer values draw on top. The canvas transform (offset, rotation, scale) is applied to all children. CanvasLayer is a ``Node`` (not a ``Node2D``), so it joins the build-once retention contract via the :class:`~simvx.core._drawable2d.Drawable2D` mixin (a plain hoist onto ``Node2D`` would miss it). Its transform Properties (``offset``/``rotation``/``scale_val``/``layer``) carry ``on_change`` hooks that mark the whole layer subtree render-dirty so the item ``RenderItemCache`` re-collects under frame-skip. The legacy path ignores the bits. """ # Marker read by ``Node._draw_recursive`` to enable the layer-sorting path # without importing this subclass per frame. _is_canvas_layer: bool = True
[docs] class Band(IntEnum): """Named ``layer`` bands for the common HUD/overlay stack. Ergonomic presets that replace the magic ``1000/1500/...`` ints scattered across ports. ``layer`` is still a plain int -- authors may pass a raw value -- but ``CanvasLayer(layer=CanvasLayer.Band.UI)`` reads as intent. ``z_index`` orders content *within* a band; ``Band`` orders the bands. ``BACKGROUND`` is negative (drawn behind world content but still over any 3D); ``WORLD`` is 0 (the same band ordinary Node2D content occupies); ``UI`` and ``OVERLAY`` are the HUD / always-on-top bands. """ BACKGROUND = -1000 WORLD = 0 UI = 1000 OVERLAY = 2000
layer = Property(0, hint="Draw order (higher = on top)", on_change="_on_layer_change") offset = Property((0.0, 0.0), hint="Layer transform offset", on_change="_invalidate_layer_render") rotation = Property(0.0, hint="Layer rotation in radians", on_change="_invalidate_layer_render") scale_val = Property((1.0, 1.0), hint="Layer scale", on_change="_invalidate_layer_render") follow_viewport = Property( False, hint="True: layer moves with the Camera2D; False (default): screen-pinned (HUD)", on_change="_invalidate_layer_render", ) environment = Property( None, hint="A WorldEnvironment whose post FX (bloom/vignette/grain/...) apply to THIS layer's band only", ) def __init__(self, **kwargs): # Render-retention dirty bits (Drawable2D) before Property kwargs run. self._render_dirty: bool = True self._transform_render_dirty: bool = True super().__init__(**kwargs) def _on_layer_change(self): """Warn on the v1 negative-layer limit, then mark the subtree dirty. Negative ``layer`` draws behind other 2D but is **still composited over 3D** on both backends: there is no 2D-behind-3D in v1. Authors who set a negative layer expecting it to sit behind a 3D scene get a one-time warning so the limit is explicit, not silent. """ if self.layer < 0: log.warning( "CanvasLayer(layer=%d): negative layer draws behind other 2D but is still " "composited OVER any 3D scene (no 2D-behind-3D in v1).", self.layer, ) self._invalidate_layer_render() def _invalidate_layer_render(self): """Mark this layer + its whole subtree render-dirty. A CanvasLayer transform/layer change shifts every child it composites, so the item cache must re-collect the layer subtree. Marks render-dirty on self and every descendant drawable. The legacy path never reads these. """ self._render_dirty = True self._transform_render_dirty = True stack = list(self.children) while stack: node = stack.pop() qr = getattr(node, "queue_redraw", None) if qr is not None: qr() mark = getattr(node, "_mark_transform_render_dirty", None) if mark is not None: mark() stack.extend(node.children) def _get_canvas_transform(self) -> np.ndarray: """Returns the 3x3 affine canvas transform matrix for this layer.""" ox, oy = self.offset sx, sy = self.scale_val c, s = math.cos(self.rotation), math.sin(self.rotation) return np.array([ [sx * c, -sy * s, ox], [sx * s, sy * c, oy], [0.0, 0.0, 1.0], ], dtype=np.float32) def _canvas_affine(self) -> tuple[float, float, float, float, float, float]: """The canvas transform as a compact ``(a, b, c, d, tx, ty)`` affine row. Matches the ``Draw2D._xf`` layout (``x' = a*x + b*y + tx``). Shared by the legacy ``_draw_self`` push and the item builder's canvas-subtree bake so both backends apply the SAME offset/rotation/scale_val. The identity row ``(1, 0, 0, 1, 0, 0)`` for a default (unconfigured) layer is what keeps activation byte-identical for every existing scene. """ ox, oy = float(self.offset[0]), float(self.offset[1]) sx, sy = float(self.scale_val[0]), float(self.scale_val[1]) c, s = math.cos(self.rotation), math.sin(self.rotation) return (sx * c, -sy * s, sx * s, sy * c, ox, oy) def _ordered_children(self): """CanvasLayer keeps children in tree order (no z/Y/layer reorder). Returning the FAST PATH sentinel routes through ``_draw_self`` (which opens screen-space) + ``_draw_children`` (which closes it), preserving today's flat traversal. """ return None, None def _draw_self(self, renderer): """Open this layer's transform scope, then fire draw handlers. Pushes ONE transform that the whole subtree (self + children) renders under; closed by ``_draw_children`` after the walk. The scope depends on :attr:`follow_viewport`: - ``follow_viewport=False`` (default, HUD): screen-pinned. The active Camera2D is bypassed and the canvas ``offset/rotation/scale_val`` apply in screen space -- this is today's CanvasLayer semantics (an unconfigured layer pushes the identity row, so it is byte-identical). - ``follow_viewport=True``: the canvas transform composes ON TOP of the active camera, so the layer scrolls with the world (a parallax-free world band). Fractional parallax is out of scope. ``push_screen_transform`` (set-absolute, bypassing the camera) and the composing ``push_transform`` are the graphics ``Draw2D`` hooks; a recorder without them (tests, the item op-recorder) falls back to ``push_identity`` so the geometry stays local (the builder bakes the canvas affine itself). """ affine = self._canvas_affine() if self.follow_viewport and hasattr(renderer, "push_transform"): renderer.push_transform(*affine) elif hasattr(renderer, "push_screen_transform"): renderer.push_screen_transform(*affine) elif hasattr(renderer, "push_identity"): renderer.push_identity() # Per-post CanvasLayer band: if this layer carries an # ``environment``, open its post-layer scope so every op emitted by the # subtree is tagged with this band. Only the web ``Draw2D`` recorder # answers ``push_post_layer``; the desktop item pipeline bands via its own # ``layer`` column, and a plain recorder (tests) simply ignores it -- so # this is inert for every path except the web 2D walk. Closed in # :meth:`_draw_children`. if self.environment is not None and hasattr(renderer, "push_post_layer"): renderer.push_post_layer(self.layer) self._draw_dispatch(renderer) def _draw_children(self, renderer): """Walk children in tree order, then close this layer's transform scope.""" for child in self.children.safe_iter(): child._draw_recursive(renderer) if self.environment is not None and hasattr(renderer, "pop_post_layer"): renderer.pop_post_layer() if hasattr(renderer, "pop_transform"): renderer.pop_transform()