Files
fluencyCAD/src/fluency/ui/render_window.py
T
bklronin 9f1387fe68 - added renderer
- Added undo
2026-07-12 22:21:43 +02:00

970 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)