- Render improvements, camera plane, update

This commit is contained in:
bklronin
2026-07-13 23:01:35 +02:00
parent c78d0af78c
commit 742d06d242
7 changed files with 1226 additions and 346 deletions
+720 -69
View File
@@ -12,14 +12,13 @@ from typing import Optional
import numpy as np
from PySide6.QtCore import Qt, Signal, Slot, QThread, QTimer
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtGui import QColor, QImage, QPixmap
from PySide6.QtWidgets import (
QColorDialog,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
@@ -30,7 +29,6 @@ from PySide6.QtWidgets import (
QPushButton,
QSizePolicy,
QSlider,
QSplitter,
QSpinBox,
QVBoxLayout,
QWidget,
@@ -134,8 +132,8 @@ class RenderWindow(QMainWindow):
# 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._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)
@@ -179,21 +177,15 @@ class RenderWindow(QMainWindow):
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.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._image_label.setMinimumSize(400, 300)
self._image_label.setStyleSheet(
"background: #1e1e2e; color: #6c7086; font-size: 14px;"
)
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._status_badge.setStyleSheet("color: #a6adc8; font-size: 11px; padding: 2px;")
self._image_container.addWidget(self._status_badge)
right.addLayout(self._image_container, stretch=1)
@@ -214,9 +206,7 @@ class RenderWindow(QMainWindow):
# 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"
)
self._auto_preview_cb.setToolTip("Automatically preview when settings change")
controls.addWidget(self._auto_preview_cb)
# Samples per pixel
@@ -232,9 +222,7 @@ class RenderWindow(QMainWindow):
# Resolution
controls.addWidget(QLabel("Resolution:"))
self._res_combo = QComboBox()
self._res_combo.addItems(
["1280×720", "1920×1080", "2560×1440", "3840×2160"]
)
self._res_combo.addItems(["1280×720", "1920×1080", "2560×1440", "3840×2160"])
self._res_combo.setCurrentText("1920×1080")
controls.addWidget(self._res_combo)
@@ -425,29 +413,24 @@ class RenderWindow(QMainWindow):
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
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
)
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)
@@ -459,9 +442,7 @@ class RenderWindow(QMainWindow):
# 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()
)
self._ground_enabled_cb.toggled.connect(lambda: self._toggle_ground_plane_widgets())
ground_layout.addWidget(self._ground_enabled_cb)
# Color button
@@ -493,7 +474,7 @@ class RenderWindow(QMainWindow):
dist_row = QHBoxLayout()
dist_row.addWidget(QLabel("Offset:"))
self._ground_dist_spin = QDoubleSpinBox() # type: ignore[attr-defined]
self._ground_dist_spin.setRange(0, 500)
self._ground_dist_spin.setRange(-500, 500)
self._ground_dist_spin.setValue(0.0)
self._ground_dist_spin.setSuffix(" mm")
self._ground_dist_spin.setFixedWidth(80)
@@ -509,9 +490,7 @@ class RenderWindow(QMainWindow):
)
)
# Wire distance spinbox to auto-preview
self._ground_dist_spin.valueChanged.connect(
lambda: self._schedule_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()
@@ -545,9 +524,7 @@ class RenderWindow(QMainWindow):
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
)
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]
@@ -607,13 +584,9 @@ class RenderWindow(QMainWindow):
# 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._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)"
)
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."""
@@ -631,9 +604,7 @@ class RenderWindow(QMainWindow):
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}"
)
QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}")
# ── Auto-preview setup ────────────────────────────────────────────
@@ -643,14 +614,18 @@ class RenderWindow(QMainWindow):
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()
)
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_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())
@@ -747,14 +722,10 @@ class RenderWindow(QMainWindow):
if mode == "preview":
self._status_badge.setText("⚡ Preview — low quality")
self._status_badge.setStyleSheet(
"color: #f9e2af; font-size: 11px; padding: 2px;"
)
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;"
)
self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
def _cancel_active_thread(self):
"""Cancel whichever thread is currently running."""
@@ -967,3 +938,683 @@ class RenderWindow(QMainWindow):
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)