- Render improvements, camera plane, update
This commit is contained in:
+720
-69
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user