"""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()