- Render improvements, camera plane, update
This commit is contained in:
Generated
+15
-2
@@ -6,7 +6,12 @@
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer - Added undo">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_to_mesh.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_to_mesh.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -419,7 +424,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783891561116</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="39" />
|
||||
<task id="LOCAL-00039" summary="- added renderer - Added undo">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783918463034</created>
|
||||
<option name="number" value="00039" />
|
||||
<option name="presentableId" value="LOCAL-00039" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783918463034</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="40" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
|
||||
@@ -7,7 +7,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -31,6 +30,7 @@ class MitsubaBackend(RenderBackend):
|
||||
sys.stderr = io.StringIO()
|
||||
try:
|
||||
import mitsuba # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
@@ -116,9 +116,7 @@ class MitsubaBackend(RenderBackend):
|
||||
}
|
||||
|
||||
# ── 3-point lighting (colors and intensities from config) ───
|
||||
key_rgb = [
|
||||
c * lighting.key_intensity for c in lighting.key_color
|
||||
]
|
||||
key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
|
||||
key_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[1.0, -0.8, 1.2],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
@@ -130,9 +128,7 @@ class MitsubaBackend(RenderBackend):
|
||||
"irradiance": {"type": "rgb", "value": key_rgb},
|
||||
}
|
||||
|
||||
fill_rgb = [
|
||||
c * lighting.fill_intensity for c in lighting.fill_color
|
||||
]
|
||||
fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
|
||||
fill_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[-1.0, 0.6, 0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
@@ -144,9 +140,7 @@ class MitsubaBackend(RenderBackend):
|
||||
"irradiance": {"type": "rgb", "value": fill_rgb},
|
||||
}
|
||||
|
||||
rim_rgb = [
|
||||
c * lighting.rim_intensity for c in lighting.rim_color
|
||||
]
|
||||
rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
|
||||
rim_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[-0.3, 1.2, -0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
@@ -158,22 +152,61 @@ class MitsubaBackend(RenderBackend):
|
||||
"irradiance": {"type": "rgb", "value": rim_rgb},
|
||||
}
|
||||
|
||||
# ── Ground plane (optional) ─────────────────────────────────
|
||||
# ── Ground plane / backdrop (optional) ─────────────────────
|
||||
if ground.enabled:
|
||||
try:
|
||||
# Load mesh to compute bounds for ground/backdrop placement
|
||||
mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
|
||||
bbox = mesh_shape.bbox()
|
||||
bbox_min, bbox_max = bbox[0], bbox[1]
|
||||
# Ground at model's lowest Z with 0.1% offset
|
||||
model_height = bbox_max[2] - bbox_min[2]
|
||||
ground_z = bbox_min[2] - 0.001 * model_height
|
||||
dx = bbox_max[0] - bbox_min[0]
|
||||
dy = bbox_max[1] - bbox_min[1]
|
||||
dz = bbox_max[2] - bbox_min[2]
|
||||
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
|
||||
except Exception:
|
||||
# Fallback: place at origin with large default size
|
||||
ground_z = -ground.distance_below
|
||||
diag = 1000.0
|
||||
|
||||
bsdf_ground = {
|
||||
"type": "diffuse",
|
||||
"reflectance": {"type": "rgb", "value": list(ground.color)},
|
||||
}
|
||||
|
||||
if ground.curved_backdrop:
|
||||
# Photo booth style curved leinwand:
|
||||
# - Flat floor section in front of the model
|
||||
# - Curved cylinder behind that sweeps up and over
|
||||
half_size = diag * 50.0 # huge floor
|
||||
radius = diag * 3.0 # curvature radius
|
||||
cyl_height = diag * 20.0 # width of the cylinder (along its axis)
|
||||
scene["ground_floor"] = {
|
||||
"type": "rectangle",
|
||||
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
# Cylinder: axis along Y, positioned behind model, radius sweeps up
|
||||
scene["ground_backdrop"] = {
|
||||
"type": "cylinder",
|
||||
"radius": radius,
|
||||
"p0": [-cyl_height / 2, -radius, ground_z],
|
||||
"p1": [cyl_height / 2, -radius, ground_z],
|
||||
"to_world": mi.ScalarTransform4f.rotate([1, 0, 0], 90)
|
||||
@ mi.ScalarTransform4f.translate([0.0, 0.0, -radius]),
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
else:
|
||||
# Simple flat ground plane — very large so edges aren't visible
|
||||
half_size = diag * 50.0
|
||||
scene["ground_plane"] = {
|
||||
"type": "rectangle",
|
||||
"size": [500.0, 500.0],
|
||||
"to_world": mi.ScalarTransform4f.translate(
|
||||
[0.0, 0.0, -ground.distance_below]
|
||||
)
|
||||
@ mi.ScalarTransform4f.rotate_about_z(90),
|
||||
"bsdf": {
|
||||
"type": "diffuse",
|
||||
"reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(ground.color),
|
||||
},
|
||||
},
|
||||
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
|
||||
return scene
|
||||
@@ -247,12 +280,14 @@ class MitsubaBackend(RenderBackend):
|
||||
scene_dict = self._build_scene_dict(mesh_path, material, camera, settings)
|
||||
scene = mi.load_dict(scene_dict)
|
||||
|
||||
logger.info(
|
||||
f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp"
|
||||
)
|
||||
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
|
||||
|
||||
# Render
|
||||
image = mi.render(scene, spp=settings.spp, seed=settings.seed or 0)
|
||||
try:
|
||||
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime
|
||||
except Exception as e:
|
||||
logger.error(f"Mitsuba render failed: {e}")
|
||||
raise
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(1.0)
|
||||
@@ -281,6 +316,8 @@ class MitsubaBackend(RenderBackend):
|
||||
spp=max(settings.spp // 4, 16),
|
||||
max_depth=min(settings.max_depth, 4),
|
||||
seed=settings.seed,
|
||||
lighting=settings.lighting,
|
||||
ground_plane=settings.ground_plane,
|
||||
)
|
||||
return self.render(mesh_path, material, camera, preview_settings)
|
||||
|
||||
@@ -295,8 +332,8 @@ class MitsubaBackend(RenderBackend):
|
||||
if ext == ".exr":
|
||||
# Save as EXR (HDR) — no tonemapping
|
||||
try:
|
||||
import OpenEXR
|
||||
import Imath
|
||||
import OpenEXR # type: ignore[import-not-found]
|
||||
import Imath # type: ignore[import-not-found]
|
||||
|
||||
h, w = image.shape[:2]
|
||||
header = OpenEXR.Header(w, h)
|
||||
|
||||
@@ -677,8 +677,10 @@ class OCCRenderer(Renderer):
|
||||
2.0 * math.tan(math.radians(fov_y / 2.0))
|
||||
)
|
||||
|
||||
# Scale factor maps directly: smaller scale (zoomed in) → closer camera.
|
||||
adjusted_distance = base_distance * view_scale
|
||||
# Scale factor maps inversely: larger scale (zoomed in) → closer camera.
|
||||
# Dividing by view_scale ensures that when the user zooms in (scale increases)
|
||||
# the render camera moves closer, matching the viewport.
|
||||
adjusted_distance = base_distance / view_scale
|
||||
|
||||
# Direction from target toward the original eye position.
|
||||
direction = eye - at
|
||||
|
||||
@@ -59,6 +59,7 @@ class GroundPlaneConfig:
|
||||
color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color
|
||||
roughness: float = 0.8 # surface roughness [0..1]
|
||||
distance_below: float = 0.0 # mm below origin (positive = below)
|
||||
curved_backdrop: bool = False # photo booth style curved leinwand
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+363
-213
File diff suppressed because it is too large
Load Diff
+718
-67
@@ -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,
|
||||
@@ -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)
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
|
||||
from PySide6.QtGui import QCursor, QFont, QPainter, QPen, QColor, QBrush, QPolygonF
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Viewer3DWidget(QWidget):
|
||||
"""3D viewer widget using OCC's native AIS display."""
|
||||
|
||||
@@ -38,6 +37,10 @@ class Viewer3DWidget(QWidget):
|
||||
# Emitted when a drag move finishes.
|
||||
assemblyMoveFinished = Signal(str)
|
||||
|
||||
# Emitted whenever the camera changes (orbit, pan, zoom).
|
||||
# Payload: (eye, at, up) — each is a tuple of 3 floats.
|
||||
cameraChanged = Signal(tuple, tuple, tuple)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
||||
@@ -90,10 +93,12 @@ class Viewer3DWidget(QWidget):
|
||||
if self._renderer is not None:
|
||||
return
|
||||
import sys as _sys
|
||||
|
||||
_sys.stdout.flush()
|
||||
logger.info("Renderer: starting import...")
|
||||
from fluency.rendering.occ_renderer import OCCRenderer
|
||||
from fluency.rendering.pygfx_renderer import PygfxRenderer
|
||||
|
||||
logger.info("Renderer: imports done, creating OCCRenderer...")
|
||||
occ = OCCRenderer()
|
||||
logger.info("Renderer: calling occ.initialize...")
|
||||
@@ -142,16 +147,18 @@ class Viewer3DWidget(QWidget):
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
from fluency.rendering.occ_renderer import OCCRenderer
|
||||
|
||||
if isinstance(self._renderer, OCCRenderer):
|
||||
oid = self._renderer.add_shape(shape, color, name)
|
||||
self._renderer.render()
|
||||
return oid
|
||||
# Fallback: tessellate and use the mesh pipeline.
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
|
||||
k = OCGeometryKernel()
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
|
||||
obj = OCCGeometryObject(shape)
|
||||
verts, faces = k.get_mesh(obj)
|
||||
oid = self._renderer.add_mesh(verts, faces, color, name)
|
||||
@@ -159,7 +166,9 @@ class Viewer3DWidget(QWidget):
|
||||
try:
|
||||
e_verts, e_edges = k.get_edges(obj)
|
||||
if len(e_verts) > 0:
|
||||
self._renderer.add_wireframe(e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges")
|
||||
self._renderer.add_wireframe(
|
||||
e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._renderer.render()
|
||||
@@ -189,7 +198,9 @@ class Viewer3DWidget(QWidget):
|
||||
|
||||
def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
|
||||
self._ensure_initialized()
|
||||
wid = self._renderer.add_wireframe(vertices, edges, color or (0.9, 0.9, 0.9), line_width, name)
|
||||
wid = self._renderer.add_wireframe(
|
||||
vertices, edges, color or (0.9, 0.9, 0.9), line_width, name
|
||||
)
|
||||
self._renderer.render()
|
||||
return wid
|
||||
|
||||
@@ -326,7 +337,7 @@ class Viewer3DWidget(QWidget):
|
||||
return
|
||||
# If connector mode was just exited (gizmo persists after pick),
|
||||
# clear any lingering gizmo on first mouse move.
|
||||
gizmo_objs = getattr(self._renderer, '_gizmo_objects', None)
|
||||
gizmo_objs = getattr(self._renderer, "_gizmo_objects", None)
|
||||
if self._connector_snap_id is not None or (gizmo_objs and len(gizmo_objs) > 0):
|
||||
self._clear_connector_snap()
|
||||
# In face-pick mode, keep dynamic highlighting.
|
||||
@@ -359,11 +370,31 @@ class Viewer3DWidget(QWidget):
|
||||
return
|
||||
self._renderer.handle_mouse_release(event)
|
||||
super().mouseReleaseEvent(event)
|
||||
# Camera may have changed during orbit/pan — emit signal.
|
||||
try:
|
||||
eye, at_, up = self.get_camera_position()
|
||||
self.cameraChanged.emit(
|
||||
tuple(float(v) for v in eye),
|
||||
tuple(float(v) for v in at_),
|
||||
tuple(float(v) for v in up),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def wheelEvent(self, event):
|
||||
self._ensure_initialized()
|
||||
self._renderer.handle_wheel(event)
|
||||
super().wheelEvent(event)
|
||||
# Zoom changed the camera — emit signal.
|
||||
try:
|
||||
eye, at_, up = self.get_camera_position()
|
||||
self.cameraChanged.emit(
|
||||
tuple(float(v) for v in eye),
|
||||
tuple(float(v) for v in at_),
|
||||
tuple(float(v) for v in up),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
@@ -389,6 +420,7 @@ class Viewer3DWidget(QWidget):
|
||||
if hasattr(self._renderer, "get_camera_position"):
|
||||
return self._renderer.get_camera_position()
|
||||
import numpy as np
|
||||
|
||||
return (
|
||||
np.zeros(3, dtype=float),
|
||||
np.zeros(3, dtype=float),
|
||||
@@ -498,6 +530,7 @@ class Viewer3DWidget(QWidget):
|
||||
on fast mouse moves.
|
||||
"""
|
||||
import time
|
||||
|
||||
now = time.monotonic()
|
||||
if now - self._connector_last_hover_time < self._connector_hover_interval:
|
||||
return # throttled — skip this mouse move
|
||||
@@ -737,7 +770,7 @@ class Viewer3DWidget(QWidget):
|
||||
|
||||
# Get camera vectors for proper view-plane projection.
|
||||
import numpy as np
|
||||
from OCP.V3d import V3d_TypeOfOrientation
|
||||
|
||||
try:
|
||||
# Get camera direction and up from the OCC view.
|
||||
camera = self._renderer._view.Camera()
|
||||
@@ -762,16 +795,11 @@ class Viewer3DWidget(QWidget):
|
||||
dy_world = 0.0
|
||||
else:
|
||||
# Normal drag: move in view plane.
|
||||
dx_world = float(cam_right[0] * dx * world_per_pixel +
|
||||
cam_up[0] * dy * world_per_pixel)
|
||||
dy_world = float(cam_right[1] * dx * world_per_pixel +
|
||||
cam_up[1] * dy * world_per_pixel)
|
||||
dz_world = float(cam_right[2] * dx * world_per_pixel +
|
||||
cam_up[2] * dy * world_per_pixel)
|
||||
dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel)
|
||||
dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel)
|
||||
dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel)
|
||||
|
||||
self.assemblyComponentDragged.emit(
|
||||
self._move_owner_obj_id, dx_world, dy_world, dz_world
|
||||
)
|
||||
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
|
||||
|
||||
def _handle_assembly_move_release(self, event) -> None:
|
||||
"""Finish the drag, emit final position."""
|
||||
@@ -888,5 +916,3 @@ class Viewer3DWidget(QWidget):
|
||||
self._renderer.render()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user