- 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
+15 -2
View File
@@ -6,7 +6,12 @@
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer&#10;- Added undo"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer&#10;- Added undo">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -419,7 +424,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1783891561116</updated> <updated>1783891561116</updated>
</task> </task>
<option name="localTasksCounter" value="39" /> <task id="LOCAL-00039" summary="- added renderer&#10;- 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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
+68 -31
View File
@@ -7,7 +7,6 @@ from __future__ import annotations
import logging import logging
import os import os
import tempfile
from typing import Callable, Optional from typing import Callable, Optional
import numpy as np import numpy as np
@@ -31,6 +30,7 @@ class MitsubaBackend(RenderBackend):
sys.stderr = io.StringIO() sys.stderr = io.StringIO()
try: try:
import mitsuba # noqa: F401 import mitsuba # noqa: F401
return True return True
except ImportError: except ImportError:
return False return False
@@ -116,9 +116,7 @@ class MitsubaBackend(RenderBackend):
} }
# ── 3-point lighting (colors and intensities from config) ─── # ── 3-point lighting (colors and intensities from config) ───
key_rgb = [ key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
c * lighting.key_intensity for c in lighting.key_color
]
key_to_world = mi.ScalarTransform4f.look_at( key_to_world = mi.ScalarTransform4f.look_at(
origin=[1.0, -0.8, 1.2], origin=[1.0, -0.8, 1.2],
target=[0.0, 0.0, 0.0], target=[0.0, 0.0, 0.0],
@@ -130,9 +128,7 @@ class MitsubaBackend(RenderBackend):
"irradiance": {"type": "rgb", "value": key_rgb}, "irradiance": {"type": "rgb", "value": key_rgb},
} }
fill_rgb = [ fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
c * lighting.fill_intensity for c in lighting.fill_color
]
fill_to_world = mi.ScalarTransform4f.look_at( fill_to_world = mi.ScalarTransform4f.look_at(
origin=[-1.0, 0.6, 0.8], origin=[-1.0, 0.6, 0.8],
target=[0.0, 0.0, 0.0], target=[0.0, 0.0, 0.0],
@@ -144,9 +140,7 @@ class MitsubaBackend(RenderBackend):
"irradiance": {"type": "rgb", "value": fill_rgb}, "irradiance": {"type": "rgb", "value": fill_rgb},
} }
rim_rgb = [ rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
c * lighting.rim_intensity for c in lighting.rim_color
]
rim_to_world = mi.ScalarTransform4f.look_at( rim_to_world = mi.ScalarTransform4f.look_at(
origin=[-0.3, 1.2, -0.8], origin=[-0.3, 1.2, -0.8],
target=[0.0, 0.0, 0.0], target=[0.0, 0.0, 0.0],
@@ -158,24 +152,63 @@ class MitsubaBackend(RenderBackend):
"irradiance": {"type": "rgb", "value": rim_rgb}, "irradiance": {"type": "rgb", "value": rim_rgb},
} }
# ── Ground plane (optional) ───────────────────────────────── # ── Ground plane / backdrop (optional) ─────────────────────
if ground.enabled: if ground.enabled:
scene["ground_plane"] = { try:
"type": "rectangle", # Load mesh to compute bounds for ground/backdrop placement
"size": [500.0, 500.0], mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
"to_world": mi.ScalarTransform4f.translate( bbox = mesh_shape.bbox()
[0.0, 0.0, -ground.distance_below] bbox_min, bbox_max = bbox[0], bbox[1]
) # Ground at model's lowest Z with 0.1% offset
@ mi.ScalarTransform4f.rotate_about_z(90), model_height = bbox_max[2] - bbox_min[2]
"bsdf": { ground_z = bbox_min[2] - 0.001 * model_height
"type": "diffuse", dx = bbox_max[0] - bbox_min[0]
"reflectance": { dy = bbox_max[1] - bbox_min[1]
"type": "rgb", dz = bbox_max[2] - bbox_min[2]
"value": list(ground.color), 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",
"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 return scene
def _make_bsdf(self, material: RenderMaterial) -> dict: def _make_bsdf(self, material: RenderMaterial) -> dict:
@@ -247,12 +280,14 @@ class MitsubaBackend(RenderBackend):
scene_dict = self._build_scene_dict(mesh_path, material, camera, settings) scene_dict = self._build_scene_dict(mesh_path, material, camera, settings)
scene = mi.load_dict(scene_dict) scene = mi.load_dict(scene_dict)
logger.info( logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp"
)
# Render # 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: if progress_callback:
progress_callback(1.0) progress_callback(1.0)
@@ -281,6 +316,8 @@ class MitsubaBackend(RenderBackend):
spp=max(settings.spp // 4, 16), spp=max(settings.spp // 4, 16),
max_depth=min(settings.max_depth, 4), max_depth=min(settings.max_depth, 4),
seed=settings.seed, seed=settings.seed,
lighting=settings.lighting,
ground_plane=settings.ground_plane,
) )
return self.render(mesh_path, material, camera, preview_settings) return self.render(mesh_path, material, camera, preview_settings)
@@ -295,8 +332,8 @@ class MitsubaBackend(RenderBackend):
if ext == ".exr": if ext == ".exr":
# Save as EXR (HDR) — no tonemapping # Save as EXR (HDR) — no tonemapping
try: try:
import OpenEXR import OpenEXR # type: ignore[import-not-found]
import Imath import Imath # type: ignore[import-not-found]
h, w = image.shape[:2] h, w = image.shape[:2]
header = OpenEXR.Header(w, h) header = OpenEXR.Header(w, h)
+4 -2
View File
@@ -677,8 +677,10 @@ class OCCRenderer(Renderer):
2.0 * math.tan(math.radians(fov_y / 2.0)) 2.0 * math.tan(math.radians(fov_y / 2.0))
) )
# Scale factor maps directly: smaller scale (zoomed in) → closer camera. # Scale factor maps inversely: larger scale (zoomed in) → closer camera.
adjusted_distance = base_distance * view_scale # 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 from target toward the original eye position.
direction = eye - at direction = eye - at
+1
View File
@@ -59,6 +59,7 @@ class GroundPlaneConfig:
color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color
roughness: float = 0.8 # surface roughness [0..1] roughness: float = 0.8 # surface roughness [0..1]
distance_below: float = 0.0 # mm below origin (positive = below) distance_below: float = 0.0 # mm below origin (positive = below)
curved_backdrop: bool = False # photo booth style curved leinwand
@dataclass @dataclass
File diff suppressed because it is too large Load Diff
+720 -69
View File
@@ -12,14 +12,13 @@ from typing import Optional
import numpy as np import numpy as np
from PySide6.QtCore import Qt, Signal, Slot, QThread, QTimer 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 ( from PySide6.QtWidgets import (
QColorDialog, QColorDialog,
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QDoubleSpinBox, QDoubleSpinBox,
QFileDialog, QFileDialog,
QFrame,
QGridLayout, QGridLayout,
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
@@ -30,7 +29,6 @@ from PySide6.QtWidgets import (
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
QSlider, QSlider,
QSplitter,
QSpinBox, QSpinBox,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@@ -134,8 +132,8 @@ class RenderWindow(QMainWindow):
# Rendering threads & images # Rendering threads & images
self._render_thread: Optional[_RenderThread] = None self._render_thread: Optional[_RenderThread] = None
self._preview_thread: Optional[_RenderThread] = None self._preview_thread: Optional[_RenderThread] = None
self._last_image: Optional[np.ndarray] = None # full render result self._last_image: Optional[np.ndarray] = None # full render result
self._last_preview: Optional[np.ndarray] = None # preview result self._last_preview: Optional[np.ndarray] = None # preview result
self._camera: Optional[RenderCamera] = camera self._camera: Optional[RenderCamera] = camera
self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5) 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 = QLabel("Click Preview or Render to start")
self._image_label.setAlignment(Qt.AlignCenter) self._image_label.setAlignment(Qt.AlignCenter)
self._image_label.setSizePolicy( self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
QSizePolicy.Expanding, QSizePolicy.Expanding
)
self._image_label.setMinimumSize(400, 300) self._image_label.setMinimumSize(400, 300)
self._image_label.setStyleSheet( self._image_label.setStyleSheet("background: #1e1e2e; color: #6c7086; font-size: 14px;")
"background: #1e1e2e; color: #6c7086; font-size: 14px;"
)
self._image_container.addWidget(self._image_label, stretch=1) self._image_container.addWidget(self._image_label, stretch=1)
# Status badge ("Preview" / "Full Render") — shown below image # Status badge ("Preview" / "Full Render") — shown below image
self._status_badge = QLabel("") self._status_badge = QLabel("")
self._status_badge.setAlignment(Qt.AlignCenter) self._status_badge.setAlignment(Qt.AlignCenter)
self._status_badge.setStyleSheet( self._status_badge.setStyleSheet("color: #a6adc8; font-size: 11px; padding: 2px;")
"color: #a6adc8; font-size: 11px; padding: 2px;"
)
self._image_container.addWidget(self._status_badge) self._image_container.addWidget(self._status_badge)
right.addLayout(self._image_container, stretch=1) right.addLayout(self._image_container, stretch=1)
@@ -214,9 +206,7 @@ class RenderWindow(QMainWindow):
# Auto-preview checkbox # Auto-preview checkbox
self._auto_preview_cb = QCheckBox("Auto-preview") self._auto_preview_cb = QCheckBox("Auto-preview")
self._auto_preview_cb.setChecked(True) self._auto_preview_cb.setChecked(True)
self._auto_preview_cb.setToolTip( self._auto_preview_cb.setToolTip("Automatically preview when settings change")
"Automatically preview when settings change"
)
controls.addWidget(self._auto_preview_cb) controls.addWidget(self._auto_preview_cb)
# Samples per pixel # Samples per pixel
@@ -232,9 +222,7 @@ class RenderWindow(QMainWindow):
# Resolution # Resolution
controls.addWidget(QLabel("Resolution:")) controls.addWidget(QLabel("Resolution:"))
self._res_combo = QComboBox() self._res_combo = QComboBox()
self._res_combo.addItems( self._res_combo.addItems(["1280×720", "1920×1080", "2560×1440", "3840×2160"])
["1280×720", "1920×1080", "2560×1440", "3840×2160"]
)
self._res_combo.setCurrentText("1920×1080") self._res_combo.setCurrentText("1920×1080")
controls.addWidget(self._res_combo) controls.addWidget(self._res_combo)
@@ -425,29 +413,24 @@ class RenderWindow(QMainWindow):
preset_row = QHBoxLayout() preset_row = QHBoxLayout()
preset_row.addWidget(QLabel("Preset:")) preset_row.addWidget(QLabel("Preset:"))
self._light_preset_combo = QComboBox() self._light_preset_combo = QComboBox()
self._light_preset_combo.addItems([ self._light_preset_combo.addItems(
"Studio (default)", "Soft", "Dramatic", "Bright", [
]) "Studio (default)",
self._light_preset_combo.setCurrentIndex(0) "Soft",
self._light_preset_combo.currentTextChanged.connect( "Dramatic",
self._on_lighting_preset_changed "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) preset_row.addWidget(self._light_preset_combo)
light_layout.addLayout(preset_row) light_layout.addLayout(preset_row)
# Wire slider value changes to labels and auto-preview # Wire slider value changes to labels and auto-preview
self._wire_light_slider( self._wire_light_slider(self._light_ambient_slider, self._light_ambient_label, 100)
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._wire_light_slider(self._light_rim_slider, self._light_rim_label, 50)
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) layout.addWidget(light_gb)
@@ -459,9 +442,7 @@ class RenderWindow(QMainWindow):
# Enabled checkbox # Enabled checkbox
self._ground_enabled_cb = QCheckBox("Enabled") self._ground_enabled_cb = QCheckBox("Enabled")
self._ground_enabled_cb.setChecked(False) self._ground_enabled_cb.setChecked(False)
self._ground_enabled_cb.toggled.connect( self._ground_enabled_cb.toggled.connect(lambda: self._toggle_ground_plane_widgets())
lambda: self._toggle_ground_plane_widgets()
)
ground_layout.addWidget(self._ground_enabled_cb) ground_layout.addWidget(self._ground_enabled_cb)
# Color button # Color button
@@ -493,7 +474,7 @@ class RenderWindow(QMainWindow):
dist_row = QHBoxLayout() dist_row = QHBoxLayout()
dist_row.addWidget(QLabel("Offset:")) dist_row.addWidget(QLabel("Offset:"))
self._ground_dist_spin = QDoubleSpinBox() # type: ignore[attr-defined] 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.setValue(0.0)
self._ground_dist_spin.setSuffix(" mm") self._ground_dist_spin.setSuffix(" mm")
self._ground_dist_spin.setFixedWidth(80) self._ground_dist_spin.setFixedWidth(80)
@@ -509,9 +490,7 @@ class RenderWindow(QMainWindow):
) )
) )
# Wire distance spinbox to auto-preview # Wire distance spinbox to auto-preview
self._ground_dist_spin.valueChanged.connect( self._ground_dist_spin.valueChanged.connect(lambda: self._schedule_auto_preview())
lambda: self._schedule_auto_preview()
)
# Initially disable ground widgets since checkbox is unchecked # Initially disable ground widgets since checkbox is unchecked
self._toggle_ground_plane_widgets() self._toggle_ground_plane_widgets()
@@ -545,9 +524,7 @@ class RenderWindow(QMainWindow):
def _on_ground_color_pick(self): def _on_ground_color_pick(self):
"""Open color dialog for ground plane color.""" """Open color dialog for ground plane color."""
current = self._ground_color # type: ignore[attr-defined] current = self._ground_color # type: ignore[attr-defined]
color = QColorDialog.getColor( color = QColorDialog.getColor(QColor(*[int(c * 255) for c in current]), self)
QColor(*[int(c * 255) for c in current]), self
)
if color.isValid(): if color.isValid():
r, g, b = color.red() / 255.0, color.green() / 255.0, color.blue() / 255.0 r, g, b = color.red() / 255.0, color.green() / 255.0, color.blue() / 255.0
self._ground_color = (r, g, b) # type: ignore[attr-defined] self._ground_color = (r, g, b) # type: ignore[attr-defined]
@@ -607,13 +584,9 @@ class RenderWindow(QMainWindow):
# No backend available — disable both buttons # No backend available — disable both buttons
self._backend = None self._backend = None
self._render_btn.setEnabled(False) self._render_btn.setEnabled(False)
self._render_btn.setToolTip( self._render_btn.setToolTip("No render backend installed (pip install mitsuba)")
"No render backend installed (pip install mitsuba)"
)
self._preview_btn.setEnabled(False) self._preview_btn.setEnabled(False)
self._preview_btn.setToolTip( self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)")
"No render backend installed (pip install mitsuba)"
)
def _prepare_mesh(self): def _prepare_mesh(self):
"""Tessellate the OCC shape to a PLY file for the renderer.""" """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}") logger.info(f"Prepared mesh: {self._mesh_path}")
except Exception as e: except Exception as e:
logger.error(f"Failed to prepare mesh: {e}") logger.error(f"Failed to prepare mesh: {e}")
QMessageBox.warning( QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}")
self, "Render Error", f"Failed to tessellate shape:\n{e}"
)
# ── Auto-preview setup ──────────────────────────────────────────── # ── Auto-preview setup ────────────────────────────────────────────
@@ -643,14 +614,18 @@ class RenderWindow(QMainWindow):
self._auto_preview_timer.setSingleShot(True) self._auto_preview_timer.setSingleShot(True)
self._auto_preview_timer.timeout.connect(self._on_preview) self._auto_preview_timer.timeout.connect(self._on_preview)
# Wire material combo to trigger debounced preview # Wire material combo to trigger debounced preview
self._material_combo.currentTextChanged.connect( self._material_combo.currentTextChanged.connect(lambda: self._schedule_auto_preview())
lambda: self._schedule_auto_preview()
)
# Wire camera controls to trigger debounced preview # Wire camera controls to trigger debounced preview
for spin in ( for spin in (
self._cam_origin_x, self._cam_origin_y, self._cam_origin_z, self._cam_origin_x,
self._cam_target_x, self._cam_target_y, self._cam_target_z, self._cam_origin_y,
self._cam_up_x, self._cam_up_y, self._cam_up_z, 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, self._cam_fov_spin,
): ):
spin.valueChanged.connect(lambda: self._schedule_auto_preview()) spin.valueChanged.connect(lambda: self._schedule_auto_preview())
@@ -747,14 +722,10 @@ class RenderWindow(QMainWindow):
if mode == "preview": if mode == "preview":
self._status_badge.setText("⚡ Preview — low quality") self._status_badge.setText("⚡ Preview — low quality")
self._status_badge.setStyleSheet( self._status_badge.setStyleSheet("color: #f9e2af; font-size: 11px; padding: 2px;")
"color: #f9e2af; font-size: 11px; padding: 2px;"
)
else: else:
self._status_badge.setText("✓ Full render complete") self._status_badge.setText("✓ Full render complete")
self._status_badge.setStyleSheet( self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
"color: #a6e3a1; font-size: 11px; padding: 2px;"
)
def _cancel_active_thread(self): def _cancel_active_thread(self):
"""Cancel whichever thread is currently running.""" """Cancel whichever thread is currently running."""
@@ -967,3 +938,683 @@ class RenderWindow(QMainWindow):
pass pass
super().closeEvent(event) 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)
+48 -22
View File
@@ -3,15 +3,14 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QCursor, QFont, QPainter, QPen, QColor, QBrush, QPolygonF
from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Viewer3DWidget(QWidget): class Viewer3DWidget(QWidget):
"""3D viewer widget using OCC's native AIS display.""" """3D viewer widget using OCC's native AIS display."""
@@ -38,6 +37,10 @@ class Viewer3DWidget(QWidget):
# Emitted when a drag move finishes. # Emitted when a drag move finishes.
assemblyMoveFinished = Signal(str) 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): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it. # 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: if self._renderer is not None:
return return
import sys as _sys import sys as _sys
_sys.stdout.flush() _sys.stdout.flush()
logger.info("Renderer: starting import...") logger.info("Renderer: starting import...")
from fluency.rendering.occ_renderer import OCCRenderer from fluency.rendering.occ_renderer import OCCRenderer
from fluency.rendering.pygfx_renderer import PygfxRenderer from fluency.rendering.pygfx_renderer import PygfxRenderer
logger.info("Renderer: imports done, creating OCCRenderer...") logger.info("Renderer: imports done, creating OCCRenderer...")
occ = OCCRenderer() occ = OCCRenderer()
logger.info("Renderer: calling occ.initialize...") logger.info("Renderer: calling occ.initialize...")
@@ -142,16 +147,18 @@ class Viewer3DWidget(QWidget):
""" """
self._ensure_initialized() self._ensure_initialized()
from fluency.rendering.occ_renderer import OCCRenderer from fluency.rendering.occ_renderer import OCCRenderer
if isinstance(self._renderer, OCCRenderer): if isinstance(self._renderer, OCCRenderer):
oid = self._renderer.add_shape(shape, color, name) oid = self._renderer.add_shape(shape, color, name)
self._renderer.render() self._renderer.render()
return oid return oid
# Fallback: tessellate and use the mesh pipeline. # Fallback: tessellate and use the mesh pipeline.
from fluency.geometry_occ.kernel import OCGeometryKernel from fluency.geometry_occ.kernel import OCGeometryKernel
k = OCGeometryKernel() k = OCGeometryKernel()
from fluency.geometry_occ.sketch import OCCSketch
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers. # Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
from fluency.geometry_occ.kernel import OCCGeometryObject from fluency.geometry_occ.kernel import OCCGeometryObject
obj = OCCGeometryObject(shape) obj = OCCGeometryObject(shape)
verts, faces = k.get_mesh(obj) verts, faces = k.get_mesh(obj)
oid = self._renderer.add_mesh(verts, faces, color, name) oid = self._renderer.add_mesh(verts, faces, color, name)
@@ -159,7 +166,9 @@ class Viewer3DWidget(QWidget):
try: try:
e_verts, e_edges = k.get_edges(obj) e_verts, e_edges = k.get_edges(obj)
if len(e_verts) > 0: 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: except Exception:
pass pass
self._renderer.render() 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: def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
self._ensure_initialized() 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() self._renderer.render()
return wid return wid
@@ -326,7 +337,7 @@ class Viewer3DWidget(QWidget):
return return
# If connector mode was just exited (gizmo persists after pick), # If connector mode was just exited (gizmo persists after pick),
# clear any lingering gizmo on first mouse move. # 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): if self._connector_snap_id is not None or (gizmo_objs and len(gizmo_objs) > 0):
self._clear_connector_snap() self._clear_connector_snap()
# In face-pick mode, keep dynamic highlighting. # In face-pick mode, keep dynamic highlighting.
@@ -359,11 +370,31 @@ class Viewer3DWidget(QWidget):
return return
self._renderer.handle_mouse_release(event) self._renderer.handle_mouse_release(event)
super().mouseReleaseEvent(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): def wheelEvent(self, event):
self._ensure_initialized() self._ensure_initialized()
self._renderer.handle_wheel(event) self._renderer.handle_wheel(event)
super().wheelEvent(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): def resizeEvent(self, event):
super().resizeEvent(event) super().resizeEvent(event)
@@ -389,6 +420,7 @@ class Viewer3DWidget(QWidget):
if hasattr(self._renderer, "get_camera_position"): if hasattr(self._renderer, "get_camera_position"):
return self._renderer.get_camera_position() return self._renderer.get_camera_position()
import numpy as np import numpy as np
return ( return (
np.zeros(3, dtype=float), np.zeros(3, dtype=float),
np.zeros(3, dtype=float), np.zeros(3, dtype=float),
@@ -498,6 +530,7 @@ class Viewer3DWidget(QWidget):
on fast mouse moves. on fast mouse moves.
""" """
import time import time
now = time.monotonic() now = time.monotonic()
if now - self._connector_last_hover_time < self._connector_hover_interval: if now - self._connector_last_hover_time < self._connector_hover_interval:
return # throttled — skip this mouse move return # throttled — skip this mouse move
@@ -569,10 +602,10 @@ class Viewer3DWidget(QWidget):
fn = getattr(self._renderer, "highlight_snap", None) fn = getattr(self._renderer, "highlight_snap", None)
if fn is not None: if fn is not None:
colors = { colors = {
"planar_face": (0.0, 0.8, 1.0), # cyan "planar_face": (0.0, 0.8, 1.0), # cyan
"cylindrical_face": (1.0, 0.4, 0.0), # orange (hole) "cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
"edge": (0.0, 1.0, 0.4), # green "edge": (0.0, 1.0, 0.4), # green
"vertex": (1.0, 1.0, 0.0), # yellow "vertex": (1.0, 1.0, 0.0), # yellow
} }
c = colors.get(entity_type, (1.0, 0.6, 0.0)) c = colors.get(entity_type, (1.0, 0.6, 0.0))
self._connector_snap_id = fn(origin, color=c, size=3.0) self._connector_snap_id = fn(origin, color=c, size=3.0)
@@ -737,7 +770,7 @@ class Viewer3DWidget(QWidget):
# Get camera vectors for proper view-plane projection. # Get camera vectors for proper view-plane projection.
import numpy as np import numpy as np
from OCP.V3d import V3d_TypeOfOrientation
try: try:
# Get camera direction and up from the OCC view. # Get camera direction and up from the OCC view.
camera = self._renderer._view.Camera() camera = self._renderer._view.Camera()
@@ -762,16 +795,11 @@ class Viewer3DWidget(QWidget):
dy_world = 0.0 dy_world = 0.0
else: else:
# Normal drag: move in view plane. # Normal drag: move in view plane.
dx_world = float(cam_right[0] * dx * world_per_pixel + dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * 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)
dy_world = float(cam_right[1] * dx * world_per_pixel + dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * 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.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
self._move_owner_obj_id, dx_world, dy_world, dz_world
)
def _handle_assembly_move_release(self, event) -> None: def _handle_assembly_move_release(self, event) -> None:
"""Finish the drag, emit final position.""" """Finish the drag, emit final position."""
@@ -888,5 +916,3 @@ class Viewer3DWidget(QWidget):
self._renderer.render() self._renderer.render()
return return
super().keyPressEvent(event) super().keyPressEvent(event)