1621 lines
62 KiB
Python
1621 lines
62 KiB
Python
"""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)
|