Improved render previews
This commit is contained in:
+417
-90
@@ -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
|
||||
(1∶1 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."""
|
||||
|
||||
Reference in New Issue
Block a user