- added "measurement lines"

This commit is contained in:
bklronin
2026-07-26 21:39:47 +02:00
parent 9f1c29d319
commit 0daa6152ee
12 changed files with 3110 additions and 1675 deletions
+410 -56
View File
@@ -183,6 +183,157 @@ def _make_body_thumbnail(
return None
def _make_component_thumbnail(
component,
kernel,
size: QSize = QSize(96, 96),
):
"""Render a small isometric thumbnail of all bodies in a component.
Combines the meshes of all visible bodies and renders them together.
Returns a QPixmap or None on failure.
"""
try:
import numpy as np
from PIL import Image, ImageDraw
from PySide6.QtGui import QImage, QPixmap
# Collect meshes from all visible bodies with geometry
all_verts = []
all_faces = []
vertex_offset = 0
for body in component.bodies.values():
if not body.visible or not body.geometry:
continue
verts, faces = body.get_mesh(kernel)
if verts is None or len(verts) == 0:
continue
verts = np.asarray(verts, dtype=np.float64)
faces = np.asarray(faces, dtype=np.int32)
# Apply body transform
if hasattr(body, "position") and body.position is not None:
if hasattr(body, "rotation") and body.rotation is not None:
verts = verts @ body.rotation.T
verts = verts + body.position
all_verts.append(verts)
all_faces.append(faces + vertex_offset)
vertex_offset += len(verts)
if not all_verts:
return None
verts = np.concatenate(all_verts, axis=0)
faces = np.concatenate(all_faces, axis=0)
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"Component thumbnail generation failed: {e}")
return None
# ── Button sizing & styling constants ────────────────────────────────
_BTN_MIN = 40 # minimum button dimension (px)
_BTN_MAX = 160 # maximum button dimension (px)
@@ -246,7 +397,7 @@ def _scroll_to_button(btn: QPushButton, scroll_area: QScrollArea) -> None:
def _create_component_button(
num: int,
name: str,
body,
component,
kernel,
group: QButtonGroup,
layout: QHBoxLayout,
@@ -261,11 +412,14 @@ def _create_component_button(
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("")
# Render thumbnail from all bodies in the component
has_geometry = any(b.visible and b.geometry for b in component.bodies.values())
if has_geometry:
pixmap = _make_component_thumbnail(component, 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)
@@ -727,7 +881,7 @@ class MainWindow(QMainWindow):
self._btn_del_sketch = ui.pb_del_sketch
# ── Body tools ──
self._btn_update_body = ui.pb_update_body
self._btn_edit_sketch_3 = ui.pb_edt_sktch_3
self._btn_body_hide = ui.pb_body_hide
self._btn_del_body = ui.pb_del_body
# ── Component tools ──
self._btn_new_compo = ui.pb_new_compo
@@ -891,7 +1045,10 @@ class MainWindow(QMainWindow):
self._btn_move.clicked.connect(self._translate_body)
self._btn_array.clicked.connect(self._pattern_array)
self._btn_offset.clicked.connect(self._offset_sketch)
self._btn_edit_sketch_3.clicked.connect(self._edit_sketch)
# Per-body hide/show toggle: the user clicks pb_body_hide next
# to a body name in the right-hand list. We update the body's
# ``visible`` flag and ask the viewer to show/hide the mesh.
self._btn_body_hide.clicked.connect(self._on_body_hide_button_clicked)
# Snap toggle
self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c))
@@ -998,11 +1155,12 @@ class MainWindow(QMainWindow):
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:
# Check if component has any bodies with geometry
has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
if not has_geometry:
return
btn = self._component_buttons[component_index]
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
pixmap = _make_component_thumbnail(comp, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
@@ -1076,18 +1234,13 @@ class MainWindow(QMainWindow):
self._sketch_list.addItem(sketch.name)
for body_id, body in self._current_component.bodies.items():
# QListWidgetItem with a checkbox so the user can toggle
# each body's visibility in the 3D viewer. The item's
# data role stores the body id so the toggle handler can
# QListWidgetItem with a data role so the toggle handler can
# look up the right body without relying on display text.
display_name = body.name
if body.needs_update:
display_name = f"{body.name}"
item = QListWidgetItem(display_name)
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)
# Greying out a hidden body's name is a nice UX touch.
if not body.visible:
item.setForeground(QColor("#6c7086"))
@@ -1103,6 +1256,8 @@ class MainWindow(QMainWindow):
changes to all assembly instances, and recalculates connectors.
"""
self._update_bodies_from_sketch()
self._update_sketches_from_bodies()
self._update_bodies_from_sketch() # re-extrude bodies whose sketches just moved
self._redraw_bodies()
self._propagate_to_assembly()
self._recalculate_connectors()
@@ -1172,6 +1327,49 @@ class MainWindow(QMainWindow):
target = b
break
# Handle cut_all_bodies: apply the cut to every body in the
# component, not just the target.
if body.extrude_cut and body.extrude_cut_all_bodies:
try:
if body.extrude_through_all and target is not None:
cut_length = self._through_all_length(target, sketch)
cut_symmetric = True
cut_invert = False
else:
cut_length = body.extrude_length or 10.0
cut_symmetric = body.extrude_symmetric
cut_invert = body.extrude_invert
tool_geom = self._kernel.extrude(
face_geom,
-cut_length if cut_invert else cut_length,
symmetric=cut_symmetric,
)
if tool_geom is None:
logger.warning(f"Body '{body.name}': cut-all tool geometry is empty")
continue
cut_count = 0
for other_id, other in list(self._current_component.bodies.items()):
if other.geometry is None:
continue
try:
other.geometry = self._kernel.boolean_difference(
other.geometry, tool_geom
)
other.needs_update = False
other.modified_at = datetime.now()
cut_count += 1
except Exception:
pass # body doesn't intersect tool, skip
body.needs_update = False
body.modified_at = datetime.now()
updated += 1
logger.info(
f"Re-extruded cut-all body '{body.name}': cut {cut_count} body(ies)"
)
except Exception as exc:
logger.exception(f"Body '{body.name}': re-extrude cut-all failed: {exc}")
continue # skip the single-target path below
# Compute the new result.
try:
if body.extrude_through_all and target is not None:
@@ -1215,6 +1413,87 @@ class MainWindow(QMainWindow):
if updated > 0:
logger.info(f"Updated {updated} body(ies) from sketch")
def _update_sketches_from_bodies(self) -> None:
"""Re-project underlay construction lines from updated 3D bodies.
For every sketch in the current component that carries a
``_source_face`` (a face-projected underlay) and a
``_source_body_id``, find the corresponding face on the updated
body geometry, re-project its edges to UV, and update the sketch's
external entities *in place* (preserving entity ids so existing
constraints survive). The solver is re-run so any user geometry
anchored to the underlay follows the body.
"""
if not self._current_component:
return
from fluency.geometry_occ.kernel import OCGeometryKernel
kernel = OCGeometryKernel()
updated = 0
for sketch in self._current_component.sketches.values():
src_body_id = getattr(sketch, "_source_body_id", None)
src_face = getattr(sketch, "_source_face", None)
if src_body_id is None or src_face is None:
continue
if sketch.occ_sketch is None:
continue
body = self._current_component.bodies.get(src_body_id)
if body is None or body.geometry is None:
continue
body_shape = kernel._get_shape(body.geometry)
if body_shape is None:
continue
# Find the face on the updated body that matches the original
# face's plane (normal parallel, origin coplanar).
wp = sketch.occ_sketch.get_workplane()
origin, normal = wp[0], wp[1]
ref_center = getattr(sketch, "_source_face_center", None)
match = OCGeometryKernel.find_coplanar_face(
body_shape,
origin,
normal,
ref_center=ref_center,
)
if match is None:
logger.debug(
"Sketch '%s': no matching face on body '%s', skipping",
sketch.name,
body.name,
)
continue
new_face, new_center = match
sketch._source_face = new_face
sketch._source_face_center = new_center
# Re-project the new face's edges into UV.
from fluency.ui.sketch_widget import _project_face_to_uv
try:
polys = _project_face_to_uv(new_face, wp)
except Exception as exc:
logger.debug("re-projection failed for sketch '%s': %s", sketch.name, exc)
continue
if not polys:
continue
# Update external entities in-place (preserves ids + constraints).
ok = sketch.occ_sketch.update_external_entities(polys)
if ok:
updated += 1
logger.info(
"Re-projected underlay for sketch '%s' from body '%s'",
sketch.name,
body.name,
)
# If the sketch is currently loaded in the widget, refresh
# the underlay data so the view reflects the new projection
# WITHOUT re-importing (which would break constraints).
if sketch.occ_sketch is self._sketch_widget._sketch:
self._sketch_widget._source_face = new_face
self._sketch_widget._source_underlay_uv = polys
self._sketch_widget._rebuild_from_sketch()
self._sketch_widget.update()
if updated > 0:
logger.info("Re-projected underlays for %d sketch(es)", updated)
def _propagate_to_assembly(self):
"""Refresh all assembly instances that reference the current component.
@@ -1390,12 +1669,12 @@ class MainWindow(QMainWindow):
btn.clicked.connect(self._on_assembly_component_clicked)
_set_button_style(btn)
# Thumbnail from the component's first body.
# Thumbnail from all bodies in the component.
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))
has_geometry = any(b.visible and b.geometry for b in src_comp.bodies.values())
if has_geometry:
pixmap = _make_component_thumbnail(src_comp, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
@@ -3225,6 +3504,17 @@ class MainWindow(QMainWindow):
sketch.set_workplane(origin, normal, x_dir)
# Keep the face reference for the projection underlay (Phase 3).
sketch._source_face = face_shape
# Store the face centroid for re-matching when the body updates.
try:
from OCP.BRepGProp import BRepGProp
from OCP.GProp import GProp_GProps
props = GProp_GProps()
BRepGProp.VolumeProperties_s(face_shape, props)
c = props.CentreOfMass()
sketch._source_face_center = (float(c.X()), float(c.Y()), float(c.Z()))
except Exception:
sketch._source_face_center = tuple(float(v) for v in origin)
# Remember which body the sketch lives on so a later cut / combine
# extrude auto-targets it. ``source_body`` may be None if the
# pick landed on an untracked shape (e.g. an imported STEP that
@@ -3452,11 +3742,9 @@ class MainWindow(QMainWindow):
break
def _on_body_visibility_changed(self, item: QListWidgetItem) -> None:
"""Toggle a body's 3D visibility when the user flips its checkbox.
"""Toggle a body's 3D visibility when the user clicks pb_body_hide.
itemChanged also fires for selection (not just check-state) changes,
so we filter on the check state being the changed role. The body
is looked up via the UserRole data we set in _refresh_lists.
The body is looked up via the UserRole data we set in _refresh_lists.
"""
if self._current_component is None:
return
@@ -3466,16 +3754,11 @@ class MainWindow(QMainWindow):
body = self._current_component.bodies.get(body_id)
if body is None:
return
new_visible = item.checkState() == Qt.Checked
new_visible = not body.visible # toggle
if body.visible == new_visible:
return # no change
body.visible = new_visible
# Greying out hidden bodies gives a quick visual hint in the list.
item.setForeground(QColor("#1e1e2e") if new_visible else QColor("#6c7086"))
# Apply to the 3D viewer: if the body has a rendered object, hide
# or show it. Bodies without a render_object (e.g. just-created,
# not yet displayed) don't need viewer updates; they'll pick up
# the visibility at the next redraw.
if body.render_object is not None:
ok = self._viewer_3d.set_visibility(body.render_object, new_visible)
if not ok:
@@ -3486,6 +3769,12 @@ class MainWindow(QMainWindow):
)
logger.info(f"{'Visible' if new_visible else 'Hidden'}: {body.name}")
def _on_body_hide_button_clicked(self) -> None:
"""Handle click on pb_body_hide button - toggle visibility of selected body."""
current_item = self._body_list.currentItem()
if current_item is not None:
self._on_body_visibility_changed(current_item)
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
def _resolve_extrude_target(
@@ -3558,6 +3847,7 @@ class MainWindow(QMainWindow):
cut: bool,
union: bool,
through_all: bool,
cut_all_bodies: bool = False,
) -> Optional[Dict[str, Any]]:
"""Compute the *previewable* result of an extrude/cut/union.
@@ -3566,6 +3856,7 @@ class MainWindow(QMainWindow):
- "target_body": the Body being modified (None for plain extrude)
- "tool_geom": the extruded profile geometry (the boolean tool)
- "tool_shape": same, as a TopoDS_Shape (for show/remove)
- "all_targets": list of all bodies affected when cut_all_bodies
Or *None* if the geometry can't be built (e.g. empty sketch).
Mutates nothing on the project — safe to call repeatedly for the
@@ -3577,6 +3868,15 @@ class MainWindow(QMainWindow):
return None
# Resolve target (only meaningful for cut / union).
target = self._resolve_extrude_target(sketch) if (cut or union) else None
# When cut_all_bodies, collect all bodies in the component as targets.
all_targets: list = []
if cut_all_bodies and cut and self._current_component is not None:
all_targets = [
b for b in self._current_component.bodies.values() if b.geometry is not None
]
# Use the first non-tool body as the primary target for preview.
if target is None and all_targets:
target = all_targets[0]
# Determine the extrude length and direction.
if through_all and target is not None:
# Pass-through: symmetric extrude large enough to clear the body
@@ -3617,6 +3917,7 @@ class MainWindow(QMainWindow):
"target_body": target,
"tool_geom": tool_geom,
"tool_shape": tool_shape,
"all_targets": all_targets,
}
# Plain extrude: the tool IS the result.
return {
@@ -3625,6 +3926,7 @@ class MainWindow(QMainWindow):
"target_body": None,
"tool_geom": tool_geom,
"tool_shape": tool_shape,
"all_targets": [],
}
def _start_extrude_preview(self, dialog: ExtrudeDialog, sketch: Sketch, face_geom: Any) -> None:
@@ -3639,17 +3941,24 @@ class MainWindow(QMainWindow):
# which case we leave them alone).
state = {"dimmed": []}
def _apply_dim(target: Optional[Body]):
# Undo any prior dim, then dim the new target.
for bid, tval in state["dimmed"]:
def _apply_dim(targets):
"""Dim one or more bodies for preview clarity."""
# Undo any prior dim.
for bid, _tval in state["dimmed"]:
body = self._current_component.bodies.get(bid) if self._current_component else None
if body is not None and body.render_object is not None:
self._viewer_3d.set_transparency(body.render_object, 0.0)
state["dimmed"].clear()
if target is not None and target.render_object is not None:
ok = self._viewer_3d.set_transparency(target.render_object, 0.6)
if ok:
state["dimmed"].append((target.id, 0.6))
# Normalize to a list.
if targets is None:
targets = []
elif isinstance(targets, Body):
targets = [targets]
for t in targets:
if t is not None and t.render_object is not None:
ok = self._viewer_3d.set_transparency(t.render_object, 0.6)
if ok:
state["dimmed"].append((t.id, 0.6))
def _clear():
self._viewer_3d.clear_preview()
@@ -3663,7 +3972,7 @@ class MainWindow(QMainWindow):
if values is None:
_clear()
return
length, symmetric, invert, cut, union, through_all, _rounded = values
length, symmetric, invert, cut, union, through_all, cut_all_bodies, _rounded = values
result = self._compute_extrude_result(
sketch,
face_geom,
@@ -3673,13 +3982,19 @@ class MainWindow(QMainWindow):
bool(cut),
bool(union),
bool(through_all),
cut_all_bodies=bool(cut_all_bodies),
)
if result is None or result["result_shape"] is None:
self._viewer_3d.clear_preview()
_apply_dim(None)
return
self._viewer_3d.show_preview(result["result_shape"])
_apply_dim(result["target_body"])
# Dim all affected bodies when cut_all_bodies is active.
all_targets = result.get("all_targets", [])
if all_targets:
_apply_dim(all_targets)
else:
_apply_dim(result["target_body"])
dialog.set_preview_callback(_callback)
@@ -3731,10 +4046,13 @@ class MainWindow(QMainWindow):
logger.info("Extrude dialog cancelled")
return
length, symmetric, invert, cut, union, through_all, rounded = dialog.get_values()
length, symmetric, invert, cut, union, through_all, cut_all_bodies, rounded = (
dialog.get_values()
)
logger.info(
f"Extrude params: length={length}, symmetric={symmetric}, "
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}"
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}, "
f"cut_all_bodies={cut_all_bodies}"
)
# Capture the face index before the dialog closes (the selected
@@ -3751,6 +4069,7 @@ class MainWindow(QMainWindow):
bool(cut),
bool(union),
bool(through_all),
cut_all_bodies=bool(cut_all_bodies),
)
if result is None or result["result_geom"] is None:
logger.warning("Extrude produced no geometry")
@@ -3758,12 +4077,41 @@ class MainWindow(QMainWindow):
return
target = result["target_body"]
if target is not None:
# Cut / union: commit the result onto the *target* body in
# place (don't create a separate tool body — the previous
# implementation did, and that was the user-perceived
# "added without cut" bug once the spurious body was
# deleted).
all_targets = result.get("all_targets", [])
if target is not None and bool(cut) and all_targets:
# Cut all bodies: apply the boolean difference to every body
# in the component that has geometry.
tool_geom = result["tool_geom"]
updated_count = 0
for body in all_targets:
try:
new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
body.geometry = new_geom
except Exception as exc:
logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
continue
body.extrude_length = length
body.extrude_symmetric = symmetric
body.extrude_invert = invert
body.extrude_cut = True
body.extrude_union = False
body.extrude_through_all = bool(through_all)
body.extrude_cut_all_bodies = True
body.extrude_face_index = face_index
body.source_sketch = sketch
body.source_operation = "cut"
body.extrude_target_body_id = body.id
if body.render_object is not None:
self._viewer_3d.remove_mesh(body.render_object)
shape = self._kernel._get_shape(body.geometry)
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
updated_count += 1
logger.info(f"Cut-all applied to {updated_count} body(ies)")
body_name = f"{updated_count} body(ies)"
elif target is not None:
# Single-body cut / union: commit the result onto the *target*
# body in place.
target.geometry = result["result_geom"]
# Store extrude params so the body can be rebuilt later.
target.extrude_length = length
@@ -3772,6 +4120,7 @@ class MainWindow(QMainWindow):
target.extrude_cut = bool(cut)
target.extrude_union = bool(union)
target.extrude_through_all = bool(through_all)
target.extrude_cut_all_bodies = False
target.extrude_face_index = face_index
target.source_sketch = sketch
target.source_operation = "cut" if cut else "union"
@@ -3797,6 +4146,7 @@ class MainWindow(QMainWindow):
extrude_cut=False,
extrude_union=False,
extrude_through_all=bool(through_all),
extrude_cut_all_bodies=False,
extrude_face_index=face_index,
)
)
@@ -4356,12 +4706,12 @@ class MainWindow(QMainWindow):
# Rebuild component buttons (one per component, with thumbnails).
for idx, comp in enumerate(self._project.components.values(), start=1):
first_body = next(iter(comp.bodies.values()), None)
if first_body and first_body.geometry:
has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
if has_geometry:
btn = _create_component_button(
idx,
comp.name,
first_body,
comp,
self._kernel,
self._component_group,
self._component_box_layout,
@@ -4409,12 +4759,16 @@ class MainWindow(QMainWindow):
btn.clicked.connect(self._on_assembly_component_clicked)
_set_button_style(btn)
# Thumbnail from the source component's first body.
# Thumbnail from the source component's all bodies.
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))
has_geometry = any(
b.visible and b.geometry for b in src_comp.bodies.values()
)
if has_geometry:
pixmap = _make_component_thumbnail(
src_comp, self._kernel, QSize(96, 96)
)
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
@@ -4512,7 +4866,7 @@ class MainWindow(QMainWindow):
btn = _create_component_button(
btn_num,
name,
body,
comp,
self._kernel,
self._component_group,
self._component_box_layout,