- added "measurement lines"
This commit is contained in:
+410
-56
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user