Source code for simvx.core._drawable2d

"""The shared 2D-drawable concept (design §7.6 / §2.7, P2 retention keystone).

P0 gate 3 proved the blanket ``Property.__set__ -> queue_redraw`` hook
(``descriptors.py``) is **silently a no-op for every ``Node2D``** today: only
``Control`` defined ``queue_redraw``, so a ``Sprite2D`` colour / texture / visible
change marked nothing dirty. The build-once retention design (§2.7) rides that
same blanket hook to set a per-item dirty bit, so item-level invalidation for
sprites is dead until the hook actually fires on a ``Node2D``.

This module resolves the §7.6 "shared drawable base-class vs mixin vs hoist"
question as a **small mixin** (:class:`Drawable2D`) carrying the render-retention
state + ``queue_redraw``. The mixin (not "hoist onto ``Node2D``") is chosen
because the drawable concept must also cover ``CanvasLayer``, which is a ``Node``,
*not* a ``Node2D`` (design §5.6 / §7.6: "a plain hoist onto ``Node2D`` would miss
it"). A 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 (its legacy
``_DrawRecorder`` path keeps working).

The two dirty bits are deliberately SEPARATE (design §2.7, P0 gate 3b)
---------------------------------------------------------------------
- :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 (the exact P0 gate 3b adversarial case). 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` (P2) 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 a ``Node2D`` hoist -- design §7.6). The mixin owns no transform: it never reads ``position``/``world_transform``. It only flips render-retention flags. The item pipeline reads the flags + drains them after upload; the legacy path ignores them. """ # 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: bool = True _transform_render_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", ) # -- render-dirty (appearance/geometry changed) --------------------------
[docs] def queue_redraw(self) -> None: """Mark this drawable's item(s) dirty (re-collect / re-upload next frame). The blanket ``Property.__set__`` hook (``descriptors.py``) calls this on *any* changed Property when the owner defines it -- which is now every ``Node2D`` (colour, text, font_scale, visible, texture refs, size/anchor/ margin on Controls, ...). It is also the manual escape hatch for an ``on_draw`` body that reads non-Property state (the ``dynamic`` cases). Idempotent and cheap: a no-op once already dirty. """ self._render_dirty = True
def _clear_render_dirty(self) -> None: """Drain the render-dirty bit. Called ONLY by the upload step (design §2.7). Never called by a ``world_position`` read -- that is the whole point of the separate lifetime (P0 gate 3b). """ self._render_dirty = False
[docs] @property def render_dirty(self) -> bool: """Whether appearance/geometry changed since the last upload (introspection).""" return self._render_dirty
# -- 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 (design §2.7 transform-only granularity). 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 (P0 gate 3b). """ 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
[docs] @property def transform_render_dirty(self) -> bool: """Whether the transform row needs a rewrite since the last upload.""" return self._transform_render_dirty