"""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