- added renderer
This commit is contained in:
@@ -256,6 +256,9 @@ class MainWindow(QMainWindow):
|
||||
self._assembly_view_active: bool = False
|
||||
self._selected_assembly_component_id: Optional[str] = None
|
||||
|
||||
# Render window reference (prevents GC)
|
||||
self._render_window = None
|
||||
|
||||
# Connector two-click state
|
||||
self._connector_first_pick: Optional[Dict[str, Any]] = None
|
||||
self._connector_second_ac_id: Optional[str] = None
|
||||
@@ -347,6 +350,20 @@ class MainWindow(QMainWindow):
|
||||
self._action_load_last.toggled.connect(self._toggle_load_last_project)
|
||||
file_menu.addAction(self._action_load_last)
|
||||
|
||||
# ── Edit menu (runtime-only, not in the .ui) ──
|
||||
edit_menu = self.menuBar().addMenu("&Edit")
|
||||
self._action_undo = QAction("Undo", self)
|
||||
self._action_undo.setShortcut(QKeySequence("Ctrl+Z"))
|
||||
self._action_undo.triggered.connect(self._undo_sketch)
|
||||
self._action_undo.setEnabled(False)
|
||||
edit_menu.addAction(self._action_undo)
|
||||
|
||||
self._action_redo = QAction("Redo", self)
|
||||
self._action_redo.setShortcut(QKeySequence("Ctrl+Shift+Z"))
|
||||
self._action_redo.triggered.connect(self._redo_sketch)
|
||||
self._action_redo.setEnabled(False)
|
||||
edit_menu.addAction(self._action_redo)
|
||||
|
||||
# ── View menu (runtime-only, not in the .ui) ──
|
||||
view_menu = self.menuBar().addMenu("&View")
|
||||
view_menu.addAction("Fit All", self._fit_view)
|
||||
@@ -360,6 +377,12 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
view_menu.addAction(action)
|
||||
|
||||
view_menu.addSeparator()
|
||||
self._action_render = QAction("Render...", self)
|
||||
self._action_render.setShortcut(QKeySequence("Ctrl+R"))
|
||||
self._action_render.triggered.connect(self._open_render_window)
|
||||
view_menu.addAction(self._action_render)
|
||||
|
||||
# ── Help menu (runtime-only, not in the .ui) ──
|
||||
help_menu = self.menuBar().addMenu("&Help")
|
||||
help_menu.addAction("About", self._show_about)
|
||||
@@ -463,6 +486,7 @@ class MainWindow(QMainWindow):
|
||||
self._btn_con_vert = ui.pb_con_vert
|
||||
self._btn_con_dist = ui.pb_con_dist
|
||||
self._btn_con_sym = ui.pb_con_sym
|
||||
self._btn_con_diameter = ui.pb_con_diameter
|
||||
# ── Snaps ──
|
||||
self._btn_snap_point = ui.pushButton_8
|
||||
self._btn_snap_mid = ui.pb_snap_midp
|
||||
@@ -500,6 +524,9 @@ class MainWindow(QMainWindow):
|
||||
self._btn_add_connector = ui.pb_add_connector
|
||||
self._btn_add_connector.setCheckable(True)
|
||||
self._btn_del_connector = ui.pb_remove_connector
|
||||
# ── Connection list ──
|
||||
self._connection_list = ui.connection_list
|
||||
self._btn_del_connection = ui.pb_del_connection
|
||||
# ── Code tab ──
|
||||
self._btn_apply_code = ui.pb_apply_code
|
||||
self._btn_load_code = ui.pushButton_5
|
||||
@@ -569,6 +596,7 @@ class MainWindow(QMainWindow):
|
||||
self._btn_con_perp.clicked.connect(lambda: self._set_sketch_mode("constrain_perpendicular"))
|
||||
self._btn_con_dist.clicked.connect(lambda: self._set_sketch_mode("constrain_distance"))
|
||||
self._btn_con_sym.clicked.connect(lambda: self._set_sketch_mode("constrain_symmetric"))
|
||||
self._btn_con_diameter.clicked.connect(lambda: self._set_sketch_mode("constrain_diameter"))
|
||||
|
||||
self._btn_snap_point.clicked.connect(
|
||||
lambda c: self._sketch_widget.set_snap_mode("point", c)
|
||||
@@ -608,6 +636,7 @@ class MainWindow(QMainWindow):
|
||||
self._btn_asm_move.toggled.connect(self._on_assembly_move_toggled)
|
||||
self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
|
||||
self._btn_del_connector.clicked.connect(self._on_delete_connector)
|
||||
self._btn_del_connection.clicked.connect(self._on_delete_connection_from_list)
|
||||
self._viewer_3d.connectorPicked.connect(self._on_connector_picked)
|
||||
self._viewer_3d.connectorHover.connect(self._on_connector_hover)
|
||||
self._viewer_3d.connectorPickCancelled.connect(
|
||||
@@ -623,7 +652,7 @@ class MainWindow(QMainWindow):
|
||||
self._on_assembly_move_finished
|
||||
)
|
||||
|
||||
self._btn_update_body.clicked.connect(self._redraw_bodies)
|
||||
self._btn_update_body.clicked.connect(self._update_and_redraw)
|
||||
self._btn_del_body.clicked.connect(self._delete_body)
|
||||
|
||||
self._btn_export_stl.clicked.connect(self._export_stl)
|
||||
@@ -686,6 +715,7 @@ class MainWindow(QMainWindow):
|
||||
self._btn_con_perp,
|
||||
self._btn_con_dist,
|
||||
self._btn_con_sym,
|
||||
self._btn_con_diameter,
|
||||
]:
|
||||
btn.setChecked(False)
|
||||
|
||||
@@ -726,12 +756,21 @@ class MainWindow(QMainWindow):
|
||||
self._btn_con_perp,
|
||||
self._btn_con_dist,
|
||||
self._btn_con_sym,
|
||||
self._btn_con_diameter,
|
||||
]:
|
||||
btn.setChecked(False)
|
||||
self._sketch_widget.set_mode(None)
|
||||
|
||||
def _on_sketch_updated(self):
|
||||
pass
|
||||
"""Mark bodies as needing update when their source sketch changes."""
|
||||
if not self._current_component or not self._current_sketch:
|
||||
return
|
||||
for body in self._current_component.bodies.values():
|
||||
if body.source_sketch is self._current_sketch and body.extrude_length is not None:
|
||||
body.needs_update = True
|
||||
self._refresh_lists()
|
||||
# Update undo/redo menu actions
|
||||
self._update_undo_redo_actions()
|
||||
|
||||
def _get_active_component_index(self) -> int:
|
||||
for i, btn in enumerate(self._component_buttons):
|
||||
@@ -792,6 +831,7 @@ class MainWindow(QMainWindow):
|
||||
def _refresh_lists(self):
|
||||
self._sketch_list.clear()
|
||||
self._body_list.clear()
|
||||
self._refresh_connection_list()
|
||||
|
||||
if self._current_component:
|
||||
for sketch_id, sketch in self._current_component.sketches.items():
|
||||
@@ -802,7 +842,10 @@ class MainWindow(QMainWindow):
|
||||
# each body's visibility in the 3D viewer. The item's
|
||||
# data role stores the body id so the toggle handler can
|
||||
# look up the right body without relying on display text.
|
||||
item = QListWidgetItem(body.name)
|
||||
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.
|
||||
@@ -812,8 +855,230 @@ class MainWindow(QMainWindow):
|
||||
# Greying out a hidden body's name is a nice UX touch.
|
||||
if not body.visible:
|
||||
item.setForeground(QColor("#6c7086"))
|
||||
elif body.needs_update:
|
||||
item.setForeground(QColor("#f5a623")) # Orange warning
|
||||
self._body_list.addItem(item)
|
||||
|
||||
def _update_and_redraw(self):
|
||||
"""Full pipeline: re-extrude from sketch, redraw, propagate to assembly.
|
||||
|
||||
Connected to the 'Update Body' button. Rebuilds body geometry
|
||||
from source sketches, refreshes the component view, propagates
|
||||
changes to all assembly instances, and recalculates connectors.
|
||||
"""
|
||||
self._update_bodies_from_sketch()
|
||||
self._redraw_bodies()
|
||||
self._propagate_to_assembly()
|
||||
self._recalculate_connectors()
|
||||
self._refresh_lists()
|
||||
|
||||
def _update_bodies_from_sketch(self):
|
||||
"""Re-extrude bodies whose source sketch has been edited.
|
||||
|
||||
For every body in the current component that has a ``source_sketch``
|
||||
and stored extrude parameters, re-solve the sketch and rebuild the
|
||||
body geometry. Cut / union operations are re-applied against the
|
||||
current target body.
|
||||
"""
|
||||
if not self._current_component:
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for body_id, body in list(self._current_component.bodies.items()):
|
||||
if body.source_sketch is None or body.extrude_length is None:
|
||||
continue # not a re-extrudable body
|
||||
|
||||
sketch = body.source_sketch
|
||||
if not sketch.occ_sketch:
|
||||
logger.debug(f"Body '{body.name}': source sketch has no OCC entity, skipping")
|
||||
continue
|
||||
|
||||
# Re-solve the sketch so geometry reflects any edits.
|
||||
sketch.apply_workplane()
|
||||
sketch.solve()
|
||||
|
||||
# Resolve the face geometry.
|
||||
face_geom = None
|
||||
if body.extrude_face_index is not None:
|
||||
faces = sketch.occ_sketch.detect_faces()
|
||||
if 0 <= body.extrude_face_index < len(faces):
|
||||
face_geom = sketch.occ_sketch.build_face_geometry(
|
||||
faces[body.extrude_face_index]
|
||||
)
|
||||
else:
|
||||
# Face index out of range (topology changed) — fallback
|
||||
# to first face if available.
|
||||
if faces:
|
||||
face_geom = sketch.occ_sketch.build_face_geometry(faces[0])
|
||||
logger.info(
|
||||
f"Body '{body.name}': face index {body.extrude_face_index} "
|
||||
f"invalid, fell back to face 0"
|
||||
)
|
||||
if face_geom is None:
|
||||
face_geom = sketch.occ_sketch.get_geometry()
|
||||
if face_geom is None:
|
||||
logger.warning(f"Body '{body.name}': no geometry from sketch, skipping")
|
||||
continue
|
||||
|
||||
# Resolve the target body for cut / union.
|
||||
target = None
|
||||
if body.extrude_cut or body.extrude_union:
|
||||
if body.extrude_target_body_id and body.extrude_target_body_id in self._current_component.bodies:
|
||||
target = self._current_component.bodies[body.extrude_target_body_id]
|
||||
else:
|
||||
# Fallback: first body that isn't this one.
|
||||
for bid, b in self._current_component.bodies.items():
|
||||
if bid != body_id and b.geometry is not None:
|
||||
target = b
|
||||
break
|
||||
|
||||
# Compute the new result.
|
||||
try:
|
||||
if body.extrude_through_all and target is not None:
|
||||
length = self._through_all_length(target, sketch)
|
||||
result = self._compute_extrude_result(
|
||||
sketch, face_geom,
|
||||
length, symmetric=True, invert=False,
|
||||
cut=body.extrude_cut, union=body.extrude_union,
|
||||
through_all=True,
|
||||
)
|
||||
else:
|
||||
length = body.extrude_length
|
||||
result = self._compute_extrude_result(
|
||||
sketch, face_geom,
|
||||
length,
|
||||
symmetric=body.extrude_symmetric,
|
||||
invert=body.extrude_invert,
|
||||
cut=body.extrude_cut,
|
||||
union=body.extrude_union,
|
||||
through_all=False,
|
||||
)
|
||||
|
||||
if result is None or result["result_geom"] is None:
|
||||
logger.warning(f"Body '{body.name}': re-extrude produced no geometry")
|
||||
continue
|
||||
|
||||
body.geometry = result["result_geom"]
|
||||
body.needs_update = False
|
||||
body.modified_at = datetime.now()
|
||||
updated += 1
|
||||
logger.info(f"Re-extruded body: {body.name}")
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(f"Body '{body.name}': re-extrude failed: {exc}")
|
||||
|
||||
if updated > 0:
|
||||
logger.info(f"Updated {updated} body(ies) from sketch")
|
||||
|
||||
def _propagate_to_assembly(self):
|
||||
"""Refresh all assembly instances that reference the current component.
|
||||
|
||||
Called after ``_update_bodies_from_sketch`` so the assembly view
|
||||
shows the updated bodies without a full scene rebuild.
|
||||
"""
|
||||
if not self._current_component:
|
||||
return
|
||||
|
||||
comp_id = self._current_component.id
|
||||
for assembly in self._project.assemblies.values():
|
||||
for ac_id, ac in assembly.components.items():
|
||||
if ac.component_id == comp_id:
|
||||
self._update_assembly_component_in_viewer(ac_id)
|
||||
|
||||
def _recalculate_connectors(self):
|
||||
"""Smart recalculation of connector positions after body update.
|
||||
|
||||
For each assembly instance of the current component, probe the
|
||||
updated geometry near each connector's world position. If a
|
||||
matching snap candidate is found within tolerance, update the
|
||||
connector's local position and normal. Otherwise mark it invalid.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if not self._current_component:
|
||||
return
|
||||
|
||||
comp_id = self._current_component.id
|
||||
TOLERANCE = 5.0 # mm — max distance to consider a match
|
||||
updated = 0
|
||||
invalidated = 0
|
||||
|
||||
for assembly in self._project.assemblies.values():
|
||||
for ac_id, ac in assembly.components.items():
|
||||
if ac.component_id != comp_id:
|
||||
continue
|
||||
for conn_id, conn in list(ac.connectors.items()):
|
||||
# Compute the connector's current world position.
|
||||
local_pos = np.array(conn.position, dtype=float)
|
||||
world_pos = ac.position + ac.rotation @ local_pos
|
||||
|
||||
# Project to screen and probe nearby geometry.
|
||||
screen = self._viewer_3d._renderer._project_to_screen(
|
||||
tuple(world_pos)
|
||||
)
|
||||
if screen is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
candidates = self._viewer_3d._renderer.probe_snap_candidates(
|
||||
screen[0], screen[1], radius=20,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not candidates:
|
||||
# No geometry nearby — connector may be orphaned.
|
||||
if not conn.is_invalid:
|
||||
conn.is_invalid = True
|
||||
invalidated += 1
|
||||
logger.info(
|
||||
f"Connector '{conn.name}' on {ac.name}: "
|
||||
f"no geometry nearby, marked invalid"
|
||||
)
|
||||
continue
|
||||
|
||||
# Find the nearest candidate of matching type.
|
||||
best = None
|
||||
best_dist = float("inf")
|
||||
for cand in candidates:
|
||||
cand_pos = np.array(cand["position"], dtype=float)
|
||||
dist = float(np.linalg.norm(cand_pos - world_pos))
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best = cand
|
||||
|
||||
if best is not None and best_dist <= TOLERANCE:
|
||||
# Update connector to new local coords.
|
||||
new_world = np.array(best["position"], dtype=float)
|
||||
new_local = ac.rotation.T @ (new_world - ac.position)
|
||||
conn.position = tuple(float(v) for v in new_local)
|
||||
if best.get("normal") is not None:
|
||||
new_normal = ac.rotation.T @ np.array(best["normal"], dtype=float)
|
||||
conn.normal = tuple(float(v) for v in new_normal)
|
||||
if best.get("x_dir") is not None:
|
||||
new_xdir = ac.rotation.T @ np.array(best["x_dir"], dtype=float)
|
||||
conn.x_dir = tuple(float(v) for v in new_xdir)
|
||||
conn.is_invalid = False
|
||||
conn.modified_at = datetime.now()
|
||||
updated += 1
|
||||
logger.info(
|
||||
f"Connector '{conn.name}' on {ac.name}: "
|
||||
f"updated ({best_dist:.1f}mm shift)"
|
||||
)
|
||||
elif not conn.is_invalid:
|
||||
conn.is_invalid = True
|
||||
invalidated += 1
|
||||
logger.info(
|
||||
f"Connector '{conn.name}' on {ac.name}: "
|
||||
f"no close match ({best_dist:.1f}mm), marked invalid"
|
||||
)
|
||||
|
||||
if updated or invalidated:
|
||||
logger.info(
|
||||
f"Connector recalc: {updated} updated, "
|
||||
f"{invalidated} invalidated"
|
||||
)
|
||||
|
||||
def _redraw_bodies(self):
|
||||
self._viewer_3d.clear_scene()
|
||||
|
||||
@@ -1078,11 +1343,12 @@ class MainWindow(QMainWindow):
|
||||
try:
|
||||
local_pos = np.array(conn.position, dtype=float)
|
||||
world_pos = ac.position + ac.rotation @ local_pos
|
||||
sphere_shape = self._make_connector_marker(tuple(world_pos))
|
||||
conn_color = (1.0, 0.1, 0.1) if conn.is_invalid else (1.0, 0.3, 0.0)
|
||||
sphere_shape = self._make_connector_marker(tuple(world_pos), color=conn_color)
|
||||
if sphere_shape is not None:
|
||||
self._viewer_3d.show_shape(
|
||||
sphere_shape,
|
||||
color=(1.0, 0.3, 0.0), # Orange
|
||||
color=conn_color,
|
||||
name=f"conn_{ac_id}_{conn_id}",
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -1151,11 +1417,12 @@ class MainWindow(QMainWindow):
|
||||
try:
|
||||
local_pos = np.array(conn.position, dtype=float)
|
||||
world_pos = ac.position + ac.rotation @ local_pos
|
||||
sphere_shape = self._make_connector_marker(tuple(world_pos))
|
||||
conn_color = (1.0, 0.1, 0.1) if conn.is_invalid else (1.0, 0.3, 0.0)
|
||||
sphere_shape = self._make_connector_marker(tuple(world_pos), color=conn_color)
|
||||
if sphere_shape is not None:
|
||||
self._viewer_3d.show_shape(
|
||||
sphere_shape,
|
||||
color=(1.0, 0.3, 0.0), # Orange
|
||||
color=conn_color,
|
||||
name=f"conn_{ac_id}_{conn_id}",
|
||||
)
|
||||
new_ids.append(f"conn_{ac_id}_{conn_id}")
|
||||
@@ -1553,6 +1820,7 @@ class MainWindow(QMainWindow):
|
||||
self._connector_first_pick = None
|
||||
self._connector_second_ac_id = None
|
||||
self._mark_dirty()
|
||||
self._refresh_connection_list()
|
||||
self._show_assembly_in_viewer(fit=True)
|
||||
|
||||
@staticmethod
|
||||
@@ -1962,6 +2230,92 @@ class MainWindow(QMainWindow):
|
||||
ac.remove_connector(conn_id)
|
||||
logger.info(f"Removed connector {conn_id}")
|
||||
self._show_assembly_in_viewer(fit=True)
|
||||
self._refresh_connection_list()
|
||||
|
||||
def _refresh_connection_list(self):
|
||||
"""Refresh the connection list widget with all assembly connections.
|
||||
|
||||
Each entry shows the connected component names and connector info.
|
||||
The list is populated from the active assembly's connections.
|
||||
"""
|
||||
self._connection_list.clear()
|
||||
|
||||
assembly = self._get_assembly()
|
||||
if assembly is None or not assembly.connections:
|
||||
return
|
||||
|
||||
for conn in assembly.connections:
|
||||
first_ac = assembly.components.get(conn.first_ac_id)
|
||||
second_ac = assembly.components.get(conn.second_ac_id)
|
||||
|
||||
if first_ac is None or second_ac is None:
|
||||
continue
|
||||
|
||||
# Get component names from the project.
|
||||
first_comp = self._project.get_component_by_id(first_ac.component_id)
|
||||
second_comp = self._project.get_component_by_id(second_ac.component_id)
|
||||
|
||||
first_name = first_comp.name if first_comp else first_ac.name
|
||||
second_name = second_comp.name if second_comp else second_ac.name
|
||||
|
||||
# Build display text.
|
||||
display = f"{first_name} ↔ {second_name}"
|
||||
|
||||
item = QListWidgetItem(display)
|
||||
item.setData(Qt.UserRole, conn.id)
|
||||
self._connection_list.addItem(item)
|
||||
|
||||
def _on_delete_connection_from_list(self):
|
||||
"""Delete the selected connection from the connection list.
|
||||
|
||||
Also removes the mated connectors from both assembly components
|
||||
and cleans up the connection graph.
|
||||
"""
|
||||
selected = self._connection_list.currentItem()
|
||||
if selected is None:
|
||||
QMessageBox.information(self, "No Selection",
|
||||
"Select a connection to delete")
|
||||
return
|
||||
|
||||
conn_id = selected.data(Qt.UserRole)
|
||||
assembly = self._get_assembly()
|
||||
if assembly is None:
|
||||
return
|
||||
|
||||
# Find the connection.
|
||||
target_conn = None
|
||||
for conn in assembly.connections:
|
||||
if conn.id == conn_id:
|
||||
target_conn = conn
|
||||
break
|
||||
|
||||
if target_conn is None:
|
||||
return
|
||||
|
||||
# Get the assembly components involved.
|
||||
first_ac = assembly.components.get(target_conn.first_ac_id)
|
||||
second_ac = assembly.components.get(target_conn.second_ac_id)
|
||||
|
||||
# Remove the mated connectors from both sides.
|
||||
if first_ac is not None:
|
||||
for conn in list(first_ac.connectors.values()):
|
||||
if conn.partner_ac_id == target_conn.second_ac_id:
|
||||
first_ac.remove_connector(conn.id)
|
||||
break
|
||||
|
||||
if second_ac is not None:
|
||||
for conn in list(second_ac.connectors.values()):
|
||||
if conn.partner_ac_id == target_conn.first_ac_id:
|
||||
second_ac.remove_connector(conn.id)
|
||||
break
|
||||
|
||||
# Remove the connection from the assembly.
|
||||
assembly.connections = [c for c in assembly.connections if c.id != conn_id]
|
||||
|
||||
self._mark_dirty()
|
||||
self._refresh_connection_list()
|
||||
self._show_assembly_in_viewer(fit=True)
|
||||
logger.info(f"Deleted connection {conn_id}")
|
||||
|
||||
def _new_workplane(self):
|
||||
"""Open the orientation dialog and create a new independent workplane.
|
||||
@@ -3046,6 +3400,10 @@ class MainWindow(QMainWindow):
|
||||
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}"
|
||||
)
|
||||
|
||||
# Capture the face index before the dialog closes (the selected
|
||||
# face may be cleared during preview cleanup).
|
||||
face_index = self._sketch_widget.get_selected_face_index()
|
||||
|
||||
try:
|
||||
result = self._compute_extrude_result(
|
||||
sketch, face_geom,
|
||||
@@ -3065,6 +3423,17 @@ class MainWindow(QMainWindow):
|
||||
# "added without cut" bug once the spurious body was
|
||||
# deleted).
|
||||
target.geometry = result["result_geom"]
|
||||
# Store extrude params so the body can be rebuilt later.
|
||||
target.extrude_length = length
|
||||
target.extrude_symmetric = symmetric
|
||||
target.extrude_invert = invert
|
||||
target.extrude_cut = bool(cut)
|
||||
target.extrude_union = bool(union)
|
||||
target.extrude_through_all = bool(through_all)
|
||||
target.extrude_face_index = face_index
|
||||
target.source_sketch = sketch
|
||||
target.source_operation = "cut" if cut else "union"
|
||||
target.extrude_target_body_id = target.id
|
||||
if target.render_object is not None:
|
||||
self._viewer_3d.remove_mesh(target.render_object)
|
||||
shape = self._kernel._get_shape(target.geometry)
|
||||
@@ -3084,6 +3453,13 @@ class MainWindow(QMainWindow):
|
||||
geometry=result["result_geom"],
|
||||
source_sketch=sketch,
|
||||
source_operation="extrude",
|
||||
extrude_length=length,
|
||||
extrude_symmetric=symmetric,
|
||||
extrude_invert=invert,
|
||||
extrude_cut=False,
|
||||
extrude_union=False,
|
||||
extrude_through_all=bool(through_all),
|
||||
extrude_face_index=face_index,
|
||||
)
|
||||
)
|
||||
self._mark_dirty()
|
||||
@@ -3204,6 +3580,8 @@ class MainWindow(QMainWindow):
|
||||
name=f"Cut_{len(self._current_component.bodies) + 1}",
|
||||
geometry=result_geom,
|
||||
source_operation="boolean_cut",
|
||||
extrude_cut=True,
|
||||
extrude_target_body_id=base_id,
|
||||
)
|
||||
)
|
||||
self._mark_dirty()
|
||||
@@ -3241,6 +3619,7 @@ class MainWindow(QMainWindow):
|
||||
name=f"Union_{len(self._current_component.bodies) + 1}",
|
||||
geometry=result_geom,
|
||||
source_operation="boolean_union",
|
||||
extrude_union=True,
|
||||
)
|
||||
)
|
||||
self._mark_dirty()
|
||||
@@ -3792,6 +4171,115 @@ class MainWindow(QMainWindow):
|
||||
def _reset_view(self):
|
||||
self._viewer_3d.set_camera_position((100, 100, 100), (0, 0, 0))
|
||||
|
||||
def _open_render_window(self):
|
||||
"""Open the render window for the selected body or assembly."""
|
||||
from fluency.ui.render_window import RenderWindow
|
||||
|
||||
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:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Render",
|
||||
"Select a body or assembly component to render.",
|
||||
)
|
||||
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.
|
||||
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()
|
||||
fov = self._viewer_3d.get_camera_fov()
|
||||
viewport_camera = RenderCamera(
|
||||
origin=tuple(float(v) for v in eye),
|
||||
target=tuple(float(v) for v in at_),
|
||||
up=tuple(float(v) for v in up),
|
||||
fov=fov,
|
||||
)
|
||||
except Exception:
|
||||
viewport_camera = None
|
||||
|
||||
win = RenderWindow(shape=shape, camera=viewport_camera, parent=self)
|
||||
win.show()
|
||||
self._render_window = win # prevent GC
|
||||
|
||||
# ─── Sketch Undo/Redo ─────────────────────────────────────────────────
|
||||
|
||||
def _get_sketch_widget(self):
|
||||
"""Return the active sketch widget, or None if not in sketch mode."""
|
||||
# The sketch widget is in the sketch tab
|
||||
if hasattr(self, '_sketch_widget'):
|
||||
return self._sketch_widget
|
||||
return None
|
||||
|
||||
def _undo_sketch(self):
|
||||
"""Undo the last sketch operation."""
|
||||
sketch_widget = self._get_sketch_widget()
|
||||
if sketch_widget and sketch_widget.get_undo_manager():
|
||||
undo_mgr = sketch_widget.get_undo_manager()
|
||||
if undo_mgr.can_undo:
|
||||
undo_mgr.undo()
|
||||
sketch_widget._rebuild_from_sketch()
|
||||
sketch_widget._solve_and_sync()
|
||||
sketch_widget.sketch_updated.emit()
|
||||
sketch_widget.update()
|
||||
self._update_undo_redo_actions()
|
||||
|
||||
def _redo_sketch(self):
|
||||
"""Redo the last undone sketch operation."""
|
||||
sketch_widget = self._get_sketch_widget()
|
||||
if sketch_widget and sketch_widget.get_undo_manager():
|
||||
undo_mgr = sketch_widget.get_undo_manager()
|
||||
if undo_mgr.can_redo:
|
||||
undo_mgr.redo()
|
||||
sketch_widget._rebuild_from_sketch()
|
||||
sketch_widget._solve_and_sync()
|
||||
sketch_widget.sketch_updated.emit()
|
||||
sketch_widget.update()
|
||||
self._update_undo_redo_actions()
|
||||
|
||||
def _update_undo_redo_actions(self):
|
||||
"""Update the enabled state of Undo/Redo menu actions."""
|
||||
sketch_widget = self._get_sketch_widget()
|
||||
if sketch_widget and sketch_widget.get_undo_manager():
|
||||
undo_mgr = sketch_widget.get_undo_manager()
|
||||
self._action_undo.setEnabled(undo_mgr.can_undo)
|
||||
self._action_redo.setEnabled(undo_mgr.can_redo)
|
||||
else:
|
||||
self._action_undo.setEnabled(False)
|
||||
self._action_redo.setEnabled(False)
|
||||
|
||||
def _show_about(self):
|
||||
QMessageBox.about(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user