- Render improvements, camera plane, update
This commit is contained in:
Generated
+15
-2
@@ -6,7 +6,12 @@
|
|||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer - Added undo">
|
<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$/.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 - 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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+370
-220
File diff suppressed because it is too large
Load Diff
+720
-69
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user