diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 523523d..e5d31d7 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -6,7 +6,12 @@
-
+
+
+
+
+
+
@@ -419,7 +424,15 @@
1783891561116
-
+
+
+ 1783918463034
+
+
+
+ 1783918463034
+
+
diff --git a/src/fluency/rendering/mitsuba_backend.py b/src/fluency/rendering/mitsuba_backend.py
index bd22afa..4bff801 100644
--- a/src/fluency/rendering/mitsuba_backend.py
+++ b/src/fluency/rendering/mitsuba_backend.py
@@ -7,7 +7,6 @@ from __future__ import annotations
import logging
import os
-import tempfile
from typing import Callable, Optional
import numpy as np
@@ -31,6 +30,7 @@ class MitsubaBackend(RenderBackend):
sys.stderr = io.StringIO()
try:
import mitsuba # noqa: F401
+
return True
except ImportError:
return False
@@ -116,9 +116,7 @@ class MitsubaBackend(RenderBackend):
}
# ── 3-point lighting (colors and intensities from config) ───
- key_rgb = [
- c * lighting.key_intensity for c in lighting.key_color
- ]
+ key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
key_to_world = mi.ScalarTransform4f.look_at(
origin=[1.0, -0.8, 1.2],
target=[0.0, 0.0, 0.0],
@@ -130,9 +128,7 @@ class MitsubaBackend(RenderBackend):
"irradiance": {"type": "rgb", "value": key_rgb},
}
- fill_rgb = [
- c * lighting.fill_intensity for c in lighting.fill_color
- ]
+ fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
fill_to_world = mi.ScalarTransform4f.look_at(
origin=[-1.0, 0.6, 0.8],
target=[0.0, 0.0, 0.0],
@@ -144,9 +140,7 @@ class MitsubaBackend(RenderBackend):
"irradiance": {"type": "rgb", "value": fill_rgb},
}
- rim_rgb = [
- c * lighting.rim_intensity for c in lighting.rim_color
- ]
+ rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
rim_to_world = mi.ScalarTransform4f.look_at(
origin=[-0.3, 1.2, -0.8],
target=[0.0, 0.0, 0.0],
@@ -158,24 +152,63 @@ class MitsubaBackend(RenderBackend):
"irradiance": {"type": "rgb", "value": rim_rgb},
}
- # ── Ground plane (optional) ─────────────────────────────────
+ # ── Ground plane / backdrop (optional) ─────────────────────
if ground.enabled:
- scene["ground_plane"] = {
- "type": "rectangle",
- "size": [500.0, 500.0],
- "to_world": mi.ScalarTransform4f.translate(
- [0.0, 0.0, -ground.distance_below]
- )
- @ mi.ScalarTransform4f.rotate_about_z(90),
- "bsdf": {
- "type": "diffuse",
- "reflectance": {
- "type": "rgb",
- "value": list(ground.color),
- },
- },
+ try:
+ # Load mesh to compute bounds for ground/backdrop placement
+ mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
+ bbox = mesh_shape.bbox()
+ bbox_min, bbox_max = bbox[0], bbox[1]
+ # Ground at model's lowest Z with 0.1% offset
+ model_height = bbox_max[2] - bbox_min[2]
+ ground_z = bbox_min[2] - 0.001 * model_height
+ dx = bbox_max[0] - bbox_min[0]
+ dy = bbox_max[1] - bbox_min[1]
+ dz = bbox_max[2] - bbox_min[2]
+ diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
+ except Exception:
+ # Fallback: place at origin with large default size
+ ground_z = -ground.distance_below
+ diag = 1000.0
+
+ bsdf_ground = {
+ "type": "diffuse",
+ "reflectance": {"type": "rgb", "value": list(ground.color)},
}
+ if ground.curved_backdrop:
+ # Photo booth style curved leinwand:
+ # - Flat floor section in front of the model
+ # - Curved cylinder behind that sweeps up and over
+ half_size = diag * 50.0 # huge floor
+ radius = diag * 3.0 # curvature radius
+ cyl_height = diag * 20.0 # width of the cylinder (along its axis)
+ scene["ground_floor"] = {
+ "type": "rectangle",
+ "to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
+ @ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
+ "bsdf": bsdf_ground,
+ }
+ # Cylinder: axis along Y, positioned behind model, radius sweeps up
+ scene["ground_backdrop"] = {
+ "type": "cylinder",
+ "radius": radius,
+ "p0": [-cyl_height / 2, -radius, ground_z],
+ "p1": [cyl_height / 2, -radius, ground_z],
+ "to_world": mi.ScalarTransform4f.rotate([1, 0, 0], 90)
+ @ mi.ScalarTransform4f.translate([0.0, 0.0, -radius]),
+ "bsdf": bsdf_ground,
+ }
+ else:
+ # Simple flat ground plane — very large so edges aren't visible
+ half_size = diag * 50.0
+ scene["ground_plane"] = {
+ "type": "rectangle",
+ "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
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 = mi.load_dict(scene_dict)
- logger.info(
- f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp"
- )
+ logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
# Render
- image = mi.render(scene, spp=settings.spp, seed=settings.seed or 0)
+ try:
+ image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime
+ except Exception as e:
+ logger.error(f"Mitsuba render failed: {e}")
+ raise
if progress_callback:
progress_callback(1.0)
@@ -281,6 +316,8 @@ class MitsubaBackend(RenderBackend):
spp=max(settings.spp // 4, 16),
max_depth=min(settings.max_depth, 4),
seed=settings.seed,
+ lighting=settings.lighting,
+ ground_plane=settings.ground_plane,
)
return self.render(mesh_path, material, camera, preview_settings)
@@ -295,8 +332,8 @@ class MitsubaBackend(RenderBackend):
if ext == ".exr":
# Save as EXR (HDR) — no tonemapping
try:
- import OpenEXR
- import Imath
+ import OpenEXR # type: ignore[import-not-found]
+ import Imath # type: ignore[import-not-found]
h, w = image.shape[:2]
header = OpenEXR.Header(w, h)
diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py
index 67f2e56..049566d 100644
--- a/src/fluency/rendering/occ_renderer.py
+++ b/src/fluency/rendering/occ_renderer.py
@@ -677,8 +677,10 @@ class OCCRenderer(Renderer):
2.0 * math.tan(math.radians(fov_y / 2.0))
)
- # Scale factor maps directly: smaller scale (zoomed in) → closer camera.
- adjusted_distance = base_distance * view_scale
+ # Scale factor maps inversely: larger scale (zoomed in) → closer camera.
+ # Dividing by view_scale ensures that when the user zooms in (scale increases)
+ # the render camera moves closer, matching the viewport.
+ adjusted_distance = base_distance / view_scale
# Direction from target toward the original eye position.
direction = eye - at
diff --git a/src/fluency/rendering/render_backend.py b/src/fluency/rendering/render_backend.py
index c9e5197..00daeb8 100644
--- a/src/fluency/rendering/render_backend.py
+++ b/src/fluency/rendering/render_backend.py
@@ -59,6 +59,7 @@ class GroundPlaneConfig:
color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color
roughness: float = 0.8 # surface roughness [0..1]
distance_below: float = 0.0 # mm below origin (positive = below)
+ curved_backdrop: bool = False # photo booth style curved leinwand
@dataclass
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
index dc325b4..9bda0b1 100644
--- a/src/fluency/ui/main_window.py
+++ b/src/fluency/ui/main_window.py
@@ -5,69 +5,42 @@ from __future__ import annotations
import math
import logging
import os
-import sys
-import tempfile
-import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
-from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect, QSettings
+from PySide6.QtCore import Qt, Slot, QSize, QSettings
from PySide6.QtGui import (
QAction,
QColor,
QFont,
- QIcon,
QKeySequence,
- QPainter,
- QPainterPath,
- QPen,
)
+
MAX_RECENT_PROJECTS = 10
from PySide6.QtWidgets import (
- QApplication,
QButtonGroup,
QCheckBox,
- QComboBox,
QDialog,
- QDialogButtonBox,
- QDockWidget,
QDoubleSpinBox,
QFileDialog,
QFrame,
- QGridLayout,
- QGroupBox,
QHBoxLayout,
QInputDialog,
QLabel,
- QLineEdit,
- QListWidget,
QListWidgetItem,
QMainWindow,
QMenu,
- QMenuBar,
QMessageBox,
QPushButton,
- QRadioButton,
- QSizePolicy,
- QSplitter,
- QSpinBox,
- QStatusBar,
- QTabWidget,
- QTextEdit,
- QToolBar,
- QTreeWidget,
- QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
-from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
+from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.geometry_occ.sketch import OCCSketch
-from fluency.geometry.base import Point2D, Point3D
from fluency.io.project_io import load_project, project_zip_path, save_project
from fluency.models.data_model import Project, Component, Sketch, Body, Workplane
-from fluency.rendering.occ_renderer import OCCRenderer
from fluency.ui.dialogs import (
ExtrudeDialog,
@@ -82,9 +55,12 @@ from gui_ui import Ui_fluencyCAD # auto-generated Qt form (project root on sys.
logger = logging.getLogger(__name__)
+
def _project_body_to_workplane(
body_shape: Any,
- workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]],
+ workplane: Tuple[
+ Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]
+ ],
) -> List[List[Tuple[float, float]]]:
"""Project ALL edges of a 3D body onto a workplane, returning UV polylines.
@@ -101,7 +77,7 @@ def _project_body_to_workplane(
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_WIRE
from OCP.TopoDS import TopoDS
- from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
+ from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Line
from OCP.gp import gp_Pnt
@@ -150,7 +126,9 @@ def _project_body_to_workplane(
return polylines
-def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[Tuple[float, float]]:
+def _offset_polygon(
+ points: List[Tuple[float, float]], distance: float
+) -> List[Tuple[float, float]]:
"""Offset a closed polygon by *distance* (positive = outward).
Uses the edge-normal method: each edge is offset along its outward
@@ -161,7 +139,6 @@ def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[
Returns the offset polygon as a list of (x, y) tuples (same length
as *points*, closed).
"""
- import math
n = len(points)
if n < 3:
return list(points)
@@ -199,7 +176,7 @@ def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[
for i in range(n):
prev_i = (i - 1 + n) % n
n_prev = normals[prev_i] # outward normal of edge (prev, i)
- n_curr = normals[i] # outward normal of edge (i, next)
+ n_curr = normals[i] # outward normal of edge (i, next)
# Intersect the two offset edge lines to find the new vertex.
# Line 1: through points[prev_i] + d*n_prev, direction = edges[prev_i]
@@ -215,8 +192,9 @@ def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[
det = d1x * d2y - d1y * d2x
if abs(det) < 1e-9:
# Parallel edges — fall back to normal offset.
- result.append((points[i][0] + distance * n_curr[0],
- points[i][1] + distance * n_curr[1]))
+ result.append(
+ (points[i][0] + distance * n_curr[0], points[i][1] + distance * n_curr[1])
+ )
else:
diff_x = p2x - p1x
diff_y = p2y - p1y
@@ -226,12 +204,6 @@ def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[
return result
-
-
-
-
-
-
class MainWindow(QMainWindow):
"""Main application window."""
@@ -256,9 +228,6 @@ class MainWindow(QMainWindow):
self._assembly_view_active: bool = False
self._selected_assembly_component_id: Optional[str] = None
- # Render window reference (prevents GC)
- self._render_window = None
-
# Connector two-click state
self._connector_first_pick: Optional[Dict[str, Any]] = None
self._connector_second_ac_id: Optional[str] = None
@@ -346,7 +315,9 @@ class MainWindow(QMainWindow):
file_menu.addSeparator()
self._action_load_last = QAction("Load Last Project on Startup", self)
self._action_load_last.setCheckable(True)
- self._action_load_last.setChecked(self._settings.value("load_last_on_startup", False, type=bool))
+ self._action_load_last.setChecked(
+ self._settings.value("load_last_on_startup", False, type=bool)
+ )
self._action_load_last.toggled.connect(self._toggle_load_last_project)
file_menu.addAction(self._action_load_last)
@@ -405,11 +376,17 @@ class MainWindow(QMainWindow):
self._viewer_3d = Viewer3DWidget()
self._ui.gl_box.layout().addWidget(self._viewer_3d)
- # Code editor — use the UI’s textEdit with our custom font.
+ # Code editor — use the UI's textEdit with our custom font.
self._code_edit = self._ui.textEdit
self._code_edit.setFont(QFont("Monaco", 10))
self._code_edit.setPlaceholderText("# Enter Python code here...")
+ # ── Render tab (adds "Render" tab to InputTab) ─────────────
+ from fluency.ui.render_window import RenderTabContent
+
+ self._render_tab = RenderTabContent()
+ self._ui.InputTab.addTab(self._render_tab, "Render")
+
# Component buttons (dynamically generated per component, not in UI).
self._component_box = QWidget()
self._component_box_layout = QHBoxLayout(self._component_box)
@@ -443,7 +420,9 @@ class MainWindow(QMainWindow):
self._btn_asm_move.setCheckable(True)
self._btn_asm_move.setMinimumSize(QSize(50, 50))
self._btn_asm_move.setMaximumSize(QSize(50, 50))
- self._btn_asm_move.setToolTip("Toggle: click a body in 3D and drag to move the assembly component")
+ self._btn_asm_move.setToolTip(
+ "Toggle: click a body in 3D and drag to move the assembly component"
+ )
asm_tools_layout = self._ui.assembly_tools.layout()
if asm_tools_layout is not None:
asm_tools_layout.addWidget(self._btn_asm_move, 0, 2, 1, 1)
@@ -624,9 +603,7 @@ class MainWindow(QMainWindow):
self._btn_del_sketch.clicked.connect(self._delete_sketch)
self._btn_wp_face.toggled.connect(self._on_face_sketch_toggled)
self._viewer_3d.facePicked.connect(self._on_face_picked)
- self._viewer_3d.pickFaceCancelled.connect(
- lambda: self._btn_wp_face.setChecked(False)
- )
+ self._viewer_3d.pickFaceCancelled.connect(lambda: self._btn_wp_face.setChecked(False))
self._btn_new_compo.clicked.connect(self._new_component)
self._btn_del_compo.clicked.connect(self._delete_component)
@@ -642,15 +619,9 @@ class MainWindow(QMainWindow):
self._viewer_3d.connectorPickCancelled.connect(
lambda: self._btn_add_connector.setChecked(False)
)
- self._viewer_3d.assemblyComponentActivated.connect(
- self._on_assembly_move_activated
- )
- self._viewer_3d.assemblyComponentDragged.connect(
- self._on_assembly_move_dragged
- )
- self._viewer_3d.assemblyMoveFinished.connect(
- self._on_assembly_move_finished
- )
+ self._viewer_3d.assemblyComponentActivated.connect(self._on_assembly_move_activated)
+ self._viewer_3d.assemblyComponentDragged.connect(self._on_assembly_move_dragged)
+ self._viewer_3d.assemblyMoveFinished.connect(self._on_assembly_move_finished)
self._btn_update_body.clicked.connect(self._update_and_redraw)
self._btn_del_body.clicked.connect(self._delete_body)
@@ -690,6 +661,12 @@ class MainWindow(QMainWindow):
# Snap toggle
self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c))
+ # Sync 3D viewport camera → render tab when rendering is active.
+ self._viewer_3d.cameraChanged.connect(self._on_camera_changed)
+
+ # Auto-load selected body when switching to the Render tab.
+ self._ui.InputTab.currentChanged.connect(self._on_tab_changed)
+
def _create_initial_component(self):
self._new_component()
@@ -817,7 +794,7 @@ class MainWindow(QMainWindow):
self._component_buttons[0].setChecked(True)
self._refresh_lists()
- logger.info(f"Deleted component")
+ logger.info("Deleted component")
def _on_component_button_clicked(self):
idx = self._get_active_component_index()
@@ -849,9 +826,7 @@ class MainWindow(QMainWindow):
item.setData(Qt.UserRole, body_id)
# Qt.Checked = visible, Qt.Unchecked = hidden. Default
# is whatever the body model says.
- item.setCheckState(
- Qt.Checked if body.visible else Qt.Unchecked
- )
+ item.setCheckState(Qt.Checked if body.visible else Qt.Unchecked)
# Greying out a hidden body's name is a nice UX touch.
if not body.visible:
item.setForeground(QColor("#6c7086"))
@@ -923,7 +898,10 @@ class MainWindow(QMainWindow):
# Resolve the target body for cut / union.
target = None
if body.extrude_cut or body.extrude_union:
- if body.extrude_target_body_id and body.extrude_target_body_id in self._current_component.bodies:
+ if (
+ body.extrude_target_body_id
+ and body.extrude_target_body_id in self._current_component.bodies
+ ):
target = self._current_component.bodies[body.extrude_target_body_id]
else:
# Fallback: first body that isn't this one.
@@ -937,15 +915,20 @@ class MainWindow(QMainWindow):
if body.extrude_through_all and target is not None:
length = self._through_all_length(target, sketch)
result = self._compute_extrude_result(
- sketch, face_geom,
- length, symmetric=True, invert=False,
- cut=body.extrude_cut, union=body.extrude_union,
+ sketch,
+ face_geom,
+ length,
+ symmetric=True,
+ invert=False,
+ cut=body.extrude_cut,
+ union=body.extrude_union,
through_all=True,
)
else:
length = body.extrude_length
result = self._compute_extrude_result(
- sketch, face_geom,
+ sketch,
+ face_geom,
length,
symmetric=body.extrude_symmetric,
invert=body.extrude_invert,
@@ -1013,15 +996,15 @@ class MainWindow(QMainWindow):
world_pos = ac.position + ac.rotation @ local_pos
# Project to screen and probe nearby geometry.
- screen = self._viewer_3d._renderer._project_to_screen(
- tuple(world_pos)
- )
+ screen = self._viewer_3d._renderer._project_to_screen(tuple(world_pos))
if screen is None:
continue
try:
candidates = self._viewer_3d._renderer.probe_snap_candidates(
- screen[0], screen[1], radius=20,
+ screen[0],
+ screen[1],
+ radius=20,
)
except Exception:
continue
@@ -1074,10 +1057,7 @@ class MainWindow(QMainWindow):
)
if updated or invalidated:
- logger.info(
- f"Connector recalc: {updated} updated, "
- f"{invalidated} invalidated"
- )
+ logger.info(f"Connector recalc: {updated} updated, {invalidated} invalidated")
def _redraw_bodies(self):
self._viewer_3d.clear_scene()
@@ -1180,7 +1160,7 @@ class MainWindow(QMainWindow):
# Find the button index for this assembly component.
idx = -1
for i, btn in enumerate(self._assembly_component_buttons):
- if getattr(btn, '_assembly_component_id', None) == active_id:
+ if getattr(btn, "_assembly_component_id", None) == active_id:
idx = i
break
@@ -1194,7 +1174,9 @@ class MainWindow(QMainWindow):
# Select the first remaining button if any.
if self._assembly_component_buttons:
self._assembly_component_buttons[0].setChecked(True)
- first_id = getattr(self._assembly_component_buttons[0], '_assembly_component_id', None)
+ first_id = getattr(
+ self._assembly_component_buttons[0], "_assembly_component_id", None
+ )
self._selected_assembly_component_id = first_id
self._assembly_view_active = True
else:
@@ -1212,7 +1194,7 @@ class MainWindow(QMainWindow):
"""Get the assembly component id of the currently checked button."""
for btn in self._assembly_component_buttons:
if btn.isChecked():
- return getattr(btn, '_assembly_component_id', None)
+ return getattr(btn, "_assembly_component_id", None)
return None
def _on_assembly_component_clicked(self):
@@ -1255,17 +1237,29 @@ class MainWindow(QMainWindow):
# [R31 R32 R33 Tz]
trsf = gp_Trsf()
trsf.SetValues(
- float(rot[0, 0]), float(rot[0, 1]), float(rot[0, 2]), float(pos[0]),
- float(rot[1, 0]), float(rot[1, 1]), float(rot[1, 2]), float(pos[1]),
- float(rot[2, 0]), float(rot[2, 1]), float(rot[2, 2]), float(pos[2]),
+ float(rot[0, 0]),
+ float(rot[0, 1]),
+ float(rot[0, 2]),
+ float(pos[0]),
+ float(rot[1, 0]),
+ float(rot[1, 1]),
+ float(rot[1, 2]),
+ float(pos[1]),
+ float(rot[2, 0]),
+ float(rot[2, 1]),
+ float(rot[2, 2]),
+ float(pos[2]),
)
transformer = BRepBuilderAPI_Transform(shape, trsf, False)
transformer.Build()
return transformer.Shape()
- def _make_connector_marker(self, position: Tuple[float, float, float],
- color: Tuple[float, float, float] = (1.0, 0.3, 0.0)) -> Optional[Any]:
+ def _make_connector_marker(
+ self,
+ position: Tuple[float, float, float],
+ color: Tuple[float, float, float] = (1.0, 0.3, 0.0),
+ ) -> Optional[Any]:
"""Create a sphere marker for a connector at *position*.
Returns the TopoDS_Shape of a sphere, or None on failure.
@@ -1273,6 +1267,7 @@ class MainWindow(QMainWindow):
try:
from OCP.gp import gp_Pnt
from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
+
sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), 4.0).Shape()
return sphere
except Exception as exc:
@@ -1302,16 +1297,18 @@ class MainWindow(QMainWindow):
self._asm_render_objects.clear()
highlight_color = (0.2, 0.6, 1.0) # Bright blue for selected
- dim_color = (0.5, 0.5, 0.5) # Grey for non-selected
+ dim_color = (0.5, 0.5, 0.5) # Grey for non-selected
shown_any = False
for ac_id, ac in assembly.components.items():
comp = self._project.get_component_by_id(ac.component_id)
if comp is None:
- logger.debug(f"Assembly component {ac_id} references missing component {ac.component_id}")
+ logger.debug(
+ f"Assembly component {ac_id} references missing component {ac.component_id}"
+ )
continue
- is_selected = (ac_id == self._selected_assembly_component_id)
+ is_selected = ac_id == self._selected_assembly_component_id
color = highlight_color if is_selected else dim_color
render_ids: List[str] = []
@@ -1320,9 +1317,7 @@ class MainWindow(QMainWindow):
try:
shape = self._kernel._get_shape(body.geometry)
# Apply component instance transform.
- transformed = self._apply_transform(
- shape, ac.position, ac.rotation
- )
+ transformed = self._apply_transform(shape, ac.position, ac.rotation)
obj_id = f"asm_{ac_id}_{body_id}"
render_obj = self._viewer_3d.show_shape(
transformed,
@@ -1390,7 +1385,7 @@ class MainWindow(QMainWindow):
except Exception:
pass
- is_selected = (ac_id == self._selected_assembly_component_id)
+ is_selected = ac_id == self._selected_assembly_component_id
color = (0.2, 0.6, 1.0) if is_selected else (0.5, 0.5, 0.5)
new_ids: List[str] = []
@@ -1398,9 +1393,7 @@ class MainWindow(QMainWindow):
if body.geometry:
try:
shape = self._kernel._get_shape(body.geometry)
- transformed = self._apply_transform(
- shape, ac.position, ac.rotation
- )
+ transformed = self._apply_transform(shape, ac.position, ac.rotation)
obj_id = f"asm_{ac_id}_{body_id}"
self._viewer_3d.show_shape(
transformed,
@@ -1413,6 +1406,7 @@ class MainWindow(QMainWindow):
# Re-add connector markers at updated world positions.
import numpy as np
+
for conn_id, conn in ac.connectors.items():
try:
local_pos = np.array(conn.position, dtype=float)
@@ -1443,8 +1437,11 @@ class MainWindow(QMainWindow):
"""
if checked and not self._assembly_view_active:
self._btn_asm_move.setChecked(False)
- QMessageBox.warning(self, "Assembly View",
- "Switch to assembly view first by clicking an assembly component button.")
+ QMessageBox.warning(
+ self,
+ "Assembly View",
+ "Switch to assembly view first by clicking an assembly component button.",
+ )
return
self._viewer_3d.set_assembly_move_mode(checked)
if checked:
@@ -1507,6 +1504,7 @@ class MainWindow(QMainWindow):
return
import numpy as np
+
delta = np.array([dx, dy, dz], dtype=float)
# Propagate the same delta to every rigid-group member.
group_ids = self._asm_move_group_ids or [ac_id]
@@ -1560,8 +1558,11 @@ class MainWindow(QMainWindow):
"""
if not self._assembly_view_active:
self._btn_add_connector.setChecked(False)
- QMessageBox.warning(self, "Assembly View",
- "Switch to assembly view first by clicking an assembly component button.")
+ QMessageBox.warning(
+ self,
+ "Assembly View",
+ "Switch to assembly view first by clicking an assembly component button.",
+ )
return
# Reset any in-progress two-click state.
@@ -1620,15 +1621,17 @@ class MainWindow(QMainWindow):
ac_id = self._parse_ac_id(owner_obj_id)
if ac_id is None:
- QMessageBox.warning(self, "Pick Error",
- "Could not identify which assembly component was clicked.")
+ QMessageBox.warning(
+ self, "Pick Error", "Could not identify which assembly component was clicked."
+ )
return
assembly = self._get_assembly()
ac = assembly.components.get(ac_id)
if ac is None:
- QMessageBox.warning(self, "Pick Error",
- "The clicked component was not found in the assembly.")
+ QMessageBox.warning(
+ self, "Pick Error", "The clicked component was not found in the assembly."
+ )
return
# Convert world-space connector to component-local coordinates.
@@ -1669,8 +1672,11 @@ class MainWindow(QMainWindow):
# Don't allow picking the same component twice.
if ac_id == first["ac_id"]:
- QMessageBox.warning(self, "Same Component",
- "Pick a different component for the second connection point.")
+ QMessageBox.warning(
+ self,
+ "Same Component",
+ "Pick a different component for the second connection point.",
+ )
return
self._connector_second_ac_id = ac_id
@@ -1717,8 +1723,7 @@ class MainWindow(QMainWindow):
)
if solved is None:
- QMessageBox.warning(self, "Solver Error",
- "SolveSpace could not align the components.")
+ QMessageBox.warning(self, "Solver Error", "SolveSpace could not align the components.")
self._connector_first_pick = None
self._connector_second_ac_id = None
self._show_assembly_in_viewer(fit=True)
@@ -1752,8 +1757,12 @@ class MainWindow(QMainWindow):
if rotation is None:
# User cancelled — restore original position.
if moved_comp_before_dialog is not None:
- moved_comp_before_dialog.position = np.array(solved["original_position"], dtype=float)
- moved_comp_before_dialog.rotation = np.array(solved["original_rotation"], dtype=float)
+ moved_comp_before_dialog.position = np.array(
+ solved["original_position"], dtype=float
+ )
+ moved_comp_before_dialog.rotation = np.array(
+ solved["original_rotation"], dtype=float
+ )
self._connector_first_pick = None
self._connector_second_ac_id = None
self._show_assembly_in_viewer(fit=True)
@@ -1761,6 +1770,7 @@ class MainWindow(QMainWindow):
# Apply dialog adjustments (rotation + offset + flip).
import numpy as np
+
# Build rotation matrix: rotate second connector normal around
# the target normal axis by rotation degrees.
angle_rad = np.radians(rotation)
@@ -1815,8 +1825,10 @@ class MainWindow(QMainWindow):
conn_m.partner_connector_id = conn_a.id
assembly.add_connection(anchor_ac_id, moved_ac_id)
- logger.info(f"Connected: anchor={anchor_ac_id} ↔ moved={moved_ac_id}, "
- f"rotation={rotation}°, offset={offset}mm, flip={flip}")
+ logger.info(
+ f"Connected: anchor={anchor_ac_id} ↔ moved={moved_ac_id}, "
+ f"rotation={rotation}°, offset={offset}mm, flip={flip}"
+ )
self._connector_first_pick = None
self._connector_second_ac_id = None
self._mark_dirty()
@@ -1838,12 +1850,15 @@ class MainWindow(QMainWindow):
"""
import numpy as _np
import math as _math
+
a = _np.asarray(a, dtype=float)
b = _np.asarray(b, dtype=float)
- an = _np.linalg.norm(a); bn = _np.linalg.norm(b)
+ an = _np.linalg.norm(a)
+ bn = _np.linalg.norm(b)
if an < 1e-12 or bn < 1e-12:
return _np.eye(3)
- a = a / an; b = b / bn
+ a = a / an
+ b = b / bn
dot = float(_np.dot(a, b))
cross = _np.cross(a, b)
cross_norm = _np.linalg.norm(cross)
@@ -1885,12 +1900,18 @@ class MainWindow(QMainWindow):
* ``original_position`` / ``original_rotation`` — for cancellation.
"""
import numpy as np
+
try:
from python_solvespace import SolverSystem, ResultFlag, Entity
except ImportError:
logger.warning("python_solvespace not available, falling back to direct alignment")
- return self._align_direct(first_ac, second_ac, first_pick, second_pick,
- anchor_component_id=anchor_component_id)
+ return self._align_direct(
+ first_ac,
+ second_ac,
+ first_pick,
+ second_pick,
+ anchor_component_id=anchor_component_id,
+ )
# ── Determine anchor and mover ──
# The anchor component stays locked. Prefer anchor_component_id
@@ -1979,8 +2000,13 @@ class MainWindow(QMainWindow):
result = sys.solve()
if result != ResultFlag.OKAY:
logger.warning(f"SolveSpace solve failed: {result}")
- return self._align_direct(first_ac, second_ac, first_pick, second_pick,
- anchor_component_id=anchor_component_id)
+ return self._align_direct(
+ first_ac,
+ second_ac,
+ first_pick,
+ second_pick,
+ anchor_component_id=anchor_component_id,
+ )
# Extract solved positions.
p_solved = np.array(sys.params(pt_mover.params), dtype=float)
@@ -2073,9 +2099,7 @@ class MainWindow(QMainWindow):
Returns (rotation_degrees, offset_mm, flip) or (None, None, False) if cancelled.
"""
- from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
- QLabel, QDoubleSpinBox, QPushButton,
- QFrame, QCheckBox)
+ from PySide6.QtWidgets import QHBoxLayout, QPushButton
if second_ac is None:
return (None, None, False)
@@ -2097,7 +2121,9 @@ class MainWindow(QMainWindow):
}
t1 = entity_names.get(first_pick.get("entity_type", ""), "Entity")
t2 = entity_names.get(second_pick.get("entity_type", ""), "Entity")
- layout.addWidget(QLabel(f"{t1} on {first_ac.name} → {t2} on {second_ac.name}"))
+ layout.addWidget(
+ QLabel(f"{t1} on {first_ac.name} → {t2} on {second_ac.name}")
+ )
layout.addWidget(QLabel("Adjust the connection:"))
# Rotation around normal axis.
@@ -2138,6 +2164,7 @@ class MainWindow(QMainWindow):
layout.addLayout(btn_layout)
import numpy as np
+
target_normal = np.array(first_pick["normal_world"], dtype=float)
target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
@@ -2184,15 +2211,15 @@ class MainWindow(QMainWindow):
"""Delete the connector nearest to the selected assembly component."""
active_id = self._get_active_assembly_component_id()
if active_id is None:
- QMessageBox.warning(self, "No Selection",
- "Select an assembly component first")
+ QMessageBox.warning(self, "No Selection", "Select an assembly component first")
return
assembly = self._get_assembly()
ac = assembly.components.get(active_id)
if ac is None or not ac.connectors:
- QMessageBox.information(self, "No Connectors",
- "This component has no connectors to remove.")
+ QMessageBox.information(
+ self, "No Connectors", "This component has no connectors to remove."
+ )
return
# List connectors in a simple choice dialog.
@@ -2200,6 +2227,7 @@ class MainWindow(QMainWindow):
conn_labels = [f"{c.name} at {c.position}" for c in ac.connectors.values()]
from PySide6.QtWidgets import QInputDialog
+
label, ok = QInputDialog.getItem(
self, "Remove Connector", "Select connector:", conn_labels, 0, False
)
@@ -2220,13 +2248,18 @@ class MainWindow(QMainWindow):
pc.partner_connector_id = None
pc.is_grounded = False
# Remove the connection edge either side references this pair.
- assembly.connections = [
- c for c in assembly.connections
- if not (
- (c.first_ac_id == active_id and c.second_ac_id == partner_ac_id)
- or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id)
- )
- ] if partner_ac_id is not None else assembly.connections
+ assembly.connections = (
+ [
+ c
+ for c in assembly.connections
+ if not (
+ (c.first_ac_id == active_id and c.second_ac_id == partner_ac_id)
+ or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id)
+ )
+ ]
+ if partner_ac_id is not None
+ else assembly.connections
+ )
ac.remove_connector(conn_id)
logger.info(f"Removed connector {conn_id}")
self._show_assembly_in_viewer(fit=True)
@@ -2273,8 +2306,7 @@ class MainWindow(QMainWindow):
"""
selected = self._connection_list.currentItem()
if selected is None:
- QMessageBox.information(self, "No Selection",
- "Select a connection to delete")
+ QMessageBox.information(self, "No Selection", "Select a connection to delete")
return
conn_id = selected.data(Qt.UserRole)
@@ -2362,13 +2394,13 @@ class MainWindow(QMainWindow):
self._current_component = self._project.add_component()
# Create the Workplane model.
- from fluency.models.data_model import Workplane
wp = self._current_component.add_workplane(
Workplane(
name=wp_name,
origin=origin,
normal=normal,
- x_dir=x_dir, )
+ x_dir=x_dir,
+ )
)
# The preview visual becomes the permanent workplane; just update
@@ -2437,7 +2469,7 @@ class MainWindow(QMainWindow):
return
origin = wp[0] # (ox, oy, oz)
normal = wp[1] # (nx, ny, nz)
- x_dir = wp[2] # (xx, xy, xz)
+ x_dir = wp[2] # (xx, xy, xz)
# Collect all body shapes from the current component.
body_shapes = []
@@ -2559,7 +2591,7 @@ class MainWindow(QMainWindow):
pick up user-drawn boundary points.
"""
matches: List[Optional[OCCSketchEntity]] = []
- for (tx, ty) in positions:
+ for tx, ty in positions:
found: Optional[OCCSketchEntity] = None
for eid, entity in sketch._entities.items():
if entity.entity_type != "point":
@@ -2600,9 +2632,10 @@ class MainWindow(QMainWindow):
selected_face = self._sketch_widget._selected_face
if selected_face is None:
QMessageBox.warning(
- self, "No Face Selected",
+ self,
+ "No Face Selected",
"Click inside a closed face (region) in the sketch to select it, "
- "then press Offset."
+ "then press Offset.",
)
return
@@ -2619,14 +2652,18 @@ class MainWindow(QMainWindow):
elif outer["type"] == "polygon":
pts = list(outer["points"])
if len(pts) < 3:
- QMessageBox.warning(self, "Invalid Polygon", "Face boundary has fewer than 3 points.")
+ QMessageBox.warning(
+ self, "Invalid Polygon", "Face boundary has fewer than 3 points."
+ )
return
# Remove closing duplicate (last == first) if present.
if len(pts) > 1 and pts[-1] == pts[0]:
pts.pop()
is_circle = False
else:
- QMessageBox.warning(self, "Unsupported Face", f"Face type '{outer.get('type')}' not supported.")
+ QMessageBox.warning(
+ self, "Unsupported Face", f"Face type '{outer.get('type')}' not supported."
+ )
return
# ── Find the ORIGINAL point entities so we can constrain to them ──
@@ -2639,7 +2676,9 @@ class MainWindow(QMainWindow):
# ── Open dialog with live preview ──
dialog = OffsetDialog(self)
- def _compute_offset_preview(distance: float, inward: bool) -> Optional[List[Tuple[float, float]]]:
+ def _compute_offset_preview(
+ distance: float, inward: bool
+ ) -> Optional[List[Tuple[float, float]]]:
"""Return offset polygon points, or None for circles."""
d = -distance if inward else distance
if is_circle:
@@ -2675,13 +2714,21 @@ class MainWindow(QMainWindow):
# ── Apply offset: create new entities in the sketch ──
if is_circle:
self._apply_circle_offset(
- sketch, cx, cy, radius, d, selected_face,
+ sketch,
+ cx,
+ cy,
+ radius,
+ d,
+ selected_face,
parent_center_entity=parent_center_entity,
offset_distance=abs(d),
)
else:
self._apply_polygon_offset(
- sketch, pts, d, selected_face,
+ sketch,
+ pts,
+ d,
+ selected_face,
parent_entities=parent_entities,
offset_distance=abs(d),
)
@@ -2701,7 +2748,8 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Error", f"Offset failed: {e}")
def _apply_polygon_offset(
- self, sketch: OCCSketch,
+ self,
+ sketch: OCCSketch,
pts: List[Tuple[float, float]],
distance: float,
face: Dict[str, Any],
@@ -2723,7 +2771,7 @@ class MainWindow(QMainWindow):
# Create new point entities at the offset positions.
new_points = []
- for (x, y) in offset_pts:
+ for x, y in offset_pts:
pt = sketch.add_point(float(x), float(y))
new_points.append(pt)
@@ -2746,7 +2794,8 @@ class MainWindow(QMainWindow):
except Exception as exc:
logger.debug(
"distance constraint failed for parent id=%s: %s",
- parent_ent.id, exc,
+ parent_ent.id,
+ exc,
)
if constrained:
logger.info("Added %d distance constraints to offset polygon", constrained)
@@ -2768,7 +2817,7 @@ class MainWindow(QMainWindow):
logger.debug("Hole offset collapsed — skipping")
continue
hole_points = []
- for (x, y) in offset_hole:
+ for x, y in offset_hole:
pt = sketch.add_point(float(x), float(y))
hole_points.append(pt)
for i in range(len(hole_points)):
@@ -2777,13 +2826,17 @@ class MainWindow(QMainWindow):
logger.info(
"Created %d offset points and %d offset lines for polygon boundary + %d holes",
- len(offset_pts), len(offset_pts),
+ len(offset_pts),
+ len(offset_pts),
len([h for h in holes if h.get("type") == "polygon"]),
)
def _apply_circle_offset(
- self, sketch: OCCSketch,
- cx: float, cy: float, radius: float,
+ self,
+ sketch: OCCSketch,
+ cx: float,
+ cy: float,
+ radius: float,
distance: float,
face: Dict[str, Any],
parent_center_entity: Optional[OCCSketchEntity] = None,
@@ -2833,7 +2886,9 @@ class MainWindow(QMainWindow):
logger.info(
"Created offset circle: center=(%.2f, %.2f), radius=%.2f",
- new_cx, new_cy, new_radius,
+ new_cx,
+ new_cy,
+ new_radius,
)
# ─── Sketch-on-surface (face pick) ────────────────────────────────────
@@ -2866,9 +2921,7 @@ class MainWindow(QMainWindow):
# against tracked AIS objects). We extract that owner so the cut
# can target the right body.
source_body = None
- logger.info(
- f"Face picked: origin={origin}, normal={normal}, x_dir={x_dir}"
- )
+ logger.info(f"Face picked: origin={origin}, normal={normal}, x_dir={x_dir}")
# Pull the owning obj_id the renderer stashed on this pick pass.
owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None)
if owner_obj_id and self._current_component is not None:
@@ -2922,9 +2975,7 @@ class MainWindow(QMainWindow):
break
# Switch focus to the sketch panel so the user can draw immediately.
self._set_panel_focus("sketch")
- self.statusBar().showMessage(
- f"Sketch placed on face — drawing in 2D on that plane", 6000
- )
+ self.statusBar().showMessage("Sketch placed on face — drawing in 2D on that plane", 6000)
# The face is now the source for the underlay construction lines:
# enable the show/hide toggle, ClrFace, and ToSketch buttons.
self._btn_underlay.setEnabled(True)
@@ -2941,9 +2992,7 @@ class MainWindow(QMainWindow):
paint + hover + hit-test while the toggle is off.
"""
self._sketch_widget.set_underlay_visible(checked)
- self.statusBar().showMessage(
- f"Underlay {'visible' if checked else 'hidden'}", 2000
- )
+ self.statusBar().showMessage(f"Underlay {'visible' if checked else 'hidden'}", 2000)
def _on_clear_source_face(self) -> None:
"""Forget the source face: remove underlay entities, keep the workplane.
@@ -3012,6 +3061,7 @@ class MainWindow(QMainWindow):
if sketch.occ_sketch is not None and hasattr(sketch.occ_sketch, "get_workplane"):
wp = sketch.occ_sketch.get_workplane()
import numpy as _np
+
sketch.workplane_origin = _np.asarray(wp[0], dtype=float)
sketch.workplane_normal = _np.asarray(wp[1], dtype=float)
sketch.workplane_x_dir = _np.asarray(wp[2], dtype=float)
@@ -3042,12 +3092,13 @@ class MainWindow(QMainWindow):
# surface), re-bind it so the underlay construction lines
# come back. set_source_face rebuilds the external
# entities and re-orients the 2D view.
- if getattr(sketch, "_source_face", None) is not None and sketch.occ_sketch is not None:
+ if (
+ getattr(sketch, "_source_face", None) is not None
+ and sketch.occ_sketch is not None
+ ):
wp = sketch.occ_sketch.get_workplane()
origin, normal, x_dir = wp[0], wp[1], wp[2]
- self._sketch_widget.set_source_face(
- sketch._source_face, origin, normal, x_dir
- )
+ self._sketch_widget.set_source_face(sketch._source_face, origin, normal, x_dir)
self._btn_underlay.setEnabled(True)
self._btn_underlay.setChecked(True)
self._btn_clr_face.setEnabled(True)
@@ -3078,7 +3129,11 @@ class MainWindow(QMainWindow):
for sketch_id, sketch in self._current_component.sketches.items():
if sketch.name == name:
self._current_sketch = sketch
- if sketch.occ_sketch and hasattr(sketch.occ_sketch, 'get_entity_count') and sketch.occ_sketch.get_entity_count() > 0:
+ if (
+ sketch.occ_sketch
+ and hasattr(sketch.occ_sketch, "get_entity_count")
+ and sketch.occ_sketch.get_entity_count() > 0
+ ):
self._sketch_widget.set_sketch(sketch.occ_sketch)
break
@@ -3147,11 +3202,10 @@ class MainWindow(QMainWindow):
if not ok:
logger.debug(
"set_visibility failed for body %s (render_object=%r)",
- body.name, body.render_object,
+ body.name,
+ body.render_object,
)
- logger.info(
- f"{'Visible' if new_visible else 'Hidden'}: {body.name}"
- )
+ logger.info(f"{'Visible' if new_visible else 'Hidden'}: {body.name}")
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
@@ -3195,6 +3249,7 @@ class MainWindow(QMainWindow):
boolean reliably removes the through volume.
"""
import numpy as _np
+
try:
p_min, p_max = self._kernel.get_bounding_box(target.geometry)
except Exception:
@@ -3260,9 +3315,7 @@ class MainWindow(QMainWindow):
invert = True
extrude_length = -length if invert else length
try:
- tool_geom = self._kernel.extrude(
- face_geom, extrude_length, symmetric=symmetric
- )
+ tool_geom = self._kernel.extrude(face_geom, extrude_length, symmetric=symmetric)
except Exception as exc:
logger.debug("preview extrude failed: %s", exc)
return None
@@ -3272,13 +3325,9 @@ class MainWindow(QMainWindow):
if target is not None:
try:
if cut:
- result_geom = self._kernel.boolean_difference(
- target.geometry, tool_geom
- )
+ result_geom = self._kernel.boolean_difference(target.geometry, tool_geom)
else: # union
- result_geom = self._kernel.boolean_union(
- target.geometry, tool_geom
- )
+ result_geom = self._kernel.boolean_union(target.geometry, tool_geom)
except Exception as exc:
logger.debug("preview boolean failed: %s", exc)
return None
@@ -3337,8 +3386,13 @@ class MainWindow(QMainWindow):
return
length, symmetric, invert, cut, union, through_all, _rounded = values
result = self._compute_extrude_result(
- sketch, face_geom,
- length, symmetric, invert, bool(cut), bool(union),
+ sketch,
+ face_geom,
+ length,
+ symmetric,
+ invert,
+ bool(cut),
+ bool(union),
bool(through_all),
)
if result is None or result["result_shape"] is None:
@@ -3406,8 +3460,13 @@ class MainWindow(QMainWindow):
try:
result = self._compute_extrude_result(
- sketch, face_geom,
- length, symmetric, invert, bool(cut), bool(union),
+ sketch,
+ face_geom,
+ length,
+ symmetric,
+ invert,
+ bool(cut),
+ bool(union),
bool(through_all),
)
if result is None or result["result_geom"] is None:
@@ -3437,13 +3496,9 @@ class MainWindow(QMainWindow):
if target.render_object is not None:
self._viewer_3d.remove_mesh(target.render_object)
shape = self._kernel._get_shape(target.geometry)
- target.render_object = self._viewer_3d.show_shape(
- shape, target.color, target.name
- )
+ target.render_object = self._viewer_3d.show_shape(shape, target.color, target.name)
op = "cut" if cut else "union"
- logger.info(
- f"{op.capitalize()} applied: {target.name} now holds the result"
- )
+ logger.info(f"{op.capitalize()} applied: {target.name} now holds the result")
body_name = target.name
else:
# Plain extrude: create a new body for the extrusion.
@@ -3466,9 +3521,7 @@ class MainWindow(QMainWindow):
logger.info(f"Created body: {body.name}")
logger.debug("Adding shape to OCC viewer")
shape = self._kernel._get_shape(body.geometry)
- body.render_object = self._viewer_3d.show_shape(
- shape, body.color, body.name
- )
+ body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
logger.info(f"Render object: {body.render_object}")
body_name = body.name
@@ -3539,7 +3592,11 @@ class MainWindow(QMainWindow):
def _boolean_cut(self):
logger.info("=== BOOLEAN CUT ===")
if not self._current_component or len(self._current_component.bodies) < 2:
- QMessageBox.warning(self, "Need Bodies", "Need at least 2 bodies to perform cut.\nCreate multiple bodies first.")
+ QMessageBox.warning(
+ self,
+ "Need Bodies",
+ "Need at least 2 bodies to perform cut.\nCreate multiple bodies first.",
+ )
return
# Use the first body in the list as base, last as tool
@@ -3550,7 +3607,12 @@ class MainWindow(QMainWindow):
# Let user pick which body to use as tool
body_names = [self._current_component.bodies[bid].name for bid in body_ids]
tool_name, ok = QInputDialog.getItem(
- self, "Select Tool Body", "Body to subtract (tool):", body_names, len(body_names) - 1, False
+ self,
+ "Select Tool Body",
+ "Body to subtract (tool):",
+ body_names,
+ len(body_names) - 1,
+ False,
)
if not ok:
return
@@ -3588,7 +3650,9 @@ class MainWindow(QMainWindow):
logger.debug("Adding shape to OCC viewer")
shape = self._kernel._get_shape(result_geom)
- new_body.render_object = self._viewer_3d.show_shape(shape, new_body.color, new_body.name)
+ new_body.render_object = self._viewer_3d.show_shape(
+ shape, new_body.color, new_body.name
+ )
logger.info(f"Render object: {new_body.render_object}")
self._refresh_lists()
@@ -3626,7 +3690,9 @@ class MainWindow(QMainWindow):
logger.debug("Adding shape to OCC viewer")
shape = self._kernel._get_shape(result_geom)
- new_body.render_object = self._viewer_3d.show_shape(shape, new_body.color, new_body.name)
+ new_body.render_object = self._viewer_3d.show_shape(
+ shape, new_body.color, new_body.name
+ )
logger.info(f"Render object: {new_body.render_object}")
self._refresh_lists()
@@ -3817,6 +3883,7 @@ class MainWindow(QMainWindow):
eye = [1.0, 1.0, 1.0]
at_ = [0.0, 0.0, 0.0]
up = [0.0, 0.0, 1.0]
+
# eye / at / up may be tuples, lists, or numpy arrays depending on
# the renderer — coerce to plain 3-lists.
def _flat3(v):
@@ -3828,6 +3895,7 @@ class MainWindow(QMainWindow):
if len(seq) < 3:
seq = seq + [0.0] * (3 - len(seq))
return [float(seq[0]), float(seq[1]), float(seq[2])]
+
return {
"active_tab": self._input_tabs.currentIndex() if hasattr(self, "_input_tabs") else 0,
"active_component_id": self._current_component.id if self._current_component else None,
@@ -3887,9 +3955,7 @@ class MainWindow(QMainWindow):
box.setWindowTitle("Unsaved Changes")
box.setText("This project has unsaved changes.")
box.setInformativeText("Save before continuing?")
- box.setStandardButtons(
- QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel
- )
+ box.setStandardButtons(QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel)
box.setDefaultButton(QMessageBox.Save)
choice = box.exec()
if choice == QMessageBox.Cancel:
@@ -3908,9 +3974,7 @@ class MainWindow(QMainWindow):
"""Prompt for a path and save. Returns True on success."""
# Default to the current file name so Save-As is one click away
# from a normal Save.
- default = self._project_path or os.path.join(
- os.path.expanduser("~"), "untitled.fluency"
- )
+ default = self._project_path or os.path.join(os.path.expanduser("~"), "untitled.fluency")
path, _ = QFileDialog.getSaveFileName(
self,
"Save Project",
@@ -3941,9 +4005,7 @@ class MainWindow(QMainWindow):
logger.info("Saved project: %s", path)
return True
except Exception as exc:
- QMessageBox.critical(
- self, "Save Failed", f"Could not save the project:\n{exc}"
- )
+ QMessageBox.critical(self, "Save Failed", f"Could not save the project:\n{exc}")
return False
def _open_project(self) -> bool:
@@ -3965,9 +4027,7 @@ class MainWindow(QMainWindow):
try:
project, view_state = load_project(path)
except Exception as exc:
- QMessageBox.critical(
- self, "Open Failed", f"Could not open the project:\n{exc}"
- )
+ QMessageBox.critical(self, "Open Failed", f"Could not open the project:\n{exc}")
return False
# Suppress dirty tracking while we replace the in-memory project
@@ -4045,11 +4105,19 @@ class MainWindow(QMainWindow):
self._assembly_component_group.addButton(btn)
self._assembly_box_layout.addWidget(btn)
# Restore the active assembly component selection.
- if assembly.active_assembly_component and assembly.active_assembly_component in assembly.components:
+ if (
+ assembly.active_assembly_component
+ and assembly.active_assembly_component in assembly.components
+ ):
for b in self._assembly_component_buttons:
- if getattr(b, '_assembly_component_id', None) == assembly.active_assembly_component:
+ if (
+ getattr(b, "_assembly_component_id", None)
+ == assembly.active_assembly_component
+ ):
b.setChecked(True)
- self._selected_assembly_component_id = assembly.active_assembly_component
+ self._selected_assembly_component_id = (
+ assembly.active_assembly_component
+ )
break
# If the saved view says we're in assembly view, switch over.
@@ -4172,9 +4240,7 @@ class MainWindow(QMainWindow):
self._viewer_3d.set_camera_position((100, 100, 100), (0, 0, 0))
def _open_render_window(self):
- """Open the render window for the selected body or assembly."""
- from fluency.ui.render_window import RenderWindow
-
+ """Populate the render tab with the selected body or assembly and switch to it."""
shape = None
# Try selected body first
if self._selected_body and self._selected_body.geometry:
@@ -4219,6 +4285,7 @@ class MainWindow(QMainWindow):
else:
# Fallback for pygfx or other renderers without the method.
from fluency.rendering.render_backend import RenderCamera
+
eye, at_, up = self._viewer_3d.get_camera_position()
fov = self._viewer_3d.get_camera_fov()
viewport_camera = RenderCamera(
@@ -4230,16 +4297,68 @@ class MainWindow(QMainWindow):
except Exception:
viewport_camera = None
- win = RenderWindow(shape=shape, camera=viewport_camera, parent=self)
- win.show()
- self._render_window = win # prevent GC
+ # Populate the render tab and switch to it.
+ self._render_tab.set_shape(shape, camera=viewport_camera)
+ self._ui.InputTab.setCurrentWidget(self._render_tab)
+
+ def _load_render_tab_shape(self) -> None:
+ """Auto-load the selected body or assembly component into the render tab."""
+ shape = None
+ # Try selected body first.
+ if self._selected_body and self._selected_body.geometry:
+ try:
+ shape = self._kernel._get_shape(self._selected_body.geometry)
+ except Exception as e:
+ logger.warning(f"Failed to get shape for render: {e}")
+ # Try selected assembly component.
+ if shape is None and self._selected_assembly_component_id:
+ ac_id = self._selected_assembly_component_id
+ for assembly in self._project.assemblies.values():
+ ac = assembly.components.get(ac_id)
+ if ac and ac.component_id:
+ src_comp = self._project.components.get(ac.component_id)
+ if src_comp:
+ for body in src_comp.bodies.values():
+ if body.geometry:
+ try:
+ shape = self._kernel._get_shape(body.geometry)
+ break
+ except Exception:
+ pass
+ break
+ if shape is None:
+ return
+ # Capture the viewport camera so the render matches what the user sees.
+ try:
+ renderer = self._viewer_3d.get_renderer()
+ if hasattr(renderer, "get_render_camera"):
+ viewport_camera = renderer.get_render_camera()
+ else:
+ from fluency.rendering.render_backend import RenderCamera
+
+ eye, at_, up = self._viewer_3d.get_camera_position()
+ fov = self._viewer_3d.get_camera_fov()
+ viewport_camera = RenderCamera(
+ origin=tuple(float(v) for v in eye),
+ target=tuple(float(v) for v in at_),
+ up=tuple(float(v) for v in up),
+ fov=fov,
+ )
+ except Exception:
+ viewport_camera = None
+ self._render_tab.set_shape(shape, camera=viewport_camera)
+
+ def _on_tab_changed(self, index: int) -> None:
+ """When the user switches to the Render tab, auto-load the selected body."""
+ if self._ui.InputTab.widget(index) is self._render_tab:
+ self._load_render_tab_shape()
# ─── Sketch Undo/Redo ─────────────────────────────────────────────────
def _get_sketch_widget(self):
"""Return the active sketch widget, or None if not in sketch mode."""
# The sketch widget is in the sketch tab
- if hasattr(self, '_sketch_widget'):
+ if hasattr(self, "_sketch_widget"):
return self._sketch_widget
return None
@@ -4297,4 +4416,35 @@ class MainWindow(QMainWindow):
"- Component timeline",
)
+ # ── Camera sync: 3D view → render tab ────────────────────────
+ def _on_camera_changed(self, eye: tuple, at_: tuple, up: tuple) -> None:
+ """Push the viewport camera into the render tab when it's active.
+
+ Fires whenever the 3D viewport camera changes (orbit, pan, zoom)
+ while the Render tab is selected. ``set_camera`` decides whether
+ to restart a running preview or just schedule a new one.
+ """
+ # Is the Render tab currently visible?
+ if self._ui.InputTab.currentWidget() is not self._render_tab:
+ return
+ # Nothing to render yet.
+ if self._render_tab._mesh_path is None:
+ return
+ try:
+ renderer = self._viewer_3d.get_renderer()
+ if hasattr(renderer, "get_render_camera"):
+ viewport_camera = renderer.get_render_camera()
+ else:
+ from fluency.rendering.render_backend import RenderCamera
+
+ fov = self._viewer_3d.get_camera_fov()
+ viewport_camera = RenderCamera(
+ origin=eye,
+ target=at_,
+ up=up,
+ fov=fov,
+ )
+ except Exception:
+ return
+ self._render_tab.set_camera(viewport_camera)
diff --git a/src/fluency/ui/render_window.py b/src/fluency/ui/render_window.py
index b38f5e5..cee6c4f 100644
--- a/src/fluency/ui/render_window.py
+++ b/src/fluency/ui/render_window.py
@@ -12,14 +12,13 @@ from typing import Optional
import numpy as np
from PySide6.QtCore import Qt, Signal, Slot, QThread, QTimer
-from PySide6.QtGui import QImage, QPixmap
+from PySide6.QtGui import QColor, QImage, QPixmap
from PySide6.QtWidgets import (
QColorDialog,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFileDialog,
- QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
@@ -30,7 +29,6 @@ from PySide6.QtWidgets import (
QPushButton,
QSizePolicy,
QSlider,
- QSplitter,
QSpinBox,
QVBoxLayout,
QWidget,
@@ -134,8 +132,8 @@ class RenderWindow(QMainWindow):
# Rendering threads & images
self._render_thread: Optional[_RenderThread] = None
self._preview_thread: Optional[_RenderThread] = None
- self._last_image: Optional[np.ndarray] = None # full render result
- self._last_preview: Optional[np.ndarray] = None # preview result
+ self._last_image: Optional[np.ndarray] = None # full render result
+ self._last_preview: Optional[np.ndarray] = None # preview result
self._camera: Optional[RenderCamera] = camera
self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5)
@@ -179,21 +177,15 @@ class RenderWindow(QMainWindow):
self._image_label = QLabel("Click Preview or Render to start")
self._image_label.setAlignment(Qt.AlignCenter)
- self._image_label.setSizePolicy(
- QSizePolicy.Expanding, QSizePolicy.Expanding
- )
+ self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._image_label.setMinimumSize(400, 300)
- self._image_label.setStyleSheet(
- "background: #1e1e2e; color: #6c7086; font-size: 14px;"
- )
+ self._image_label.setStyleSheet("background: #1e1e2e; color: #6c7086; font-size: 14px;")
self._image_container.addWidget(self._image_label, stretch=1)
# Status badge ("Preview" / "Full Render") — shown below image
self._status_badge = QLabel("")
self._status_badge.setAlignment(Qt.AlignCenter)
- self._status_badge.setStyleSheet(
- "color: #a6adc8; font-size: 11px; padding: 2px;"
- )
+ self._status_badge.setStyleSheet("color: #a6adc8; font-size: 11px; padding: 2px;")
self._image_container.addWidget(self._status_badge)
right.addLayout(self._image_container, stretch=1)
@@ -214,9 +206,7 @@ class RenderWindow(QMainWindow):
# Auto-preview checkbox
self._auto_preview_cb = QCheckBox("Auto-preview")
self._auto_preview_cb.setChecked(True)
- self._auto_preview_cb.setToolTip(
- "Automatically preview when settings change"
- )
+ self._auto_preview_cb.setToolTip("Automatically preview when settings change")
controls.addWidget(self._auto_preview_cb)
# Samples per pixel
@@ -232,9 +222,7 @@ class RenderWindow(QMainWindow):
# Resolution
controls.addWidget(QLabel("Resolution:"))
self._res_combo = QComboBox()
- self._res_combo.addItems(
- ["1280×720", "1920×1080", "2560×1440", "3840×2160"]
- )
+ self._res_combo.addItems(["1280×720", "1920×1080", "2560×1440", "3840×2160"])
self._res_combo.setCurrentText("1920×1080")
controls.addWidget(self._res_combo)
@@ -425,29 +413,24 @@ class RenderWindow(QMainWindow):
preset_row = QHBoxLayout()
preset_row.addWidget(QLabel("Preset:"))
self._light_preset_combo = QComboBox()
- self._light_preset_combo.addItems([
- "Studio (default)", "Soft", "Dramatic", "Bright",
- ])
- self._light_preset_combo.setCurrentIndex(0)
- self._light_preset_combo.currentTextChanged.connect(
- self._on_lighting_preset_changed
+ self._light_preset_combo.addItems(
+ [
+ "Studio (default)",
+ "Soft",
+ "Dramatic",
+ "Bright",
+ ]
)
+ self._light_preset_combo.setCurrentIndex(0)
+ self._light_preset_combo.currentTextChanged.connect(self._on_lighting_preset_changed)
preset_row.addWidget(self._light_preset_combo)
light_layout.addLayout(preset_row)
# Wire slider value changes to labels and auto-preview
- self._wire_light_slider(
- self._light_ambient_slider, self._light_ambient_label, 100
- )
- self._wire_light_slider(
- self._light_key_slider, self._light_key_label, 50
- )
- self._wire_light_slider(
- self._light_fill_slider, self._light_fill_label, 50
- )
- self._wire_light_slider(
- self._light_rim_slider, self._light_rim_label, 50
- )
+ self._wire_light_slider(self._light_ambient_slider, self._light_ambient_label, 100)
+ self._wire_light_slider(self._light_key_slider, self._light_key_label, 50)
+ self._wire_light_slider(self._light_fill_slider, self._light_fill_label, 50)
+ self._wire_light_slider(self._light_rim_slider, self._light_rim_label, 50)
layout.addWidget(light_gb)
@@ -459,9 +442,7 @@ class RenderWindow(QMainWindow):
# Enabled checkbox
self._ground_enabled_cb = QCheckBox("Enabled")
self._ground_enabled_cb.setChecked(False)
- self._ground_enabled_cb.toggled.connect(
- lambda: self._toggle_ground_plane_widgets()
- )
+ self._ground_enabled_cb.toggled.connect(lambda: self._toggle_ground_plane_widgets())
ground_layout.addWidget(self._ground_enabled_cb)
# Color button
@@ -493,7 +474,7 @@ class RenderWindow(QMainWindow):
dist_row = QHBoxLayout()
dist_row.addWidget(QLabel("Offset:"))
self._ground_dist_spin = QDoubleSpinBox() # type: ignore[attr-defined]
- self._ground_dist_spin.setRange(0, 500)
+ self._ground_dist_spin.setRange(-500, 500)
self._ground_dist_spin.setValue(0.0)
self._ground_dist_spin.setSuffix(" mm")
self._ground_dist_spin.setFixedWidth(80)
@@ -509,9 +490,7 @@ class RenderWindow(QMainWindow):
)
)
# Wire distance spinbox to auto-preview
- self._ground_dist_spin.valueChanged.connect(
- lambda: self._schedule_auto_preview()
- )
+ self._ground_dist_spin.valueChanged.connect(lambda: self._schedule_auto_preview())
# Initially disable ground widgets since checkbox is unchecked
self._toggle_ground_plane_widgets()
@@ -545,9 +524,7 @@ class RenderWindow(QMainWindow):
def _on_ground_color_pick(self):
"""Open color dialog for ground plane color."""
current = self._ground_color # type: ignore[attr-defined]
- color = QColorDialog.getColor(
- QColor(*[int(c * 255) for c in current]), self
- )
+ color = QColorDialog.getColor(QColor(*[int(c * 255) for c in current]), self)
if color.isValid():
r, g, b = color.red() / 255.0, color.green() / 255.0, color.blue() / 255.0
self._ground_color = (r, g, b) # type: ignore[attr-defined]
@@ -607,13 +584,9 @@ class RenderWindow(QMainWindow):
# No backend available — disable both buttons
self._backend = None
self._render_btn.setEnabled(False)
- self._render_btn.setToolTip(
- "No render backend installed (pip install mitsuba)"
- )
+ self._render_btn.setToolTip("No render backend installed (pip install mitsuba)")
self._preview_btn.setEnabled(False)
- self._preview_btn.setToolTip(
- "No render backend installed (pip install mitsuba)"
- )
+ self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)")
def _prepare_mesh(self):
"""Tessellate the OCC shape to a PLY file for the renderer."""
@@ -631,9 +604,7 @@ class RenderWindow(QMainWindow):
logger.info(f"Prepared mesh: {self._mesh_path}")
except Exception as e:
logger.error(f"Failed to prepare mesh: {e}")
- QMessageBox.warning(
- self, "Render Error", f"Failed to tessellate shape:\n{e}"
- )
+ QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}")
# ── Auto-preview setup ────────────────────────────────────────────
@@ -643,14 +614,18 @@ class RenderWindow(QMainWindow):
self._auto_preview_timer.setSingleShot(True)
self._auto_preview_timer.timeout.connect(self._on_preview)
# Wire material combo to trigger debounced preview
- self._material_combo.currentTextChanged.connect(
- lambda: self._schedule_auto_preview()
- )
+ self._material_combo.currentTextChanged.connect(lambda: self._schedule_auto_preview())
# Wire camera controls to trigger debounced preview
for spin in (
- self._cam_origin_x, self._cam_origin_y, self._cam_origin_z,
- self._cam_target_x, self._cam_target_y, self._cam_target_z,
- self._cam_up_x, self._cam_up_y, self._cam_up_z,
+ self._cam_origin_x,
+ self._cam_origin_y,
+ self._cam_origin_z,
+ self._cam_target_x,
+ self._cam_target_y,
+ self._cam_target_z,
+ self._cam_up_x,
+ self._cam_up_y,
+ self._cam_up_z,
self._cam_fov_spin,
):
spin.valueChanged.connect(lambda: self._schedule_auto_preview())
@@ -747,14 +722,10 @@ class RenderWindow(QMainWindow):
if mode == "preview":
self._status_badge.setText("⚡ Preview — low quality")
- self._status_badge.setStyleSheet(
- "color: #f9e2af; font-size: 11px; padding: 2px;"
- )
+ self._status_badge.setStyleSheet("color: #f9e2af; font-size: 11px; padding: 2px;")
else:
self._status_badge.setText("✓ Full render complete")
- self._status_badge.setStyleSheet(
- "color: #a6e3a1; font-size: 11px; padding: 2px;"
- )
+ self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
def _cancel_active_thread(self):
"""Cancel whichever thread is currently running."""
@@ -967,3 +938,683 @@ class RenderWindow(QMainWindow):
pass
super().closeEvent(event)
+
+
+# ── Render tab content (embeddable in QTabWidget) ──────────────────────
+
+
+class RenderTabContent(QWidget):
+ """Render controls and image display as a QWidget, designed to sit in a tab.
+
+ Mirrors RenderWindow\'s controls but as a plain widget so it can be
+ embedded in MainWindow\'s InputTab (alongside Sketch and Code).
+
+ Call ``set_shape(shape, camera)`` to load a new shape and optionally reset
+ the camera to the current viewport. ``cleanup()`` stops threads and
+ deletes temp files when the tab is hidden.
+ """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._shape = None
+ self._backend = None
+ self._mesh_path: Optional[str] = None
+
+ # Rendering threads & images
+ self._render_thread: Optional[_RenderThread] = None
+ self._preview_thread: Optional[_RenderThread] = None
+ self._last_image: Optional[np.ndarray] = None
+ self._last_preview: Optional[np.ndarray] = None
+ self._camera: Optional[RenderCamera] = None
+ self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5)
+ self._active_mode: Optional[str] = None
+ self._auto_preview_timer: Optional[QTimer] = None
+
+ self._init_ui()
+ self._init_backend()
+ self._setup_auto_preview()
+
+ # ── Public API ─────────────────────────────────────────────────
+
+ def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None:
+ """Load a new OCC TopoDS_Shape for rendering.
+
+ *camera* — if provided, overrides the stored camera. Pass the
+ viewport\'s render camera to match the 3D view framing.
+ """
+ self._shape = shape
+ if camera is not None:
+ self._camera = camera
+ self._last_image = None
+ self._last_preview = None
+ self._image_label.setPixmap(QPixmap())
+ self._image_label.setText("Click Preview or Render to start")
+ self._status_badge.setText("")
+ self._export_btn.setEnabled(False)
+ self._prepare_mesh()
+ self._populate_camera_controls()
+ # Trigger auto-preview when a new shape is loaded
+ self._schedule_auto_preview()
+
+ def get_camera(self) -> Optional[RenderCamera]:
+ """Return the current camera (from UI controls or initial)."""
+ return self._build_camera_from_ui()
+
+ def set_camera(self, camera: RenderCamera) -> None:
+ """Update the render camera from an external source (e.g. 3D viewport).
+
+ Stores the camera and, if a preview is active, cancels it and
+ schedules a new one with the updated framing. For full renders the
+ camera is stored so the next manual render uses the new framing.
+ """
+ if camera is None:
+ return
+ self._camera = camera
+ self._cam_fov_spin.blockSignals(True)
+ try:
+ self._cam_fov_spin.setValue(camera.fov)
+ finally:
+ self._cam_fov_spin.blockSignals(False)
+ # ── If a preview is active, cancel and restart with the new camera ──
+ if self._active_mode == "preview" and self._preview_thread is not None:
+ self._cancel_active_thread()
+ self._schedule_auto_preview()
+ return
+ # For full renders or idle: schedule a preview if auto-preview is on.
+ self._schedule_auto_preview()
+
+ def cleanup(self) -> None:
+ """Stop threads and delete temp files. Call when the tab is hidden/closed."""
+ if self._auto_preview_timer and self._auto_preview_timer.isActive():
+ self._auto_preview_timer.stop()
+ for thread in (self._preview_thread, self._render_thread):
+ if thread and thread.isRunning():
+ thread.cancel()
+ thread.terminate()
+ thread.wait(2000)
+ if self._mesh_path and os.path.exists(self._mesh_path):
+ try:
+ os.unlink(self._mesh_path)
+ except OSError:
+ pass
+ self._mesh_path = None
+
+ # ── UI Setup ───────────────────────────────────────────────────
+
+ def _init_ui(self):
+ main_layout = QHBoxLayout(self)
+ main_layout.setContentsMargins(8, 8, 8, 8)
+ main_layout.setSpacing(8)
+
+ # ── Left sidebar: Render Settings ───────────────────────────
+ self._settings_panel = self._build_settings_panel()
+ main_layout.addWidget(self._settings_panel)
+
+ # ── Right side: Image + Controls ────────────────────────────
+ right = QVBoxLayout()
+ right.setSpacing(6)
+
+ self._image_container = QVBoxLayout()
+ self._image_container.setContentsMargins(0, 0, 0, 0)
+
+ self._image_label = QLabel("Click Preview or Render to start")
+ self._image_label.setAlignment(Qt.AlignCenter)
+ self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ self._image_label.setMinimumSize(400, 300)
+ self._image_label.setStyleSheet("background: #1e1e2e; color: #6c7086; font-size: 14px;")
+ self._image_container.addWidget(self._image_label, stretch=1)
+
+ self._status_badge = QLabel("")
+ self._status_badge.setAlignment(Qt.AlignCenter)
+ self._status_badge.setStyleSheet("color: #a6adc8; font-size: 11px; padding: 2px;")
+ self._image_container.addWidget(self._status_badge)
+
+ right.addLayout(self._image_container, stretch=1)
+
+ # Controls row
+ controls = QHBoxLayout()
+ controls.setSpacing(8)
+
+ controls.addWidget(QLabel("Material:"))
+ self._material_combo = QComboBox()
+ for name in preset_names():
+ self._material_combo.addItem(name)
+ self._material_combo.setCurrentText("Brushed Steel")
+ self._material_combo.setMinimumWidth(140)
+ controls.addWidget(self._material_combo)
+
+ self._auto_preview_cb = QCheckBox("Auto-preview")
+ self._auto_preview_cb.setChecked(True)
+ controls.addWidget(self._auto_preview_cb)
+
+ controls.addWidget(QLabel("Quality:"))
+ self._spp_spin = QSpinBox()
+ self._spp_spin.setRange(16, 4096)
+ self._spp_spin.setValue(256)
+ self._spp_spin.setSingleStep(64)
+ self._spp_spin.setSuffix(" spp")
+ self._spp_spin.setMinimumWidth(100)
+ controls.addWidget(self._spp_spin)
+
+ controls.addWidget(QLabel("Resolution:"))
+ self._res_combo = QComboBox()
+ self._res_combo.addItems(["1280×720", "1920×1080", "2560×1440", "3840×2160"])
+ self._res_combo.setCurrentText("1920×1080")
+ controls.addWidget(self._res_combo)
+
+ controls.addStretch()
+
+ self._preview_btn = QPushButton("⚡ Preview")
+ self._preview_btn.setMinimumWidth(90)
+ self._preview_btn.clicked.connect(self._on_preview)
+ controls.addWidget(self._preview_btn)
+
+ self._render_btn = QPushButton("▶ Render")
+ self._render_btn.setMinimumWidth(100)
+ self._render_btn.clicked.connect(self._on_render)
+ controls.addWidget(self._render_btn)
+
+ self._cancel_btn = QPushButton("⏹ Cancel")
+ self._cancel_btn.setMinimumWidth(80)
+ self._cancel_btn.setEnabled(False)
+ self._cancel_btn.clicked.connect(self._on_cancel)
+ controls.addWidget(self._cancel_btn)
+
+ self._export_btn = QPushButton("💾 Export")
+ self._export_btn.setMinimumWidth(80)
+ self._export_btn.setEnabled(False)
+ self._export_btn.clicked.connect(self._on_export)
+ controls.addWidget(self._export_btn)
+
+ right.addLayout(controls)
+
+ self._progress = QProgressBar()
+ self._progress.setRange(0, 100)
+ self._progress.setValue(0)
+ self._progress.setTextVisible(True)
+ self._progress.setFormat("%p%")
+ right.addWidget(self._progress)
+
+ main_layout.addLayout(right, stretch=1)
+
+ # ── Settings panel builder ─────────────────────────────────────
+
+ def _build_settings_panel(self) -> QWidget:
+ """Build the left sidebar with camera, lighting, and ground plane controls."""
+ panel = QWidget()
+ panel.setFixedWidth(240)
+ layout = QVBoxLayout(panel)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(8)
+
+ # ── Camera Parameters ───────────────────────────────────────
+ camera_gb = QGroupBox("Camera")
+ camera_layout = QVBoxLayout(camera_gb)
+ camera_layout.setSpacing(4)
+
+ # Origin, target, and up are controlled via the 3D viewport.
+ # Only FOV is editable here.
+ fov_row = QHBoxLayout()
+ fov_row.addWidget(QLabel("FOV:"))
+ self._cam_fov_spin = QDoubleSpinBox()
+ self._cam_fov_spin.setRange(1, 179)
+ self._cam_fov_spin.setValue(45.0)
+ self._cam_fov_spin.setSuffix("°")
+ self._cam_fov_spin.setFixedWidth(80)
+ fov_row.addWidget(self._cam_fov_spin)
+ fov_row.addStretch()
+ camera_layout.addLayout(fov_row)
+
+ reset_cam_btn = QPushButton("Reset to Viewport")
+ reset_cam_btn.clicked.connect(self._reset_camera_to_viewport)
+ camera_layout.addWidget(reset_cam_btn)
+
+ layout.addWidget(camera_gb)
+
+ # ── Lighting ────────────────────────────────────────────────
+ light_gb = QGroupBox("Lighting")
+ light_layout = QVBoxLayout(light_gb)
+ light_layout.setSpacing(4)
+
+ ambient_row = QHBoxLayout()
+ ambient_row.addWidget(QLabel("Ambient:"))
+ self._light_ambient_slider = QSlider(Qt.Horizontal)
+ self._light_ambient_slider.setRange(0, 100)
+ self._light_ambient_slider.setValue(30)
+ ambient_row.addWidget(self._light_ambient_slider)
+ self._light_ambient_label = QLabel("0.30")
+ self._light_ambient_label.setFixedWidth(40)
+ ambient_row.addWidget(self._light_ambient_label)
+ light_layout.addLayout(ambient_row)
+
+ key_row = QHBoxLayout()
+ key_row.addWidget(QLabel("Key:"))
+ self._light_key_slider = QSlider(Qt.Horizontal)
+ self._light_key_slider.setRange(0, 200)
+ self._light_key_slider.setValue(70)
+ key_row.addWidget(self._light_key_slider)
+ self._light_key_label = QLabel("3.5")
+ self._light_key_label.setFixedWidth(40)
+ key_row.addWidget(self._light_key_label)
+ light_layout.addLayout(key_row)
+
+ fill_row = QHBoxLayout()
+ fill_row.addWidget(QLabel("Fill:"))
+ self._light_fill_slider = QSlider(Qt.Horizontal)
+ self._light_fill_slider.setRange(0, 200)
+ self._light_fill_slider.setValue(30)
+ fill_row.addWidget(self._light_fill_slider)
+ self._light_fill_label = QLabel("1.5")
+ self._light_fill_label.setFixedWidth(40)
+ fill_row.addWidget(self._light_fill_label)
+ light_layout.addLayout(fill_row)
+
+ rim_row = QHBoxLayout()
+ rim_row.addWidget(QLabel("Rim:"))
+ self._light_rim_slider = QSlider(Qt.Horizontal)
+ self._light_rim_slider.setRange(0, 200)
+ self._light_rim_slider.setValue(24)
+ rim_row.addWidget(self._light_rim_slider)
+ self._light_rim_label = QLabel("1.2")
+ self._light_rim_label.setFixedWidth(40)
+ rim_row.addWidget(self._light_rim_label)
+ light_layout.addLayout(rim_row)
+
+ preset_row = QHBoxLayout()
+ preset_row.addWidget(QLabel("Preset:"))
+ self._light_preset_combo = QComboBox()
+ self._light_preset_combo.addItems(
+ [
+ "Studio (default)",
+ "Soft",
+ "Dramatic",
+ "Bright",
+ ]
+ )
+ self._light_preset_combo.setCurrentIndex(0)
+ self._light_preset_combo.currentTextChanged.connect(self._on_lighting_preset_changed)
+ preset_row.addWidget(self._light_preset_combo)
+ light_layout.addLayout(preset_row)
+
+ self._wire_light_slider(self._light_ambient_slider, self._light_ambient_label, 100)
+ self._wire_light_slider(self._light_key_slider, self._light_key_label, 50)
+ self._wire_light_slider(self._light_fill_slider, self._light_fill_label, 50)
+ self._wire_light_slider(self._light_rim_slider, self._light_rim_label, 50)
+
+ layout.addWidget(light_gb)
+
+ # ── Ground Plane ────────────────────────────────────────────
+ ground_gb = QGroupBox("Ground Plane")
+ ground_layout = QVBoxLayout(ground_gb)
+ ground_layout.setSpacing(4)
+
+ self._ground_enabled_cb = QCheckBox("Enabled")
+ self._ground_enabled_cb.setChecked(False)
+ self._ground_enabled_cb.toggled.connect(lambda: self._toggle_ground_plane_widgets())
+ ground_layout.addWidget(self._ground_enabled_cb)
+
+ self._ground_curved_cb = QCheckBox("Curved Backdrop")
+ self._ground_curved_cb.setChecked(False)
+ self._ground_curved_cb.toggled.connect(lambda: self._schedule_auto_preview())
+ ground_layout.addWidget(self._ground_curved_cb)
+
+ color_row = QHBoxLayout()
+ color_row.addWidget(QLabel("Color:"))
+ self._ground_color_btn = QPushButton()
+ self._ground_color_btn.setFixedSize(40, 24)
+ self._ground_color_btn.setStyleSheet(
+ "background-color: rgb(128, 128, 128); border: 1px solid #555;"
+ )
+ self._ground_color_btn.clicked.connect(self._on_ground_color_pick)
+ color_row.addWidget(self._ground_color_btn)
+ color_row.addStretch()
+ ground_layout.addLayout(color_row)
+
+ rough_row = QHBoxLayout()
+ rough_row.addWidget(QLabel("Roughness:"))
+ self._ground_rough_slider = QSlider(Qt.Horizontal)
+ self._ground_rough_slider.setRange(0, 100)
+ self._ground_rough_slider.setValue(80)
+ rough_row.addWidget(self._ground_rough_slider)
+ self._ground_rough_label = QLabel("0.80")
+ self._ground_rough_label.setFixedWidth(40)
+ rough_row.addWidget(self._ground_rough_label)
+ ground_layout.addLayout(rough_row)
+
+ dist_row = QHBoxLayout()
+ dist_row.addWidget(QLabel("Offset:"))
+ self._ground_dist_spin = QDoubleSpinBox()
+ self._ground_dist_spin.setRange(-500, 500)
+ self._ground_dist_spin.setValue(0.0)
+ self._ground_dist_spin.setSuffix(" mm")
+ self._ground_dist_spin.setFixedWidth(80)
+ dist_row.addWidget(self._ground_dist_spin)
+ dist_row.addStretch()
+ ground_layout.addLayout(dist_row)
+
+ self._ground_rough_slider.valueChanged.connect(
+ lambda v: (
+ self._ground_rough_label.setText(f"{v / 100:.2f}"),
+ self._schedule_auto_preview(),
+ )
+ )
+ self._ground_dist_spin.valueChanged.connect(lambda: self._schedule_auto_preview())
+
+ self._toggle_ground_plane_widgets()
+
+ layout.addWidget(ground_gb)
+ layout.addStretch()
+
+ return panel
+
+ def _wire_light_slider(self, slider, label, divisor):
+ slider.valueChanged.connect(
+ lambda v: (
+ label.setText(f"{v / divisor:.1f}"),
+ self._schedule_auto_preview(),
+ )
+ )
+
+ def _toggle_ground_plane_widgets(self):
+ enabled = self._ground_enabled_cb.isChecked()
+ for w in (
+ self._ground_color_btn,
+ self._ground_rough_slider,
+ self._ground_dist_spin,
+ self._ground_curved_cb,
+ ):
+ w.setEnabled(enabled)
+ if enabled:
+ self._schedule_auto_preview()
+
+ def _on_ground_color_pick(self):
+ current = self._ground_color
+ color = QColorDialog.getColor(QColor(*[int(c * 255) for c in current]), self)
+ if color.isValid():
+ r, g, b = color.red() / 255.0, color.green() / 255.0, color.blue() / 255.0
+ self._ground_color = (r, g, b)
+ self._ground_color_btn.setStyleSheet(
+ f"background-color: rgb({color.red()}, {color.green()}, {color.blue()}); "
+ "border: 1px solid #555;"
+ )
+ self._schedule_auto_preview()
+
+ def _on_lighting_preset_changed(self, preset_name: str):
+ presets = {
+ "Studio (default)": {"ambient": 30, "key": 70, "fill": 30, "rim": 24},
+ "Soft": {"ambient": 50, "key": 40, "fill": 40, "rim": 15},
+ "Dramatic": {"ambient": 10, "key": 120, "fill": 15, "rim": 60},
+ "Bright": {"ambient": 70, "key": 100, "fill": 70, "rim": 40},
+ }
+ p = presets.get(preset_name)
+ if p:
+ self._light_ambient_slider.setValue(p["ambient"])
+ self._light_key_slider.setValue(p["key"])
+ self._light_fill_slider.setValue(p["fill"])
+ self._light_rim_slider.setValue(p["rim"])
+
+ def _reset_camera_to_viewport(self):
+ if self._camera is None:
+ return
+ self._cam_fov_spin.setValue(self._camera.fov)
+ self._schedule_auto_preview()
+
+ def _init_backend(self):
+ if self._backend is not None:
+ return
+ from fluency.rendering.mitsuba_backend import MitsubaBackend
+
+ mitsuba = MitsubaBackend()
+ if mitsuba.is_available():
+ self._backend = mitsuba
+ logger.info(f"Using render backend: {mitsuba.name()}")
+ return
+ self._backend = None
+ self._render_btn.setEnabled(False)
+ self._render_btn.setToolTip("No render backend installed (pip install mitsuba)")
+ self._preview_btn.setEnabled(False)
+ self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)")
+
+ def _prepare_mesh(self):
+ if self._shape is None:
+ return
+ try:
+ self._mesh_path = occ_shape_to_ply(
+ self._shape, linear_deflection=0.1, angular_deflection=0.15
+ )
+ if self._camera is None:
+ mn, mx = occ_shape_bounds(self._shape)
+ self._camera = self._backend.default_camera_from_bounds(mn, mx)
+ logger.info(f"Prepared mesh: {self._mesh_path}")
+ except Exception as e:
+ logger.error(f"Failed to prepare mesh: {e}")
+ QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}")
+
+ def _setup_auto_preview(self):
+ self._auto_preview_timer = QTimer(self)
+ self._auto_preview_timer.setSingleShot(True)
+ self._auto_preview_timer.timeout.connect(self._on_preview)
+ self._material_combo.currentTextChanged.connect(lambda: self._schedule_auto_preview())
+ self._cam_fov_spin.valueChanged.connect(lambda: self._schedule_auto_preview())
+
+ def _populate_camera_controls(self):
+ if self._camera is None:
+ return
+ self._cam_fov_spin.setValue(self._camera.fov)
+
+ def _schedule_auto_preview(self):
+ if not self._auto_preview_cb.isChecked():
+ return
+ if self._active_mode is not None:
+ return
+ if self._backend is None or self._mesh_path is None:
+ return
+ self._auto_preview_timer.start(500)
+
+ def _build_camera_from_ui(self) -> RenderCamera:
+ # Origin, target, and up are controlled via the 3D viewport;
+ # only FOV is editable in the sidebar.
+ _fallback = RenderCamera()
+ return RenderCamera(
+ origin=self._camera.origin if self._camera else _fallback.origin,
+ target=self._camera.target if self._camera else _fallback.target,
+ up=self._camera.up if self._camera else _fallback.up,
+ fov=self._cam_fov_spin.value(),
+ )
+
+ def _build_lighting_config(self) -> LightingConfig:
+ return LightingConfig(
+ ambient_intensity=self._light_ambient_slider.value() / 100.0,
+ key_intensity=self._light_key_slider.value() / 50.0,
+ fill_intensity=self._light_fill_slider.value() / 50.0,
+ rim_intensity=self._light_rim_slider.value() / 50.0,
+ )
+
+ def _build_ground_plane_config(self) -> GroundPlaneConfig:
+ return GroundPlaneConfig(
+ enabled=self._ground_enabled_cb.isChecked(),
+ color=self._ground_color,
+ roughness=self._ground_rough_slider.value() / 100.0,
+ distance_below=self._ground_dist_spin.value(),
+ curved_backdrop=self._ground_curved_cb.isChecked(),
+ )
+
+ def _display_image(self, image: np.ndarray, mode: str) -> None:
+ h, w = image.shape[:2]
+ rgb = (np.clip(image, 0.0, 1.0) * 255).astype(np.uint8)
+ qimg = QImage(rgb.data, w, h, 3 * w, QImage.Format_RGB888)
+ pixmap = QPixmap.fromImage(qimg)
+ scaled = pixmap.scaled(
+ self._image_label.size(),
+ Qt.KeepAspectRatio,
+ Qt.SmoothTransformation,
+ )
+ self._image_label.setPixmap(scaled)
+ if mode == "preview":
+ self._status_badge.setText("⚡ Preview — low quality")
+ self._status_badge.setStyleSheet("color: #f9e2af; font-size: 11px; padding: 2px;")
+ else:
+ self._status_badge.setText("✓ Full render complete")
+ self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
+
+ def _cancel_active_thread(self):
+ if self._active_mode == "preview" and self._preview_thread:
+ self._preview_thread.cancel()
+ self._preview_thread.terminate()
+ self._preview_thread.wait(2000)
+ elif self._active_mode == "render" and self._render_thread:
+ self._render_thread.cancel()
+ self._render_thread.terminate()
+ self._render_thread.wait(2000)
+ self._active_mode = None
+
+ def _set_buttons_rendering(self, mode: str):
+ self._preview_btn.setEnabled(False)
+ self._render_btn.setEnabled(False)
+ self._cancel_btn.setEnabled(True)
+ self._export_btn.setEnabled(False)
+ self._progress.setValue(0)
+ self._active_mode = mode
+ if mode == "preview":
+ self._image_label.setText("Previewing...")
+ else:
+ self._image_label.setText("Rendering...")
+ self._image_label.setPixmap(QPixmap())
+
+ def _set_buttons_idle(self):
+ self._preview_btn.setEnabled(True)
+ self._render_btn.setEnabled(True)
+ self._cancel_btn.setEnabled(False)
+ self._active_mode = None
+
+ @Slot()
+ def _on_preview(self):
+ if self._backend is None or self._mesh_path is None:
+ return
+ self._cancel_active_thread()
+ material = get_preset(self._material_combo.currentText())
+ res_text = self._res_combo.currentText()
+ w, h = [int(x) for x in res_text.split("×")]
+ settings = RenderSettings(
+ width=w,
+ height=h,
+ spp=self._spp_spin.value(),
+ lighting=self._build_lighting_config(),
+ ground_plane=self._build_ground_plane_config(),
+ )
+ camera = self._build_camera_from_ui()
+ self._set_buttons_rendering("preview")
+ self._preview_thread = _RenderThread(
+ self._backend,
+ self._mesh_path,
+ material,
+ camera,
+ settings,
+ parent=self,
+ is_preview=True,
+ )
+ self._preview_thread.finished.connect(self._on_preview_done)
+ self._preview_thread.error.connect(self._on_render_error)
+ self._preview_thread.start()
+
+ @Slot()
+ def _on_render(self):
+ if self._backend is None:
+ QMessageBox.warning(
+ self,
+ "No Backend",
+ "No render backend available.\nInstall with: pip install mitsuba",
+ )
+ return
+ if self._mesh_path is None:
+ QMessageBox.warning(self, "No Shape", "No shape to render.")
+ return
+ self._cancel_active_thread()
+ material = get_preset(self._material_combo.currentText())
+ spp = self._spp_spin.value()
+ res_text = self._res_combo.currentText()
+ w, h = [int(x) for x in res_text.split("×")]
+ settings = RenderSettings(
+ width=w,
+ height=h,
+ spp=spp,
+ lighting=self._build_lighting_config(),
+ ground_plane=self._build_ground_plane_config(),
+ )
+ camera = self._build_camera_from_ui()
+ self._set_buttons_rendering("render")
+ self._render_thread = _RenderThread(
+ self._backend,
+ self._mesh_path,
+ material,
+ camera,
+ settings,
+ parent=self,
+ is_preview=False,
+ )
+ self._render_thread.finished.connect(self._on_render_done)
+ self._render_thread.error.connect(self._on_render_error)
+ self._render_thread.progress.connect(self._on_render_progress)
+ self._render_thread.start()
+
+ @Slot()
+ def _on_cancel(self):
+ self._cancel_active_thread()
+ self._set_buttons_idle()
+ self._image_label.setText("Cancelled")
+ self._status_badge.setText("")
+ self._progress.setValue(0)
+
+ @Slot(np.ndarray)
+ def _on_preview_done(self, image: np.ndarray):
+ self._last_preview = image
+ self._set_buttons_idle()
+ self._export_btn.setEnabled(False)
+ self._progress.setValue(100)
+ self._display_image(image, "preview")
+
+ @Slot(np.ndarray)
+ def _on_render_done(self, image: np.ndarray):
+ self._last_image = image
+ self._set_buttons_idle()
+ self._export_btn.setEnabled(True)
+ self._progress.setValue(100)
+ self._display_image(image, "render")
+
+ @Slot(str)
+ def _on_render_error(self, msg: str):
+ self._set_buttons_idle()
+ self._export_btn.setEnabled(False)
+ self._image_label.setText(f"Error: {msg}")
+ self._status_badge.setText("")
+ self._progress.setValue(0)
+ QMessageBox.critical(self, "Render Error", msg)
+
+ @Slot(float)
+ def _on_render_progress(self, fraction: float):
+ self._progress.setValue(int(fraction * 100))
+
+ @Slot()
+ def _on_export(self):
+ if self._last_image is None:
+ return
+ path, filt = QFileDialog.getSaveFileName(
+ self,
+ "Export Render",
+ "",
+ "PNG Image (*.png);;EXR Image (*.exr);;JPEG Image (*.jpg)",
+ )
+ if not path:
+ return
+ try:
+ self._backend.export_image(self._last_image, path)
+ QMessageBox.information(self, "Exported", f"Saved to:\n{path}")
+ except Exception as e:
+ QMessageBox.critical(self, "Export Error", str(e))
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ current_image = self._last_image or self._last_preview
+ if current_image is not None:
+ mode = "render" if self._last_image is not None else "preview"
+ self._display_image(current_image, mode)
diff --git a/src/fluency/ui/viewer_widget.py b/src/fluency/ui/viewer_widget.py
index 6fda07a..14ddf3d 100644
--- a/src/fluency/ui/viewer_widget.py
+++ b/src/fluency/ui/viewer_widget.py
@@ -3,15 +3,14 @@
from __future__ import annotations
import logging
-import sys
from typing import Any, Dict, List, Optional, Tuple
-from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
-from PySide6.QtGui import QCursor, QFont, QPainter, QPen, QColor, QBrush, QPolygonF
+from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QWidget
logger = logging.getLogger(__name__)
+
class Viewer3DWidget(QWidget):
"""3D viewer widget using OCC's native AIS display."""
@@ -38,6 +37,10 @@ class Viewer3DWidget(QWidget):
# Emitted when a drag move finishes.
assemblyMoveFinished = Signal(str)
+ # Emitted whenever the camera changes (orbit, pan, zoom).
+ # Payload: (eye, at, up) — each is a tuple of 3 floats.
+ cameraChanged = Signal(tuple, tuple, tuple)
+
def __init__(self, parent=None):
super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
@@ -90,10 +93,12 @@ class Viewer3DWidget(QWidget):
if self._renderer is not None:
return
import sys as _sys
+
_sys.stdout.flush()
logger.info("Renderer: starting import...")
from fluency.rendering.occ_renderer import OCCRenderer
from fluency.rendering.pygfx_renderer import PygfxRenderer
+
logger.info("Renderer: imports done, creating OCCRenderer...")
occ = OCCRenderer()
logger.info("Renderer: calling occ.initialize...")
@@ -142,16 +147,18 @@ class Viewer3DWidget(QWidget):
"""
self._ensure_initialized()
from fluency.rendering.occ_renderer import OCCRenderer
+
if isinstance(self._renderer, OCCRenderer):
oid = self._renderer.add_shape(shape, color, name)
self._renderer.render()
return oid
# Fallback: tessellate and use the mesh pipeline.
from fluency.geometry_occ.kernel import OCGeometryKernel
+
k = OCGeometryKernel()
- from fluency.geometry_occ.sketch import OCCSketch
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
from fluency.geometry_occ.kernel import OCCGeometryObject
+
obj = OCCGeometryObject(shape)
verts, faces = k.get_mesh(obj)
oid = self._renderer.add_mesh(verts, faces, color, name)
@@ -159,7 +166,9 @@ class Viewer3DWidget(QWidget):
try:
e_verts, e_edges = k.get_edges(obj)
if len(e_verts) > 0:
- self._renderer.add_wireframe(e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges")
+ self._renderer.add_wireframe(
+ e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges"
+ )
except Exception:
pass
self._renderer.render()
@@ -189,7 +198,9 @@ class Viewer3DWidget(QWidget):
def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
self._ensure_initialized()
- wid = self._renderer.add_wireframe(vertices, edges, color or (0.9, 0.9, 0.9), line_width, name)
+ wid = self._renderer.add_wireframe(
+ vertices, edges, color or (0.9, 0.9, 0.9), line_width, name
+ )
self._renderer.render()
return wid
@@ -326,7 +337,7 @@ class Viewer3DWidget(QWidget):
return
# If connector mode was just exited (gizmo persists after pick),
# clear any lingering gizmo on first mouse move.
- gizmo_objs = getattr(self._renderer, '_gizmo_objects', None)
+ gizmo_objs = getattr(self._renderer, "_gizmo_objects", None)
if self._connector_snap_id is not None or (gizmo_objs and len(gizmo_objs) > 0):
self._clear_connector_snap()
# In face-pick mode, keep dynamic highlighting.
@@ -359,11 +370,31 @@ class Viewer3DWidget(QWidget):
return
self._renderer.handle_mouse_release(event)
super().mouseReleaseEvent(event)
+ # Camera may have changed during orbit/pan — emit signal.
+ try:
+ eye, at_, up = self.get_camera_position()
+ self.cameraChanged.emit(
+ tuple(float(v) for v in eye),
+ tuple(float(v) for v in at_),
+ tuple(float(v) for v in up),
+ )
+ except Exception:
+ pass
def wheelEvent(self, event):
self._ensure_initialized()
self._renderer.handle_wheel(event)
super().wheelEvent(event)
+ # Zoom changed the camera — emit signal.
+ try:
+ eye, at_, up = self.get_camera_position()
+ self.cameraChanged.emit(
+ tuple(float(v) for v in eye),
+ tuple(float(v) for v in at_),
+ tuple(float(v) for v in up),
+ )
+ except Exception:
+ pass
def resizeEvent(self, event):
super().resizeEvent(event)
@@ -389,6 +420,7 @@ class Viewer3DWidget(QWidget):
if hasattr(self._renderer, "get_camera_position"):
return self._renderer.get_camera_position()
import numpy as np
+
return (
np.zeros(3, dtype=float),
np.zeros(3, dtype=float),
@@ -498,6 +530,7 @@ class Viewer3DWidget(QWidget):
on fast mouse moves.
"""
import time
+
now = time.monotonic()
if now - self._connector_last_hover_time < self._connector_hover_interval:
return # throttled — skip this mouse move
@@ -569,10 +602,10 @@ class Viewer3DWidget(QWidget):
fn = getattr(self._renderer, "highlight_snap", None)
if fn is not None:
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)
- "edge": (0.0, 1.0, 0.4), # green
- "vertex": (1.0, 1.0, 0.0), # yellow
+ "edge": (0.0, 1.0, 0.4), # green
+ "vertex": (1.0, 1.0, 0.0), # yellow
}
c = colors.get(entity_type, (1.0, 0.6, 0.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.
import numpy as np
- from OCP.V3d import V3d_TypeOfOrientation
+
try:
# Get camera direction and up from the OCC view.
camera = self._renderer._view.Camera()
@@ -762,16 +795,11 @@ class Viewer3DWidget(QWidget):
dy_world = 0.0
else:
# Normal drag: move in view plane.
- dx_world = float(cam_right[0] * dx * world_per_pixel +
- cam_up[0] * dy * world_per_pixel)
- dy_world = float(cam_right[1] * dx * world_per_pixel +
- cam_up[1] * dy * world_per_pixel)
- dz_world = float(cam_right[2] * dx * world_per_pixel +
- cam_up[2] * dy * world_per_pixel)
+ dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel)
+ dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel)
+ dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel)
- self.assemblyComponentDragged.emit(
- self._move_owner_obj_id, dx_world, dy_world, dz_world
- )
+ self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
def _handle_assembly_move_release(self, event) -> None:
"""Finish the drag, emit final position."""
@@ -888,5 +916,3 @@ class Viewer3DWidget(QWidget):
self._renderer.render()
return
super().keyPressEvent(event)
-
-