Files
fluencyCAD/src/fluency/ui/render_window.py
T
2026-07-13 23:01:35 +02:00

1621 lines
62 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 QColor, QImage, QPixmap
from PySide6.QtWidgets import (
QColorDialog,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QProgressBar,
QPushButton,
QSizePolicy,
QSlider,
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(-500, 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)
# ── Render tab content (embeddable in QTabWidget) ──────────────────────
class RenderTabContent(QWidget):
"""Render controls and image display as a QWidget, designed to sit in a tab.
Mirrors RenderWindow\'s controls but as a plain widget so it can be
embedded in MainWindow\'s InputTab (alongside Sketch and Code).
Call ``set_shape(shape, camera)`` to load a new shape and optionally reset
the camera to the current viewport. ``cleanup()`` stops threads and
deletes temp files when the tab is hidden.
"""
def __init__(self, parent=None):
super().__init__(parent)
self._shape = None
self._backend = None
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
self._last_preview: Optional[np.ndarray] = None
self._camera: Optional[RenderCamera] = None
self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5)
self._active_mode: Optional[str] = None
self._auto_preview_timer: Optional[QTimer] = None
self._init_ui()
self._init_backend()
self._setup_auto_preview()
# ── Public API ─────────────────────────────────────────────────
def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None:
"""Load a new OCC TopoDS_Shape for rendering.
*camera* — if provided, overrides the stored camera. Pass the
viewport\'s render camera to match the 3D view framing.
"""
self._shape = shape
if camera is not None:
self._camera = camera
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
self._prepare_mesh()
self._populate_camera_controls()
# Trigger auto-preview when a new shape is loaded
self._schedule_auto_preview()
def get_camera(self) -> Optional[RenderCamera]:
"""Return the current camera (from UI controls or initial)."""
return self._build_camera_from_ui()
def set_camera(self, camera: RenderCamera) -> None:
"""Update the render camera from an external source (e.g. 3D viewport).
Stores the camera and, if a preview is active, cancels it and
schedules a new one with the updated framing. For full renders the
camera is stored so the next manual render uses the new framing.
"""
if camera is None:
return
self._camera = camera
self._cam_fov_spin.blockSignals(True)
try:
self._cam_fov_spin.setValue(camera.fov)
finally:
self._cam_fov_spin.blockSignals(False)
# ── If a preview is active, cancel and restart with the new camera ──
if self._active_mode == "preview" and self._preview_thread is not None:
self._cancel_active_thread()
self._schedule_auto_preview()
return
# For full renders or idle: schedule a preview if auto-preview is on.
self._schedule_auto_preview()
def cleanup(self) -> None:
"""Stop threads and delete temp files. Call when the tab is hidden/closed."""
if self._auto_preview_timer and self._auto_preview_timer.isActive():
self._auto_preview_timer.stop()
for thread in (self._preview_thread, self._render_thread):
if thread and thread.isRunning():
thread.cancel()
thread.terminate()
thread.wait(2000)
if self._mesh_path and os.path.exists(self._mesh_path):
try:
os.unlink(self._mesh_path)
except OSError:
pass
self._mesh_path = None
# ── UI Setup ───────────────────────────────────────────────────
def _init_ui(self):
main_layout = QHBoxLayout(self)
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)
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)
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)
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)
self._auto_preview_cb = QCheckBox("Auto-preview")
self._auto_preview_cb.setChecked(True)
controls.addWidget(self._auto_preview_cb)
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)
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()
self._preview_btn = QPushButton("⚡ Preview")
self._preview_btn.setMinimumWidth(90)
self._preview_btn.clicked.connect(self._on_preview)
controls.addWidget(self._preview_btn)
self._render_btn = QPushButton("▶ Render")
self._render_btn.setMinimumWidth(100)
self._render_btn.clicked.connect(self._on_render)
controls.addWidget(self._render_btn)
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)
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)
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, target, and up are controlled via the 3D viewport.
# Only FOV is editable here.
fov_row = QHBoxLayout()
fov_row.addWidget(QLabel("FOV:"))
self._cam_fov_spin = QDoubleSpinBox()
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_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_row = QHBoxLayout()
ambient_row.addWidget(QLabel("Ambient:"))
self._light_ambient_slider = QSlider(Qt.Horizontal)
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_row = QHBoxLayout()
key_row.addWidget(QLabel("Key:"))
self._light_key_slider = QSlider(Qt.Horizontal)
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_row = QHBoxLayout()
fill_row.addWidget(QLabel("Fill:"))
self._light_fill_slider = QSlider(Qt.Horizontal)
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_row = QHBoxLayout()
rim_row.addWidget(QLabel("Rim:"))
self._light_rim_slider = QSlider(Qt.Horizontal)
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)
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)
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)
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)
self._ground_curved_cb = QCheckBox("Curved Backdrop")
self._ground_curved_cb.setChecked(False)
self._ground_curved_cb.toggled.connect(lambda: self._schedule_auto_preview())
ground_layout.addWidget(self._ground_curved_cb)
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)
rough_row = QHBoxLayout()
rough_row.addWidget(QLabel("Roughness:"))
self._ground_rough_slider = QSlider(Qt.Horizontal)
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)
dist_row = QHBoxLayout()
dist_row.addWidget(QLabel("Offset:"))
self._ground_dist_spin = QDoubleSpinBox()
self._ground_dist_spin.setRange(-500, 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)
self._ground_rough_slider.valueChanged.connect(
lambda v: (
self._ground_rough_label.setText(f"{v / 100:.2f}"),
self._schedule_auto_preview(),
)
)
self._ground_dist_spin.valueChanged.connect(lambda: self._schedule_auto_preview())
self._toggle_ground_plane_widgets()
layout.addWidget(ground_gb)
layout.addStretch()
return panel
def _wire_light_slider(self, slider, label, divisor):
slider.valueChanged.connect(
lambda v: (
label.setText(f"{v / divisor:.1f}"),
self._schedule_auto_preview(),
)
)
def _toggle_ground_plane_widgets(self):
enabled = self._ground_enabled_cb.isChecked()
for w in (
self._ground_color_btn,
self._ground_rough_slider,
self._ground_dist_spin,
self._ground_curved_cb,
):
w.setEnabled(enabled)
if enabled:
self._schedule_auto_preview()
def _on_ground_color_pick(self):
current = self._ground_color
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)
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):
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):
if self._camera is None:
return
self._cam_fov_spin.setValue(self._camera.fov)
self._schedule_auto_preview()
def _init_backend(self):
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
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):
if self._shape is None:
return
try:
self._mesh_path = occ_shape_to_ply(
self._shape, linear_deflection=0.1, angular_deflection=0.15
)
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}")
def _setup_auto_preview(self):
self._auto_preview_timer = QTimer(self)
self._auto_preview_timer.setSingleShot(True)
self._auto_preview_timer.timeout.connect(self._on_preview)
self._material_combo.currentTextChanged.connect(lambda: self._schedule_auto_preview())
self._cam_fov_spin.valueChanged.connect(lambda: self._schedule_auto_preview())
def _populate_camera_controls(self):
if self._camera is None:
return
self._cam_fov_spin.setValue(self._camera.fov)
def _schedule_auto_preview(self):
if not self._auto_preview_cb.isChecked():
return
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)
def _build_camera_from_ui(self) -> RenderCamera:
# Origin, target, and up are controlled via the 3D viewport;
# only FOV is editable in the sidebar.
_fallback = RenderCamera()
return RenderCamera(
origin=self._camera.origin if self._camera else _fallback.origin,
target=self._camera.target if self._camera else _fallback.target,
up=self._camera.up if self._camera else _fallback.up,
fov=self._cam_fov_spin.value(),
)
def _build_lighting_config(self) -> LightingConfig:
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:
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(),
curved_backdrop=self._ground_curved_cb.isChecked(),
)
def _display_image(self, image: np.ndarray, mode: str) -> None:
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)
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):
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):
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):
self._preview_btn.setEnabled(True)
self._render_btn.setEnabled(True)
self._cancel_btn.setEnabled(False)
self._active_mode = None
@Slot()
def _on_preview(self):
if self._backend is None or self._mesh_path is None:
return
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):
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
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):
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):
self._last_preview = image
self._set_buttons_idle()
self._export_btn.setEnabled(False)
self._progress.setValue(100)
self._display_image(image, "preview")
@Slot(np.ndarray)
def _on_render_done(self, image: np.ndarray):
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):
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):
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))
def resizeEvent(self, event):
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)