- added renderer

This commit is contained in:
bklronin
2026-07-12 22:21:20 +02:00
parent b8516fff91
commit 210e3cfb5d
12 changed files with 2628 additions and 1580 deletions
+495 -7
View File
@@ -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,