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