"""Render window — separate photorealistic rendering of a selected shape. Opens as an independent QMainWindow so it doesn't clutter the workspace. Uses a swappable RenderBackend (default: Mitsuba 3). """ from __future__ import annotations import logging import os from typing import Optional import numpy as np from PySide6.QtCore import Qt, Signal, Slot, QThread, QTimer from PySide6.QtGui import QImage, QPixmap from PySide6.QtWidgets import ( QColorDialog, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QProgressBar, QPushButton, QSizePolicy, QSlider, QSplitter, QSpinBox, QVBoxLayout, QWidget, ) from fluency.rendering.material_presets import get_preset, preset_names from fluency.rendering.occ_to_mesh import occ_shape_bounds, occ_shape_to_ply from fluency.rendering.render_backend import ( GroundPlaneConfig, LightingConfig, RenderBackend, RenderCamera, RenderMaterial, RenderSettings, ) logger = logging.getLogger(__name__) # ── Background render thread ──────────────────────────────────────── class _RenderThread(QThread): """Background thread for Mitsuba rendering.""" finished = Signal(np.ndarray) error = Signal(str) progress = Signal(float) def __init__( self, backend: RenderBackend, mesh_path: str, material: RenderMaterial, camera: RenderCamera, settings: RenderSettings, parent=None, is_preview: bool = False, ): super().__init__(parent) self._backend = backend self._mesh_path = mesh_path self._material = material self._camera = camera self._settings = settings self._is_preview = is_preview self._cancelled = False def cancel(self): self._cancelled = True def run(self): try: if self._is_preview: image = self._backend.render_preview( self._mesh_path, self._material, self._camera, self._settings, ) else: image = self._backend.render( self._mesh_path, self._material, self._camera, self._settings, progress_callback=lambda p: self.progress.emit(p), ) if not self._cancelled: self.finished.emit(image) except Exception as e: if not self._cancelled: self.error.emit(str(e)) # ── Render window ─────────────────────────────────────────────────── class RenderWindow(QMainWindow): """Standalone render window for photorealistic rendering. Usage:: from fluency.ui.render_window import RenderWindow win = RenderWindow(shape=some_topods_shape, parent=main_window) win.show() """ def __init__( self, shape=None, backend: Optional[RenderBackend] = None, camera: Optional[RenderCamera] = None, parent=None, ): super().__init__(parent) self._shape = shape self._backend = backend self._mesh_path: Optional[str] = None # Rendering threads & images self._render_thread: Optional[_RenderThread] = None self._preview_thread: Optional[_RenderThread] = None self._last_image: Optional[np.ndarray] = None # full render result self._last_preview: Optional[np.ndarray] = None # preview result self._camera: Optional[RenderCamera] = camera self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5) # Which operation is currently running? "render" | "preview" | None self._active_mode: Optional[str] = None # Auto-preview debounce timer (500 ms) self._auto_preview_timer: Optional[QTimer] = None self.setWindowTitle("Render") self.setMinimumSize(800, 600) self.resize(1024, 768) self.setAttribute(Qt.WA_DeleteOnClose) self._init_ui() self._init_backend() self._setup_auto_preview() self._prepare_mesh() self._populate_camera_controls() # ── UI Setup ──────────────────────────────────────────────────── def _init_ui(self): central = QWidget() self.setCentralWidget(central) main_layout = QHBoxLayout(central) main_layout.setContentsMargins(8, 8, 8, 8) main_layout.setSpacing(8) # ── Left sidebar: Render Settings ─────────────────────────── self._settings_panel = self._build_settings_panel() main_layout.addWidget(self._settings_panel) # ── Right side: Image + Controls ──────────────────────────── right = QVBoxLayout() right.setSpacing(6) # Image preview area with status badge below self._image_container = QVBoxLayout() self._image_container.setContentsMargins(0, 0, 0, 0) self._image_label = QLabel("Click Preview or Render to start") self._image_label.setAlignment(Qt.AlignCenter) self._image_label.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Expanding ) self._image_label.setMinimumSize(400, 300) self._image_label.setStyleSheet( "background: #1e1e2e; color: #6c7086; font-size: 14px;" ) self._image_container.addWidget(self._image_label, stretch=1) # Status badge ("Preview" / "Full Render") — shown below image self._status_badge = QLabel("") self._status_badge.setAlignment(Qt.AlignCenter) self._status_badge.setStyleSheet( "color: #a6adc8; font-size: 11px; padding: 2px;" ) self._image_container.addWidget(self._status_badge) right.addLayout(self._image_container, stretch=1) # Controls row controls = QHBoxLayout() controls.setSpacing(8) # Material preset controls.addWidget(QLabel("Material:")) self._material_combo = QComboBox() for name in preset_names(): self._material_combo.addItem(name) self._material_combo.setCurrentText("Brushed Steel") self._material_combo.setMinimumWidth(140) controls.addWidget(self._material_combo) # Auto-preview checkbox self._auto_preview_cb = QCheckBox("Auto-preview") self._auto_preview_cb.setChecked(True) self._auto_preview_cb.setToolTip( "Automatically preview when settings change" ) controls.addWidget(self._auto_preview_cb) # Samples per pixel controls.addWidget(QLabel("Quality:")) self._spp_spin = QSpinBox() self._spp_spin.setRange(16, 4096) self._spp_spin.setValue(256) self._spp_spin.setSingleStep(64) self._spp_spin.setSuffix(" spp") self._spp_spin.setMinimumWidth(100) controls.addWidget(self._spp_spin) # Resolution controls.addWidget(QLabel("Resolution:")) self._res_combo = QComboBox() self._res_combo.addItems( ["1280×720", "1920×1080", "2560×1440", "3840×2160"] ) self._res_combo.setCurrentText("1920×1080") controls.addWidget(self._res_combo) controls.addStretch() # Preview button (fast, low quality) self._preview_btn = QPushButton("⚡ Preview") self._preview_btn.setMinimumWidth(90) self._preview_btn.clicked.connect(self._on_preview) controls.addWidget(self._preview_btn) # Render button (full quality) self._render_btn = QPushButton("▶ Render") self._render_btn.setMinimumWidth(100) self._render_btn.clicked.connect(self._on_render) controls.addWidget(self._render_btn) # Cancel button self._cancel_btn = QPushButton("⏹ Cancel") self._cancel_btn.setMinimumWidth(80) self._cancel_btn.setEnabled(False) self._cancel_btn.clicked.connect(self._on_cancel) controls.addWidget(self._cancel_btn) # Export button self._export_btn = QPushButton("💾 Export") self._export_btn.setMinimumWidth(80) self._export_btn.setEnabled(False) self._export_btn.clicked.connect(self._on_export) controls.addWidget(self._export_btn) right.addLayout(controls) # Progress bar self._progress = QProgressBar() self._progress.setRange(0, 100) self._progress.setValue(0) self._progress.setTextVisible(True) self._progress.setFormat("%p%") right.addWidget(self._progress) main_layout.addLayout(right, stretch=1) # ── Settings panel builder ───────────────────────────────────── def _build_settings_panel(self) -> QWidget: """Build the left sidebar with camera, lighting, and ground plane controls.""" panel = QWidget() panel.setFixedWidth(240) layout = QVBoxLayout(panel) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(8) # ── Camera Parameters ─────────────────────────────────────── camera_gb = QGroupBox("Camera") camera_layout = QVBoxLayout(camera_gb) camera_layout.setSpacing(4) # Origin origin_grid = QGridLayout() origin_grid.addWidget(QLabel("Origin"), 0, 0) self._cam_origin_x = QDoubleSpinBox() # type: ignore[attr-defined] self._cam_origin_y = QDoubleSpinBox() # type: ignore[attr-defined] self._cam_origin_z = QDoubleSpinBox() # type: ignore[attr-defined] for spin in (self._cam_origin_x, self._cam_origin_y, self._cam_origin_z): spin.setRange(-10000, 10000) spin.setDecimals(1) spin.setSuffix(" mm") spin.setFixedWidth(80) origin_grid.addWidget(QLabel("X:"), 1, 0) origin_grid.addWidget(self._cam_origin_x, 1, 1) origin_grid.addWidget(QLabel("Y:"), 2, 0) origin_grid.addWidget(self._cam_origin_y, 2, 1) origin_grid.addWidget(QLabel("Z:"), 3, 0) origin_grid.addWidget(self._cam_origin_z, 3, 1) camera_layout.addLayout(origin_grid) # Target target_grid = QGridLayout() target_grid.addWidget(QLabel("Target"), 0, 0) self._cam_target_x = QDoubleSpinBox() # type: ignore[attr-defined] self._cam_target_y = QDoubleSpinBox() # type: ignore[attr-defined] self._cam_target_z = QDoubleSpinBox() # type: ignore[attr-defined] for spin in (self._cam_target_x, self._cam_target_y, self._cam_target_z): spin.setRange(-10000, 10000) spin.setDecimals(1) spin.setSuffix(" mm") spin.setFixedWidth(80) target_grid.addWidget(QLabel("X:"), 1, 0) target_grid.addWidget(self._cam_target_x, 1, 1) target_grid.addWidget(QLabel("Y:"), 2, 0) target_grid.addWidget(self._cam_target_y, 2, 1) target_grid.addWidget(QLabel("Z:"), 3, 0) target_grid.addWidget(self._cam_target_z, 3, 1) camera_layout.addLayout(target_grid) # Up vector up_grid = QGridLayout() up_grid.addWidget(QLabel("Up"), 0, 0) self._cam_up_x = QDoubleSpinBox() # type: ignore[attr-defined] self._cam_up_y = QDoubleSpinBox() # type: ignore[attr-defined] self._cam_up_z = QDoubleSpinBox() # type: ignore[attr-defined] for spin in (self._cam_up_x, self._cam_up_y, self._cam_up_z): spin.setRange(-10, 10) spin.setDecimals(1) spin.setFixedWidth(80) up_grid.addWidget(QLabel("X:"), 1, 0) up_grid.addWidget(self._cam_up_x, 1, 1) up_grid.addWidget(QLabel("Y:"), 2, 0) up_grid.addWidget(self._cam_up_y, 2, 1) up_grid.addWidget(QLabel("Z:"), 3, 0) up_grid.addWidget(self._cam_up_z, 3, 1) camera_layout.addLayout(up_grid) # FOV fov_row = QHBoxLayout() fov_row.addWidget(QLabel("FOV:")) self._cam_fov_spin = QDoubleSpinBox() # type: ignore[attr-defined] self._cam_fov_spin.setRange(1, 179) self._cam_fov_spin.setValue(45.0) self._cam_fov_spin.setSuffix("°") self._cam_fov_spin.setFixedWidth(80) fov_row.addWidget(self._cam_fov_spin) fov_row.addStretch() camera_layout.addLayout(fov_row) # Reset button reset_cam_btn = QPushButton("Reset to Viewport") reset_cam_btn.clicked.connect(self._reset_camera_to_viewport) camera_layout.addWidget(reset_cam_btn) layout.addWidget(camera_gb) # ── Lighting ──────────────────────────────────────────────── light_gb = QGroupBox("Lighting") light_layout = QVBoxLayout(light_gb) light_layout.setSpacing(4) # Ambient intensity slider ambient_row = QHBoxLayout() ambient_row.addWidget(QLabel("Ambient:")) self._light_ambient_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined] self._light_ambient_slider.setRange(0, 100) self._light_ambient_slider.setValue(30) ambient_row.addWidget(self._light_ambient_slider) self._light_ambient_label = QLabel("0.30") self._light_ambient_label.setFixedWidth(40) ambient_row.addWidget(self._light_ambient_label) light_layout.addLayout(ambient_row) # Key light key_row = QHBoxLayout() key_row.addWidget(QLabel("Key:")) self._light_key_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined] self._light_key_slider.setRange(0, 200) self._light_key_slider.setValue(70) key_row.addWidget(self._light_key_slider) self._light_key_label = QLabel("3.5") self._light_key_label.setFixedWidth(40) key_row.addWidget(self._light_key_label) light_layout.addLayout(key_row) # Fill light fill_row = QHBoxLayout() fill_row.addWidget(QLabel("Fill:")) self._light_fill_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined] self._light_fill_slider.setRange(0, 200) self._light_fill_slider.setValue(30) fill_row.addWidget(self._light_fill_slider) self._light_fill_label = QLabel("1.5") self._light_fill_label.setFixedWidth(40) fill_row.addWidget(self._light_fill_label) light_layout.addLayout(fill_row) # Rim light rim_row = QHBoxLayout() rim_row.addWidget(QLabel("Rim:")) self._light_rim_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined] self._light_rim_slider.setRange(0, 200) self._light_rim_slider.setValue(24) rim_row.addWidget(self._light_rim_slider) self._light_rim_label = QLabel("1.2") self._light_rim_label.setFixedWidth(40) rim_row.addWidget(self._light_rim_label) light_layout.addLayout(rim_row) # Lighting presets preset_row = QHBoxLayout() preset_row.addWidget(QLabel("Preset:")) self._light_preset_combo = QComboBox() self._light_preset_combo.addItems([ "Studio (default)", "Soft", "Dramatic", "Bright", ]) self._light_preset_combo.setCurrentIndex(0) self._light_preset_combo.currentTextChanged.connect( self._on_lighting_preset_changed ) preset_row.addWidget(self._light_preset_combo) light_layout.addLayout(preset_row) # Wire slider value changes to labels and auto-preview self._wire_light_slider( self._light_ambient_slider, self._light_ambient_label, 100 ) self._wire_light_slider( self._light_key_slider, self._light_key_label, 50 ) self._wire_light_slider( self._light_fill_slider, self._light_fill_label, 50 ) self._wire_light_slider( self._light_rim_slider, self._light_rim_label, 50 ) layout.addWidget(light_gb) # ── Ground Plane ──────────────────────────────────────────── ground_gb = QGroupBox("Ground Plane") ground_layout = QVBoxLayout(ground_gb) ground_layout.setSpacing(4) # Enabled checkbox self._ground_enabled_cb = QCheckBox("Enabled") self._ground_enabled_cb.setChecked(False) self._ground_enabled_cb.toggled.connect( lambda: self._toggle_ground_plane_widgets() ) ground_layout.addWidget(self._ground_enabled_cb) # Color button color_row = QHBoxLayout() color_row.addWidget(QLabel("Color:")) self._ground_color_btn = QPushButton() self._ground_color_btn.setFixedSize(40, 24) self._ground_color_btn.setStyleSheet( "background-color: rgb(128, 128, 128); border: 1px solid #555;" ) self._ground_color_btn.clicked.connect(self._on_ground_color_pick) color_row.addWidget(self._ground_color_btn) color_row.addStretch() ground_layout.addLayout(color_row) # Roughness slider rough_row = QHBoxLayout() rough_row.addWidget(QLabel("Roughness:")) self._ground_rough_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined] self._ground_rough_slider.setRange(0, 100) self._ground_rough_slider.setValue(80) rough_row.addWidget(self._ground_rough_slider) self._ground_rough_label = QLabel("0.80") self._ground_rough_label.setFixedWidth(40) rough_row.addWidget(self._ground_rough_label) ground_layout.addLayout(rough_row) # Distance below slider dist_row = QHBoxLayout() dist_row.addWidget(QLabel("Offset:")) self._ground_dist_spin = QDoubleSpinBox() # type: ignore[attr-defined] self._ground_dist_spin.setRange(0, 500) self._ground_dist_spin.setValue(0.0) self._ground_dist_spin.setSuffix(" mm") self._ground_dist_spin.setFixedWidth(80) dist_row.addWidget(self._ground_dist_spin) dist_row.addStretch() ground_layout.addLayout(dist_row) # Wire roughness slider to label and auto-preview self._ground_rough_slider.valueChanged.connect( lambda v: ( self._ground_rough_label.setText(f"{v / 100:.2f}"), self._schedule_auto_preview(), ) ) # Wire distance spinbox to auto-preview self._ground_dist_spin.valueChanged.connect( lambda: self._schedule_auto_preview() ) # Initially disable ground widgets since checkbox is unchecked self._toggle_ground_plane_widgets() layout.addWidget(ground_gb) layout.addStretch() return panel def _wire_light_slider(self, slider, label, divisor): """Connect a lighting slider to its value label and auto-preview.""" slider.valueChanged.connect( lambda v: ( label.setText(f"{v / divisor:.1f}"), self._schedule_auto_preview(), ) ) def _toggle_ground_plane_widgets(self): """Enable/disable ground plane widgets based on checkbox.""" enabled = self._ground_enabled_cb.isChecked() for w in ( self._ground_color_btn, self._ground_rough_slider, self._ground_dist_spin, ): w.setEnabled(enabled) if enabled: self._schedule_auto_preview() def _on_ground_color_pick(self): """Open color dialog for ground plane color.""" current = self._ground_color # type: ignore[attr-defined] color = QColorDialog.getColor( QColor(*[int(c * 255) for c in current]), self ) if color.isValid(): r, g, b = color.red() / 255.0, color.green() / 255.0, color.blue() / 255.0 self._ground_color = (r, g, b) # type: ignore[attr-defined] self._ground_color_btn.setStyleSheet( f"background-color: rgb({color.red()}, {color.green()}, {color.blue()}); " "border: 1px solid #555;" ) self._schedule_auto_preview() def _on_lighting_preset_changed(self, preset_name: str): """Apply a lighting preset by setting slider values.""" presets = { "Studio (default)": {"ambient": 30, "key": 70, "fill": 30, "rim": 24}, "Soft": {"ambient": 50, "key": 40, "fill": 40, "rim": 15}, "Dramatic": {"ambient": 10, "key": 120, "fill": 15, "rim": 60}, "Bright": {"ambient": 70, "key": 100, "fill": 70, "rim": 40}, } p = presets.get(preset_name) if p: self._light_ambient_slider.setValue(p["ambient"]) self._light_key_slider.setValue(p["key"]) self._light_fill_slider.setValue(p["fill"]) self._light_rim_slider.setValue(p["rim"]) def _reset_camera_to_viewport(self): """Reset camera parameters to match the 3D viewport.""" if self._camera is None: return o = self._camera.origin t = self._camera.target u = self._camera.up self._cam_origin_x.setValue(o[0]) self._cam_origin_y.setValue(o[1]) self._cam_origin_z.setValue(o[2]) self._cam_target_x.setValue(t[0]) self._cam_target_y.setValue(t[1]) self._cam_target_z.setValue(t[2]) self._cam_up_x.setValue(u[0]) self._cam_up_y.setValue(u[1]) self._cam_up_z.setValue(u[2]) self._cam_fov_spin.setValue(self._camera.fov) self._schedule_auto_preview() def _init_backend(self): """Select the best available backend.""" if self._backend is not None: return from fluency.rendering.mitsuba_backend import MitsubaBackend mitsuba = MitsubaBackend() if mitsuba.is_available(): self._backend = mitsuba logger.info(f"Using render backend: {mitsuba.name()}") return # No backend available — disable both buttons self._backend = None self._render_btn.setEnabled(False) self._render_btn.setToolTip( "No render backend installed (pip install mitsuba)" ) self._preview_btn.setEnabled(False) self._preview_btn.setToolTip( "No render backend installed (pip install mitsuba)" ) def _prepare_mesh(self): """Tessellate the OCC shape to a PLY file for the renderer.""" if self._shape is None: return try: self._mesh_path = occ_shape_to_ply( self._shape, linear_deflection=0.1, angular_deflection=0.15 ) # Use externally-provided camera (from 3D viewport), or compute default if self._camera is None: mn, mx = occ_shape_bounds(self._shape) self._camera = self._backend.default_camera_from_bounds(mn, mx) logger.info(f"Prepared mesh: {self._mesh_path}") except Exception as e: logger.error(f"Failed to prepare mesh: {e}") QMessageBox.warning( self, "Render Error", f"Failed to tessellate shape:\n{e}" ) # ── Auto-preview setup ──────────────────────────────────────────── def _setup_auto_preview(self): """Set up debounced auto-preview on settings changes.""" self._auto_preview_timer = QTimer(self) self._auto_preview_timer.setSingleShot(True) self._auto_preview_timer.timeout.connect(self._on_preview) # Wire material combo to trigger debounced preview self._material_combo.currentTextChanged.connect( lambda: self._schedule_auto_preview() ) # Wire camera controls to trigger debounced preview for spin in ( self._cam_origin_x, self._cam_origin_y, self._cam_origin_z, self._cam_target_x, self._cam_target_y, self._cam_target_z, self._cam_up_x, self._cam_up_y, self._cam_up_z, self._cam_fov_spin, ): spin.valueChanged.connect(lambda: self._schedule_auto_preview()) def _populate_camera_controls(self): """Fill camera spinboxes from the current RenderCamera.""" if self._camera is None: return o = self._camera.origin t = self._camera.target u = self._camera.up self._cam_origin_x.setValue(o[0]) self._cam_origin_y.setValue(o[1]) self._cam_origin_z.setValue(o[2]) self._cam_target_x.setValue(t[0]) self._cam_target_y.setValue(t[1]) self._cam_target_z.setValue(t[2]) self._cam_up_x.setValue(u[0]) self._cam_up_y.setValue(u[1]) self._cam_up_z.setValue(u[2]) self._cam_fov_spin.setValue(self._camera.fov) def _schedule_auto_preview(self): """Schedule a preview after debounce delay if auto-preview is enabled.""" if not self._auto_preview_cb.isChecked(): return # Don't schedule if something is already rendering if self._active_mode is not None: return if self._backend is None or self._mesh_path is None: return self._auto_preview_timer.start(500) # ── Config builders from UI controls ─────────────────────────── def _build_camera_from_ui(self) -> RenderCamera: """Build a RenderCamera from the camera control widgets.""" return RenderCamera( origin=( self._cam_origin_x.value(), self._cam_origin_y.value(), self._cam_origin_z.value(), ), target=( self._cam_target_x.value(), self._cam_target_y.value(), self._cam_target_z.value(), ), up=( self._cam_up_x.value(), self._cam_up_y.value(), self._cam_up_z.value(), ), fov=self._cam_fov_spin.value(), ) def _build_lighting_config(self) -> LightingConfig: """Build a LightingConfig from the lighting control widgets.""" return LightingConfig( ambient_intensity=self._light_ambient_slider.value() / 100.0, key_intensity=self._light_key_slider.value() / 50.0, fill_intensity=self._light_fill_slider.value() / 50.0, rim_intensity=self._light_rim_slider.value() / 50.0, ) def _build_ground_plane_config(self) -> GroundPlaneConfig: """Build a GroundPlaneConfig from the ground plane widgets.""" return GroundPlaneConfig( enabled=self._ground_enabled_cb.isChecked(), color=self._ground_color, roughness=self._ground_rough_slider.value() / 100.0, distance_below=self._ground_dist_spin.value(), ) # ── Helpers ─────────────────────────────────────────────────────── def _display_image(self, image: np.ndarray, mode: str) -> None: """Display an image in the label with a status badge. *mode* is 'preview' or 'render'. """ h, w = image.shape[:2] rgb = (np.clip(image, 0.0, 1.0) * 255).astype(np.uint8) qimg = QImage(rgb.data, w, h, 3 * w, QImage.Format_RGB888) pixmap = QPixmap.fromImage(qimg) # Scale to fit label scaled = pixmap.scaled( self._image_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation, ) self._image_label.setPixmap(scaled) if mode == "preview": self._status_badge.setText("⚡ Preview — low quality") self._status_badge.setStyleSheet( "color: #f9e2af; font-size: 11px; padding: 2px;" ) else: self._status_badge.setText("✓ Full render complete") self._status_badge.setStyleSheet( "color: #a6e3a1; font-size: 11px; padding: 2px;" ) def _cancel_active_thread(self): """Cancel whichever thread is currently running.""" if self._active_mode == "preview" and self._preview_thread: self._preview_thread.cancel() self._preview_thread.terminate() self._preview_thread.wait(2000) elif self._active_mode == "render" and self._render_thread: self._render_thread.cancel() self._render_thread.terminate() self._render_thread.wait(2000) self._active_mode = None def _set_buttons_rendering(self, mode: str): """Disable buttons while rendering.""" self._preview_btn.setEnabled(False) self._render_btn.setEnabled(False) self._cancel_btn.setEnabled(True) self._export_btn.setEnabled(False) self._progress.setValue(0) self._active_mode = mode if mode == "preview": self._image_label.setText("Previewing...") else: self._image_label.setText("Rendering...") self._image_label.setPixmap(QPixmap()) def _set_buttons_idle(self): """Re-enable buttons after rendering.""" self._preview_btn.setEnabled(True) self._render_btn.setEnabled(True) self._cancel_btn.setEnabled(False) self._active_mode = None # ── Slots ─────────────────────────────────────────────────────── @Slot() def _on_preview(self): """Start a fast low-quality preview.""" if self._backend is None or self._mesh_path is None: return # Cancel any running operation first self._cancel_active_thread() material = get_preset(self._material_combo.currentText()) res_text = self._res_combo.currentText() w, h = [int(x) for x in res_text.split("×")] settings = RenderSettings( width=w, height=h, spp=self._spp_spin.value(), lighting=self._build_lighting_config(), ground_plane=self._build_ground_plane_config(), ) camera = self._build_camera_from_ui() self._set_buttons_rendering("preview") self._preview_thread = _RenderThread( self._backend, self._mesh_path, material, camera, settings, parent=self, is_preview=True, ) self._preview_thread.finished.connect(self._on_preview_done) self._preview_thread.error.connect(self._on_render_error) self._preview_thread.start() @Slot() def _on_render(self): """Start a full-quality render.""" if self._backend is None: QMessageBox.warning( self, "No Backend", "No render backend available.\nInstall with: pip install mitsuba", ) return if self._mesh_path is None: QMessageBox.warning(self, "No Shape", "No shape to render.") return # Cancel any running preview first self._cancel_active_thread() material = get_preset(self._material_combo.currentText()) spp = self._spp_spin.value() res_text = self._res_combo.currentText() w, h = [int(x) for x in res_text.split("×")] settings = RenderSettings( width=w, height=h, spp=spp, lighting=self._build_lighting_config(), ground_plane=self._build_ground_plane_config(), ) camera = self._build_camera_from_ui() self._set_buttons_rendering("render") self._render_thread = _RenderThread( self._backend, self._mesh_path, material, camera, settings, parent=self, is_preview=False, ) self._render_thread.finished.connect(self._on_render_done) self._render_thread.error.connect(self._on_render_error) self._render_thread.progress.connect(self._on_render_progress) self._render_thread.start() @Slot() def _on_cancel(self): """Cancel the running render/preview.""" self._cancel_active_thread() self._set_buttons_idle() self._image_label.setText("Cancelled") self._status_badge.setText("") self._progress.setValue(0) @Slot(np.ndarray) def _on_preview_done(self, image: np.ndarray): """Display the preview result.""" self._last_preview = image self._set_buttons_idle() self._export_btn.setEnabled(False) # export uses full render only self._progress.setValue(100) self._display_image(image, "preview") @Slot(np.ndarray) def _on_render_done(self, image: np.ndarray): """Display the full render result.""" self._last_image = image self._set_buttons_idle() self._export_btn.setEnabled(True) self._progress.setValue(100) self._display_image(image, "render") @Slot(str) def _on_render_error(self, msg: str): """Handle render/preview error.""" self._set_buttons_idle() self._export_btn.setEnabled(False) self._image_label.setText(f"Error: {msg}") self._status_badge.setText("") self._progress.setValue(0) QMessageBox.critical(self, "Render Error", msg) @Slot(float) def _on_render_progress(self, fraction: float): self._progress.setValue(int(fraction * 100)) @Slot() def _on_export(self): """Export the last full-rendered image to file.""" if self._last_image is None: return path, filt = QFileDialog.getSaveFileName( self, "Export Render", "", "PNG Image (*.png);;EXR Image (*.exr);;JPEG Image (*.jpg)", ) if not path: return try: self._backend.export_image(self._last_image, path) QMessageBox.information(self, "Exported", f"Saved to:\n{path}") except Exception as e: QMessageBox.critical(self, "Export Error", str(e)) # ── Event handlers ──────────────────────────────────────────────── def resizeEvent(self, event): """Re-scale the image when the window resizes.""" super().resizeEvent(event) current_image = self._last_image or self._last_preview if current_image is not None: mode = "render" if self._last_image is not None else "preview" self._display_image(current_image, mode) def closeEvent(self, event): """Clean up render threads on close.""" # Cancel auto-preview timer if self._auto_preview_timer and self._auto_preview_timer.isActive(): self._auto_preview_timer.stop() # Kill both possible threads for thread in (self._preview_thread, self._render_thread): if thread and thread.isRunning(): thread.cancel() thread.terminate() thread.wait(2000) # Clean up temp mesh file if self._mesh_path and os.path.exists(self._mesh_path): try: os.unlink(self._mesh_path) except OSError: pass super().closeEvent(event)