Source code for simvx.editor.panels.inspector_sections._camera_section

"""CameraPreviewSection -- camera viewport preview.

Registered with the section registry via @register_inspector_section at
import time.
"""

import logging

import numpy as np

from simvx.core import Camera2D, Camera3D, Control, Label, Node, Vec2

from ._base import (
    InspectorSection,
    _font_size,
    _row_h,
    register_inspector_section,
)

log = logging.getLogger(__name__)

_PREVIEW_W = 200.0
_PREVIEW_H = 120.0
_PREVIEW_BG = (0.08, 0.08, 0.12, 1.0)
_PREVIEW_BORDER = (0.35, 0.45, 0.6, 1.0)
_PREVIEW_TEXT = (0.5, 0.55, 0.65, 1.0)
_INFO_TEXT = (0.7, 0.7, 0.7, 1.0)

class _CameraPreview(Control):
    """Live camera preview widget -- captures the scene and displays it as a texture.

    On first draw (and when camera properties change), captures the current
    framebuffer, downsamples to preview size, uploads as a GPU texture, and
    draws it.  Falls back to a placeholder when no engine is available.
    """

    _draw_caching = False
    _REFRESH_INTERVAL = 30  # re-capture every N frames

    def __init__(self, camera_node: Node, **kwargs):
        super().__init__(**kwargs)
        self._camera = camera_node
        self.size = Vec2(_PREVIEW_W, _PREVIEW_H)
        self._texture_id: int = -1
        self._last_props: str = ""
        self._frame_counter: int = 0
        self._capture_attempted: bool = False

    def _camera_info(self) -> str:
        """Build a one-line summary of camera parameters."""
        cam = self._camera
        if isinstance(cam, Camera3D):
            return f"Perspective  FOV: {cam.fov:.0f}°  Near: {cam.near}  Far: {cam.far}"
        if isinstance(cam, Camera2D):
            parts = [f"Zoom: {cam.zoom:.1f}x"]
            has_limits = (
                cam.limit_left > -1e8
                or cam.limit_right < 1e8
                or cam.limit_top > -1e8
                or cam.limit_bottom < 1e8
            )
            if has_limits:
                parts.append(
                    f"Limits: L={cam.limit_left:.0f} R={cam.limit_right:.0f} "
                    f"T={cam.limit_top:.0f} B={cam.limit_bottom:.0f}"
                )
            return "  ".join(parts)
        return ""

    def _camera_prop_key(self) -> str:
        """Fingerprint camera properties to detect changes."""
        cam = self._camera
        if isinstance(cam, Camera3D):
            pos = cam.world_position
            return f"{cam.fov},{cam.near},{cam.far},{pos[0]:.2f},{pos[1]:.2f},{pos[2]:.2f}"
        if isinstance(cam, Camera2D):
            pos = cam.world_position
            return f"{cam.zoom},{pos[0]:.2f},{pos[1]:.2f}"
        return ""

    def _try_capture(self) -> bool:
        """Attempt to capture the current framebuffer and upload as a texture.

        Returns True if capture succeeded, False otherwise.
        """
        cam = self._camera
        app = getattr(cam, "app", None)
        if app is None:
            return False
        engine = getattr(app, "_engine", None) or getattr(app, "engine", None)
        renderer = getattr(engine, "renderer", None) if engine is not None else None
        if renderer is None or not hasattr(renderer, "capture_frame"):
            return False
        try:
            pixels = renderer.capture_frame()  # (H, W, 4) uint8
            if pixels is None or pixels.size == 0:
                return False
            # Downsample to preview dimensions
            ph, pw = int(_PREVIEW_H), int(_PREVIEW_W)
            src_h, src_w = pixels.shape[:2]
            if src_h < 2 or src_w < 2:
                return False
            # Simple strided downsample (fast, no PIL dependency)
            step_y = max(1, src_h // ph)
            step_x = max(1, src_w // pw)
            small = pixels[::step_y, ::step_x][:ph, :pw]
            # Ensure exact preview size via padding/cropping
            final = np.zeros((ph, pw, 4), dtype=np.uint8)
            copy_h = min(small.shape[0], ph)
            copy_w = min(small.shape[1], pw)
            final[:copy_h, :copy_w] = small[:copy_h, :copy_w]
            self._texture_id = engine.upload_texture_pixels(
                np.ascontiguousarray(final), pw, ph,
            )
            return True
        except Exception:
            log.debug("Camera preview capture failed", exc_info=True)
            return False

    def on_process(self, dt: float):
        self._frame_counter += 1
        # Capture on first process, then periodically or on property change
        props = self._camera_prop_key()
        needs_refresh = (
            self._texture_id < 0
            or props != self._last_props
            or self._frame_counter % self._REFRESH_INTERVAL == 0
        )
        if needs_refresh and self._frame_counter > 1:
            if self._try_capture():
                self._last_props = props

    def on_draw(self, renderer):
        x, y, w, h = self.get_global_rect()
        info = self._camera_info()

        if self._texture_id >= 0:
            # Draw captured preview texture
            renderer.draw_rect((x, y), (w, _PREVIEW_H), colour=_PREVIEW_BG, filled=True)
            renderer.draw_texture(self._texture_id, x, y, w, _PREVIEW_H)
            renderer.draw_rect((x, y), (w, _PREVIEW_H), colour=_PREVIEW_BORDER)
        else:
            # Fallback placeholder
            renderer.draw_rect((x, y), (w, _PREVIEW_H), colour=_PREVIEW_BG, filled=True)
            renderer.draw_rect((x, y), (w, _PREVIEW_H), colour=_PREVIEW_BORDER)
            scale = _font_size() / 14.0
            label = "Camera Preview"
            tw = renderer.text_width(label, scale)
            renderer.draw_text(
                label,
                (x + (w - tw) / 2, y + (_PREVIEW_H - _font_size()) / 2),
                colour=_PREVIEW_TEXT, scale=scale,
            )

        # Camera info line below the preview rectangle
        if info:
            info_scale = (_font_size() - 1) / 14.0
            renderer.draw_text(info, (x + 2, y + _PREVIEW_H + 2), colour=_INFO_TEXT, scale=info_scale)

[docs] @register_inspector_section class CameraPreviewSection(InspectorSection): """Shows a live camera preview (captured framebuffer) and key camera parameters.""" section_title = "Camera Preview" priority = 2
[docs] def can_handle(self, node): return isinstance(node, Camera2D | Camera3D)
[docs] def handled_properties(self, node): return set()
[docs] def build_rows(self, node, ctx): rows: list[Control] = [] # Preview label lbl = Label("Preview") lbl.font_size = _font_size() lbl.size = Vec2(_PREVIEW_W, _row_h()) rows.append(lbl) # Camera preview widget preview = _CameraPreview(node) # Reserve extra height for the info text below info_text = preview._camera_info() extra = _font_size() * 1.4 if info_text else 0.0 preview.size = Vec2(_PREVIEW_W, _PREVIEW_H + extra) rows.append(preview) ctx.register_widget("camera_preview", preview) return rows