Improved render previews

This commit is contained in:
bklronin
2026-07-18 23:06:42 +02:00
parent 742d06d242
commit d6e829c23d
9 changed files with 2195 additions and 1553 deletions
+417 -90
View File
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
QMenu,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
@@ -56,6 +57,221 @@ from gui_ui import Ui_fluencyCAD # auto-generated Qt form (project root on sys.
logger = logging.getLogger(__name__)
def _make_body_thumbnail(
body,
kernel,
size: QSize = QSize(64, 48),
):
"""Render a small isometric thumbnail of a body using Pillow.
Returns a QPixmap or None on failure.
"""
try:
import numpy as np
from PIL import Image, ImageDraw
from PySide6.QtGui import QImage, QPixmap
verts, faces = body.get_mesh(kernel)
if verts is None or len(verts) == 0:
return None
verts = np.asarray(verts, dtype=np.float64)
faces = np.asarray(faces, dtype=np.int32)
mins = verts.min(axis=0)
maxs = verts.max(axis=0)
center = (mins + maxs) / 2
extent = maxs - mins
max_dim = max(extent)
if max_dim < 1e-10:
return None
# Normalize vertices to [-1, 1] range centered at origin
v = (verts - center) / (max_dim * 0.7)
# Simple isometric projection (rotation + orthographic)
angle_y = np.radians(-45)
angle_x = np.radians(25)
cos_y, sin_y = np.cos(angle_y), np.sin(angle_y)
cos_x, sin_x = np.cos(angle_x), np.sin(angle_x)
# Rotate Y
x1 = v[:, 0] * cos_y - v[:, 2] * sin_y
z1 = v[:, 0] * sin_y + v[:, 2] * cos_y
y1 = v[:, 1]
# Rotate X
y2 = y1 * cos_x - z1 * sin_x
z2 = y1 * sin_x + z1 * cos_x
x2 = x1
# Project to 2D (orthographic)
w_px, h_px = size.width(), size.height()
# Compute 2D bounding box of projected vertices for tight framing
all_px = x2
all_py = -y2
px_min, px_max = all_px.min(), all_px.max()
py_min, py_max = all_py.min(), all_py.max()
span_x = px_max - px_min
span_y = py_max - py_min
if span_x < 1e-10 or span_y < 1e-10:
return None
# Scale to fill ~90% of the image
margin = 0.10
avail_w = w_px * (1.0 - margin)
avail_h = h_px * (1.0 - margin)
scale = min(avail_w / span_x, avail_h / span_y)
center_x = (px_min + px_max) / 2
center_y = (py_min + py_max) / 2
px = (all_px * scale + w_px / 2 - center_x * scale).astype(np.float64)
py = (all_py * scale + h_px / 2 - center_y * scale).astype(np.float64)
depth = z2 # for painter's algorithm
# Dark grey background
img = Image.new("RGBA", (w_px, h_px), (55, 55, 60, 255))
draw = ImageDraw.Draw(img)
# Compute face normals for backface culling & shading
v0 = np.stack([px[faces[:, 0]], py[faces[:, 0]], depth[faces[:, 0]]], axis=1)
v1 = np.stack([px[faces[:, 1]], py[faces[:, 1]], depth[faces[:, 1]]], axis=1)
v2 = np.stack([px[faces[:, 2]], py[faces[:, 2]], depth[faces[:, 2]]], axis=1)
# 2D cross product for winding
cross = (v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) - (v1[:, 1] - v0[:, 1]) * (
v2[:, 0] - v0[:, 0]
)
# Average depth per face
avg_depth = (v0[:, 2] + v1[:, 2] + v2[:, 2]) / 3.0
# Sort faces by depth (painter's algorithm: draw far faces first)
order = np.argsort(-avg_depth)
# Ceramic white body with shading
base_r, base_g, base_b = 220, 218, 215
for i in order:
# Backface culling
if cross[i] <= 0:
continue
pts = [
(float(px[faces[i, 0]]), float(py[faces[i, 0]])),
(float(px[faces[i, 1]]), float(py[faces[i, 1]])),
(float(px[faces[i, 2]]), float(py[faces[i, 2]])),
]
# Shading: stronger contrast for depth perception
brightness = 0.5 + 0.5 * max(0.0, min(1.0, (avg_depth[i] + 1) / 2))
r = int(base_r * brightness)
g = int(base_g * brightness)
b = int(base_b * brightness)
draw.polygon(pts, fill=(r, g, b, 255))
# Convert PIL image to QPixmap
data = img.tobytes("raw", "RGBA")
qimg = QImage(data, w_px, h_px, w_px * 4, QImage.Format_RGBA8888)
pixmap = QPixmap.fromImage(qimg.copy())
return pixmap
except Exception as e:
logger.debug(f"Thumbnail generation failed: {e}")
return None
# ── Button sizing & styling constants ────────────────────────────────
_BTN_MIN = 40 # minimum button dimension (px)
_BTN_MAX = 160 # maximum button dimension (px)
_BTN_PAD = 4 # padding between icon and button edge
_BTN_UNSELECTED_STYLE = (
"QPushButton {"
" background-color: #3a3a3e;"
" border: 2px solid #555560;"
" border-radius: 6px;"
"}"
"QPushButton:hover {"
" background-color: #4a4a50;"
"}"
"QPushButton:checked {"
" background-color: #2d5f8a;"
" border: 2px solid #4da6ff;"
"}"
)
def _set_button_style(btn: QPushButton) -> None:
"""Apply the standard button style sheet."""
btn.setStyleSheet(_BTN_UNSELECTED_STYLE)
def _resize_row_buttons(scroll_area: QScrollArea, buttons: list[QPushButton]) -> None:
"""Resize every button in *buttons* to fill the scroll area height.
Computes a square size from the parent viewport height so buttons
always use all available vertical space. Width follows height
(11 aspect) clamped to [_BTN_MIN, _BTN_MAX].
"""
vh = scroll_area.viewport().height()
if vh < 20 or not buttons:
return
size = max(_BTN_MIN, min(_BTN_MAX, vh - 8)) # 8 px margin
for btn in buttons:
btn.setFixedSize(QSize(size, size))
icon_size = QSize(size - _BTN_PAD, size - _BTN_PAD)
if btn.icon().isNull():
continue
btn.setIconSize(icon_size)
def _scroll_to_button(btn: QPushButton, scroll_area: QScrollArea) -> None:
"""Ensure *btn* is visible inside a horizontal scroll area."""
ha = scroll_area.horizontalScrollBar()
viewport_w = scroll_area.viewport().width()
parent = btn.parent()
if parent:
x = btn.pos().x()
right = x + btn.width()
scroll_left = ha.value()
scroll_right = scroll_left + viewport_w
if x < scroll_left:
ha.setValue(x)
elif right > scroll_right:
ha.setValue(right - viewport_w)
def _create_component_button(
num: int,
name: str,
body,
kernel,
group: QButtonGroup,
layout: QHBoxLayout,
click_handler,
scroll_area: QScrollArea,
) -> QPushButton:
"""Create a component button with thumbnail."""
btn = QPushButton(str(num))
btn.setCheckable(True)
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(name)
btn.clicked.connect(click_handler)
_set_button_style(btn)
pixmap = _make_body_thumbnail(body, kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
btn.setText("")
group.addButton(btn)
layout.addWidget(btn)
return btn
def _project_body_to_workplane(
body_shape: Any,
workplane: Tuple[
@@ -388,32 +604,51 @@ class MainWindow(QMainWindow):
self._ui.InputTab.addTab(self._render_tab, "Render")
# Component buttons (dynamically generated per component, not in UI).
# Wrapped in a QScrollArea so many components can scroll horizontally.
self._component_box = QWidget()
self._component_box_layout = QHBoxLayout(self._component_box)
self._component_box_layout.setAlignment(Qt.AlignLeft)
self._component_box_layout.setContentsMargins(2, 2, 2, 2)
self._component_group = QButtonGroup(self)
self._component_group.setExclusive(True)
self._component_scroll = QScrollArea()
self._component_scroll.setWidget(self._component_box)
self._component_scroll.setWidgetResizable(True)
self._component_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self._component_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._component_scroll.setFrameShape(QFrame.NoFrame)
self._component_scroll.setMaximumHeight(95)
# Add to the Components group box from the UI.
compo_layout = self._ui.compo_box.layout()
if compo_layout is None:
compo_layout = QHBoxLayout(self._ui.compo_box)
compo_layout.setContentsMargins(0, 0, 0, 0)
compo_layout.addWidget(self._component_box)
compo_layout.addStretch()
compo_layout.addWidget(self._component_scroll)
# ── Assembly box (dynamic buttons like component box) ──
self._assembly_box = QWidget()
self._assembly_box_layout = QHBoxLayout(self._assembly_box)
self._assembly_box_layout.setAlignment(Qt.AlignLeft)
self._assembly_box_layout.setContentsMargins(2, 2, 2, 2)
self._assembly_component_group = QButtonGroup(self)
self._assembly_component_group.setExclusive(True)
self._assembly_scroll = QScrollArea()
self._assembly_scroll.setWidget(self._assembly_box)
self._assembly_scroll.setWidgetResizable(True)
self._assembly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self._assembly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._assembly_scroll.setFrameShape(QFrame.NoFrame)
self._assembly_scroll.setMaximumHeight(95)
# Add to the Assembly group box from the UI.
asm_layout = self._ui.assembly_box.layout()
if asm_layout is None:
asm_layout = QHBoxLayout(self._ui.assembly_box)
asm_layout.setContentsMargins(0, 0, 0, 0)
asm_layout.addWidget(self._assembly_box)
asm_layout.addStretch()
asm_layout.addWidget(self._assembly_scroll)
# ── Assembly Move button (programmatic, in assembly_tools) ──
self._btn_asm_move = QPushButton("Pos")
@@ -425,7 +660,7 @@ class MainWindow(QMainWindow):
)
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)
asm_tools_layout.addWidget(self._btn_asm_move)
# Panel-focus mode (equal | sketch | viewer).
self._panel_focus: str = "equal"
@@ -755,6 +990,24 @@ class MainWindow(QMainWindow):
return i
return 0
def _update_component_thumbnail(self, component_index: int) -> None:
"""Update the thumbnail icon on a component button."""
if not (0 <= component_index < len(self._component_buttons)):
return
comp_ids = list(self._project.components.keys())
if component_index >= len(comp_ids):
return
comp = self._project.components[comp_ids[component_index]]
first_body = next(iter(comp.bodies.values()), None)
if not first_body or not first_body.geometry:
return
btn = self._component_buttons[component_index]
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
btn.setText("")
def _new_component(self):
logger.info("=== NEW COMPONENT ===")
comp = self._project.add_component()
@@ -762,11 +1015,14 @@ class MainWindow(QMainWindow):
self._mark_dirty()
logger.info(f"Created component: {comp.name}")
btn = QPushButton(str(len(self._project.components)))
btn_num = len(self._project.components)
btn = QPushButton(str(btn_num))
btn.setCheckable(True)
btn.setFixedSize(QSize(40, 40))
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(comp.name)
btn.clicked.connect(self._on_component_button_clicked)
btn.setChecked(True)
_set_button_style(btn)
for b in self._component_buttons:
b.setChecked(False)
@@ -774,6 +1030,8 @@ class MainWindow(QMainWindow):
self._component_buttons.append(btn)
self._component_group.addButton(btn)
self._component_box_layout.addWidget(btn)
# Scroll to the new button.
_scroll_to_button(btn, self._component_scroll)
self._refresh_lists()
logger.info(f"Created component: {comp.name}")
@@ -804,6 +1062,9 @@ class MainWindow(QMainWindow):
self._assembly_view_active = False
self._refresh_lists()
self._redraw_bodies()
# Scroll to the selected button.
if 0 <= idx < len(self._component_buttons):
_scroll_to_button(self._component_buttons[idx], self._component_scroll)
def _refresh_lists(self):
self._sketch_list.clear()
@@ -846,6 +1107,7 @@ class MainWindow(QMainWindow):
self._propagate_to_assembly()
self._recalculate_connectors()
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
def _update_bodies_from_sketch(self):
"""Re-extrude bodies whose source sketch has been edited.
@@ -1121,11 +1383,22 @@ class MainWindow(QMainWindow):
label = f"{instance_num}"
btn = QPushButton(label)
btn.setCheckable(True)
btn.setFixedSize(QSize(40, 40))
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(f"{ac.name} (instance {list(assembly.components.keys()).index(ac.id) + 1})")
# Store the assembly component id in the button.
btn._assembly_component_id = ac.id
btn.clicked.connect(self._on_assembly_component_clicked)
_set_button_style(btn)
# Thumbnail from the component's first body.
src_comp = self._current_component
if src_comp:
first_body = next(iter(src_comp.bodies.values()), None)
if first_body and first_body.geometry:
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
# Uncheck all other assembly buttons, check this one.
for b in self._assembly_component_buttons:
@@ -1213,6 +1486,12 @@ class MainWindow(QMainWindow):
self._show_assembly_in_viewer(fit=True)
# Scroll to the selected button.
for btn in self._assembly_component_buttons:
if getattr(btn, "_assembly_component_id", None) == active_id:
_scroll_to_button(btn, self._assembly_scroll)
break
def _apply_transform(self, shape: Any, position, rotation) -> Any:
"""Apply a position translation and rotation matrix to a shape.
@@ -3419,6 +3698,10 @@ class MainWindow(QMainWindow):
logger.warning("No sketch entity found")
QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
return
if sketch is None:
sketch = Sketch()
self._current_sketch = sketch
self._current_component.add_sketch(sketch)
sketch.occ_sketch = sketch_entity
# Resolve the profile geometry *before* opening the dialog so the
@@ -3526,6 +3809,7 @@ class MainWindow(QMainWindow):
body_name = body.name
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera()
logger.info(f"Extruded: {body_name}")
logger.info("=== EXTRUDE COMPLETE ===")
@@ -3546,6 +3830,10 @@ class MainWindow(QMainWindow):
if not sketch_entity:
QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
return
if sketch is None:
sketch = Sketch()
self._current_sketch = sketch
self._current_component.add_sketch(sketch)
sketch.occ_sketch = sketch_entity
dialog = RevolveDialog(self)
@@ -3582,6 +3870,7 @@ class MainWindow(QMainWindow):
logger.info(f"Render object: {body.render_object}")
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera()
logger.info(f"Revolved: {body.name}")
@@ -3656,6 +3945,7 @@ class MainWindow(QMainWindow):
logger.info(f"Render object: {new_body.render_object}")
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera()
logger.info(f"Cut complete: {new_body.name}")
@@ -3696,6 +3986,7 @@ class MainWindow(QMainWindow):
logger.info(f"Render object: {new_body.render_object}")
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera()
logger.info(f"Union complete: {new_body.name}")
@@ -4063,15 +4354,30 @@ class MainWindow(QMainWindow):
self._viewer_3d.clear_scene()
self._refresh_lists()
# Rebuild component buttons (one per component, numbered).
# Rebuild component buttons (one per component, with thumbnails).
for idx, comp in enumerate(self._project.components.values(), start=1):
btn = QPushButton(str(idx))
btn.setCheckable(True)
btn.setFixedSize(QSize(40, 40))
btn.clicked.connect(self._on_component_button_clicked)
first_body = next(iter(comp.bodies.values()), None)
if first_body and first_body.geometry:
btn = _create_component_button(
idx,
comp.name,
first_body,
self._kernel,
self._component_group,
self._component_box_layout,
self._on_component_button_clicked,
self._component_scroll,
)
else:
btn = QPushButton(str(idx))
btn.setCheckable(True)
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(comp.name)
btn.clicked.connect(self._on_component_button_clicked)
_set_button_style(btn)
self._component_group.addButton(btn)
self._component_box_layout.addWidget(btn)
self._component_buttons.append(btn)
self._component_group.addButton(btn)
self._component_box_layout.addWidget(btn)
# Pick which component to activate: explicit saved selection,
# falling back to the project's active_component, then the first.
@@ -4097,10 +4403,22 @@ class MainWindow(QMainWindow):
instance_num = len(self._assembly_component_buttons) + 1
btn = QPushButton(str(instance_num))
btn.setCheckable(True)
btn.setFixedSize(QSize(40, 40))
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(f"{ac.name} (instance {instance_num})")
btn._assembly_component_id = ac.id
btn.clicked.connect(self._on_assembly_component_clicked)
_set_button_style(btn)
# Thumbnail from the source component's first body.
src_comp = self._project.components.get(ac.component_id)
if src_comp:
first_body = next(iter(src_comp.bodies.values()), None)
if first_body and first_body.geometry:
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
self._assembly_component_buttons.append(btn)
self._assembly_component_group.addButton(btn)
self._assembly_box_layout.addWidget(btn)
@@ -4166,32 +4484,59 @@ class MainWindow(QMainWindow):
filepath, _ = QFileDialog.getOpenFileName(
self, "Import File", "", "STEP Files (*.step *.stp);;IGES Files (*.iges *.igs)"
)
if filepath:
try:
if filepath.lower().endswith((".step", ".stp")):
geometry = self._kernel.import_step(filepath)
else:
geometry = self._kernel.import_iges(filepath)
if not filepath:
return
if not self._current_component:
self._current_component = self._project.add_component()
try:
if filepath.lower().endswith((".step", ".stp")):
parts = self._kernel.import_step_components(filepath)
else:
geometry = self._kernel.import_iges(filepath)
parts = [("Imported", geometry)]
body = self._current_component.add_body(
Body(name="Imported", geometry=geometry, source_operation="import")
)
self._mark_dirty()
num_before = len(self._project.components)
for name, geometry in parts:
comp = self._project.add_component(Component(name=name))
self._current_component = comp
body = comp.add_body(Body(name=name, geometry=geometry, source_operation="import"))
vertices, faces = body.get_mesh(self._kernel)
body.render_object = self._viewer_3d.add_mesh(
vertices, faces, body.color, body.name
)
self._refresh_lists()
self._viewer_3d.fit_camera()
logger.info(f"Imported: {filepath}")
# Add a numbered button to the component bar
btn_num = len(self._project.components)
btn = _create_component_button(
btn_num,
name,
body,
self._kernel,
self._component_group,
self._component_box_layout,
self._on_component_button_clicked,
self._component_scroll,
)
self._component_buttons.append(btn)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to import: {e}")
# Select the first newly imported component
if self._component_buttons and num_before < len(self._component_buttons):
for b in self._component_buttons:
b.setChecked(False)
self._component_buttons[num_before].setChecked(True)
self._current_component = self._project.components[
list(self._project.components.keys())[num_before]
]
self._refresh_lists()
self._viewer_3d.fit_camera()
self._mark_dirty()
logger.info(f"Imported {len(parts)} part(s) from {filepath}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to import: {e}")
def _export_step(self):
if not self._selected_body:
@@ -4241,32 +4586,21 @@ class MainWindow(QMainWindow):
def _open_render_window(self):
"""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:
try:
shape = self._kernel._get_shape(self._selected_body.geometry)
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
# Collect all visible bodies across all components
assembly_parts = [] # list of (TopoDS_Shape, Optional[str])
single_shape = None
# 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
for comp in self._project.components.values():
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
if shape is None:
if not assembly_parts:
QMessageBox.information(
self,
"Render",
@@ -4274,16 +4608,12 @@ class MainWindow(QMainWindow):
)
return
# Capture the current 3D viewport camera so the render matches what the user sees.
# get_render_camera() handles both perspective and orthographic modes — in
# orthographic mode it translates view.Scale() (mouse-wheel zoom) into a
# proper perspective camera distance, so the render framing matches the viewport.
# Capture the current 3D viewport camera
try:
renderer = self._viewer_3d.get_renderer()
if hasattr(renderer, "get_render_camera"):
viewport_camera = renderer.get_render_camera()
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()
@@ -4297,38 +4627,31 @@ class MainWindow(QMainWindow):
except Exception:
viewport_camera = None
# Populate the render tab and switch to it.
self._render_tab.set_shape(shape, camera=viewport_camera)
# Single body → use set_shape; multiple → use set_assembly
if len(assembly_parts) == 1:
self._render_tab.set_shape(assembly_parts[0][0], camera=viewport_camera)
else:
self._render_tab.set_assembly(assembly_parts, 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:
# Collect all visible bodies across all components
assembly_parts = []
for comp in self._project.components.values():
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
if not assembly_parts:
return
# Capture the viewport camera so the render matches what the user sees.
# Capture the viewport camera
try:
renderer = self._viewer_3d.get_renderer()
if hasattr(renderer, "get_render_camera"):
@@ -4346,7 +4669,11 @@ class MainWindow(QMainWindow):
)
except Exception:
viewport_camera = None
self._render_tab.set_shape(shape, camera=viewport_camera)
if len(assembly_parts) == 1:
self._render_tab.set_shape(assembly_parts[0][0], camera=viewport_camera)
else:
self._render_tab.set_assembly(assembly_parts, camera=viewport_camera)
def _on_tab_changed(self, index: int) -> None:
"""When the user switches to the Render tab, auto-load the selected body."""