Source code for simvx.core._drawable2d
"""The shared 2D-drawable concept.
The blanket ``Property.__set__ -> queue_redraw`` hook (``descriptors.py``) only
does anything on a node that actually defines ``queue_redraw``. The build-once
retention path rides that same hook to set a per-item dirty bit, so every 2D
drawable needs it: without it a ``Sprite2D`` colour / texture / visible change
would mark nothing dirty and item-level invalidation would never fire.
This module supplies it as a **small mixin** (:class:`Drawable2D`) carrying the
render-retention state + ``queue_redraw``. A mixin rather than a hoist onto
``Node2D``, because the drawable concept must also cover ``CanvasLayer``, which
is a ``Node`` and *not* a ``Node2D``. The mixin lets ``Node2D`` and
``CanvasLayer`` share one implementation without forcing ``CanvasLayer`` through
``Node2D``'s 2D transform cache. ``Control`` inherits it via ``Node2D`` and
*extends* ``queue_redraw`` additively, so its ``_DrawRecorder`` path keeps
working.
The two dirty bits are deliberately SEPARATE
--------------------------------------------
- :attr:`_render_dirty` -- "this drawable's geometry/appearance changed; its
item(s) must be re-collected/re-uploaded." Its lifetime is owned by the render
layer: it persists until the upload step clears it (:meth:`_clear_render_dirty`),
and is NEVER cleared by a ``world_position`` read.
- :attr:`_transform_render_dirty` -- "this drawable moved; its transform row must
be rewritten (geometry untouched -- the scroll-by-translation fast path)." Also
render-owned, also persists until the upload step clears it.
Neither is ``Node2D._transform_dirty``: that flag is cleared *lazily* by any
``world_position``/``world_transform`` read (scene-adapter / collision / audio /
culling all read it mid-frame) and ``_invalidate_transform`` short-circuits its
descendant recursion ``if not self._transform_dirty``. A render-dirty bit layered
on it would inherit that early-return and leave descendants stale after a
read-then-move sequence. So the transform-render bit gets its OWN propagation
with no such short-circuit.
Retention bits
--------------
These render-retention bits are read by the 2D item pipeline's
:class:`RenderItemCache` to decide what to re-collect, re-capture, or
patch in place each frame.
"""
from __future__ import annotations
from .descriptors import Property
[docs]
class Drawable2D:
"""Mixin: render-retention dirty state + ``queue_redraw`` for 2D drawables.
Mixed into :class:`~simvx.core.nodes_2d.node2d.Node2D` (so every sprite /
shape / Text2D / Control inherits it) and
:class:`~simvx.core.nodes_2d.canvas.CanvasLayer` (a ``Node``, covered by the
mixin rather than by a ``Node2D`` hoist).
The mixin owns no transform: it never reads ``position``/``world_transform``.
It only flips render-retention flags, which the item pipeline reads and
drains after upload.
"""
# Class-level defaults so the flags read truthy (first-frame dirty) even on a
# subclass instance whose ``__init__`` has not yet run the mixin initialiser,
# and so a plain ``hasattr`` probe never raises. Instances shadow these with
# their own attribute the moment any mark fires.
#
# ``_render_dirty`` defaults ``True`` here (overriding the base ``Node``'s
# ``False``): a freshly built 2D drawable IS dirty so the item pipeline collects
# + uploads it once. The render-dirty methods themselves (``queue_redraw`` /
# ``_clear_render_dirty`` / ``render_dirty``) and the public ``dynamic`` attribute
# live on the base ``Node`` (any node with an ``on_draw`` shares them); this mixin
# only adds the 2D-specific TRANSFORM-render bit + the auto-dirty opt-in below.
_render_dirty: bool = True
_transform_render_dirty: bool = True
# Opt into the blanket ``Property.__set__ -> queue_redraw`` auto-dirty hook
# (``descriptors.py``): a colour / text / size / anchor change on a 2D drawable
# marks it render-dirty with no per-property list. Base ``Node`` leaves this
# ``False`` so a non-2D node's Property write never dirties the 2D scan.
# (``visible`` is a plain ``@property`` on ``Node``, NOT a ``Property``, so the
# blanket hook can't see it; ``Node._propagate_visibility`` marks render-dirty
# directly on every effective-visibility flip instead.)
_render_auto_dirty: bool = True
# HDR-lane override (N1, 2D-in-HDR). ``None`` (default) = by role: a world-space
# drawable renders into the HDR target before tonemap when post-processing is on
# (so it gets exposure/tonemap/bloom, consistent with the 3D scene), while a
# screen-space drawable (HUD/UI) stays post-tonemap at authored LDR. ``True``
# forces this drawable into the HDR lane; ``False`` forces it out (the escape
# hatch for stylised flat 2D that must keep its exact authored colour). Changing
# it marks the drawable render-dirty via the blanket Property hook so the new
# lane takes effect next frame.
hdr = Property(
None,
hint="HDR participation: None=auto by role, True=tonemap/bloom this 2D, False=keep flat LDR",
)
# ``queue_redraw`` / ``_clear_render_dirty`` / ``render_dirty`` are inherited
# from the base ``Node`` (the render-dirty bit is a per-``on_draw`` concept, not
# a 2D-only one). ``Control`` still OVERRIDES ``queue_redraw`` additively to also
# poke its legacy ``_draw_dirty`` recorder cache.
# -- transform-render-dirty (moved; geometry untouched) ------------------
def _mark_transform_render_dirty(self) -> None:
"""Mark this drawable's transform row stale (move / scroll fast path).
Separate from :meth:`queue_redraw`: a move rewrites one transform row and
leaves geometry alone. Driven by
the spatial-Property / ``_ObservedVec`` path and ``rotation``'s on_change.
Has its OWN propagation to children (each subclass overrides
:meth:`_propagate_transform_render_dirty` to recurse), with **no**
``_transform_dirty`` short-circuit, so a read-then-move sequence still
re-dirties descendants.
"""
self._transform_render_dirty = True
self._propagate_transform_render_dirty()
def _propagate_transform_render_dirty(self) -> None:
"""Recurse the transform-render-dirty bit into children. Subclass hook.
The base mixin does not know the child taxonomy; ``Node2D`` overrides this
to mark its 2D descendants (geometry follows ancestor transforms), and
``CanvasLayer`` marks its whole subtree. Default: no descendants.
"""
def _clear_transform_render_dirty(self) -> None:
"""Drain the transform-render bit. Called ONLY by the upload step."""
self._transform_render_dirty = False