Source code for simvx.core.viewport

"""Viewport, SubViewport, and Display system for SimVX."""

import logging
from enum import IntEnum

import numpy as np

from .descriptors import Property
from .node import Node
from .ui.core import Control

log = logging.getLogger(__name__)

[docs] class StretchMode(IntEnum): DISABLED = 0 # Pixel-perfect, no scaling CANVAS_ITEMS = 1 # Scale 2D content, keep 3D at window resolution VIEWPORT = 2 # Scale everything (render at design res, upscale)
[docs] class StretchAspect(IntEnum): IGNORE = 0 # Stretch to fill, distort if needed KEEP = 1 # Letterbox/pillarbox to maintain aspect KEEP_WIDTH = 2 # Keep width, adjust height KEEP_HEIGHT = 3 # Keep height, adjust width EXPAND = 4 # Expand viewport, no black bars
[docs] class VSyncMode(IntEnum): DISABLED = 0 ENABLED = 1 ADAPTIVE = 2 MAILBOX = 3
[docs] class WindowMode(IntEnum): WINDOWED = 0 FULLSCREEN = 1 BORDERLESS = 2 MAXIMIZED = 3
[docs] class DisplaySettings: """Project-level display configuration. Read from simvx.toml.""" def __init__(self): self.design_width: int = 1280 self.design_height: int = 720 self.stretch_mode: StretchMode = StretchMode.DISABLED self.stretch_aspect: StretchAspect = StretchAspect.KEEP self.window_mode: WindowMode = WindowMode.WINDOWED self.vsync: VSyncMode = VSyncMode.ENABLED self.max_fps: int = 0 # 0 = unlimited self.msaa: int = 0 # 0, 2, 4, 8
[docs] def compute_canvas_transform(self, window_width: int, window_height: int) -> np.ndarray: """Compute the 2D canvas transform matrix based on stretch settings. Returns 3x3 affine matrix to apply to all 2D rendering. """ if self.stretch_mode == StretchMode.DISABLED: return np.eye(3, dtype=np.float32) dw, dh = self.design_width, self.design_height ww, wh = window_width, window_height sx = ww / dw sy = wh / dh # Apply aspect ratio correction ox, oy = 0.0, 0.0 if self.stretch_aspect == StretchAspect.IGNORE: pass # Use sx, sy as-is elif self.stretch_aspect == StretchAspect.KEEP: s = min(sx, sy) ox = (ww - dw * s) / 2 oy = (wh - dh * s) / 2 sx = sy = s elif self.stretch_aspect == StretchAspect.KEEP_WIDTH: sy = sx # Match width scale elif self.stretch_aspect == StretchAspect.KEEP_HEIGHT: sx = sy # Match height scale elif self.stretch_aspect == StretchAspect.EXPAND: s = max(sx, sy) sx = sy = s return np.array([[sx, 0, ox], [0, sy, oy], [0, 0, 1]], dtype=np.float32)
[docs] def compute_viewport_rect(self, window_width: int, window_height: int) -> tuple[int, int, int, int]: """Compute the viewport rectangle (x, y, w, h) for 3D rendering.""" if self.stretch_mode == StretchMode.DISABLED: return (0, 0, window_width, window_height) dw, dh = self.design_width, self.design_height ww, wh = window_width, window_height dar = dw / dh war = ww / wh if self.stretch_aspect == StretchAspect.KEEP: if war > dar: vh = wh vw = int(vh * dar) vx = (ww - vw) // 2 vy = 0 else: vw = ww vh = int(vw / dar) vx = 0 vy = (wh - vh) // 2 return (vx, vy, vw, vh) return (0, 0, window_width, window_height)
[docs] class SubViewport(Node): """Renders its children to an offscreen texture. The SubViewport's subtree is rendered into its own offscreen render target each frame, using its *own* camera: a ``Camera3D`` and/or ``Camera2D`` placed among its children (a sensible default is used if none is present). The rendered colour buffer is exposed as a bindless texture index via :attr:`texture`, which other nodes in the main scene can sample: assign it to a ``Material.albedo_tex_index`` for a 3D "monitor in the world", or feed a ``Sprite2D`` for a 2D minimap / picture-in-picture. The render-to-texture integration is driven from the graphics backend (``simvx.graphics.renderer.sub_viewport.SubViewportManager``), not from this node: core stays rendering-agnostic. SubViewports render *before* the main scene pass each frame, so the main pass samples fresh content the same frame. A SubViewport that samples *another* SubViewport sees the previous frame's content (one-frame lag). Properties: size: Offscreen target dimensions in pixels. transparent_bg: Clear to transparent instead of opaque black. render_target_update_mode: ``"always"`` (default, render every frame), ``"once"`` (render a single frame then freeze), or ``"disabled"`` (never render; the slot stays valid but stale). """ size = Property((256, 256)) transparent_bg = Property(False) render_target_update_mode = Property("always") def __init__(self, name="SubViewport", **kwargs): super().__init__(name=name, **kwargs) self._texture_id: int = -1 self._scene_tree = None
[docs] @property def texture(self): """The rendered texture, usable as material input or Sprite2D texture.""" return self._texture_id
[docs] @property def texture_size(self) -> tuple[int, int]: """Pixel dimensions of the underlying render target.""" return tuple(self.size)
[docs] class ViewportContainer(Control): """UI widget that hosts and displays a SubViewport.""" stretch = Property(True) def __init__(self, name="ViewportContainer", **kwargs): super().__init__(name=name, **kwargs) self._viewport: SubViewport | None = None
[docs] def set_viewport(self, viewport: SubViewport): self._viewport = viewport if self.stretch: viewport.size = (int(self.size.x), int(self.size.y))