3807 lines
160 KiB
Python
3807 lines
160 KiB
Python
"""Main application window — application shell, menus, panels, operations."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import logging
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
import uuid
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect, QSettings
|
||
from PySide6.QtGui import (
|
||
QAction,
|
||
QColor,
|
||
QFont,
|
||
QIcon,
|
||
QKeySequence,
|
||
QPainter,
|
||
QPainterPath,
|
||
QPen,
|
||
)
|
||
MAX_RECENT_PROJECTS = 10
|
||
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QButtonGroup,
|
||
QCheckBox,
|
||
QComboBox,
|
||
QDialog,
|
||
QDialogButtonBox,
|
||
QDockWidget,
|
||
QDoubleSpinBox,
|
||
QFileDialog,
|
||
QFrame,
|
||
QGridLayout,
|
||
QGroupBox,
|
||
QHBoxLayout,
|
||
QInputDialog,
|
||
QLabel,
|
||
QLineEdit,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QMainWindow,
|
||
QMenu,
|
||
QMenuBar,
|
||
QMessageBox,
|
||
QPushButton,
|
||
QRadioButton,
|
||
QSizePolicy,
|
||
QSplitter,
|
||
QSpinBox,
|
||
QStatusBar,
|
||
QTabWidget,
|
||
QTextEdit,
|
||
QToolBar,
|
||
QTreeWidget,
|
||
QTreeWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||
from fluency.geometry_occ.sketch import OCCSketch
|
||
from fluency.geometry.base import Point2D, Point3D
|
||
from fluency.io.project_io import load_project, project_zip_path, save_project
|
||
from fluency.models.data_model import Project, Component, Sketch, Body, Workplane
|
||
from fluency.rendering.occ_renderer import OCCRenderer
|
||
|
||
from fluency.ui.dialogs import (
|
||
ExtrudeDialog,
|
||
OffsetDialog,
|
||
RevolveDialog,
|
||
WorkplaneOrientationDialog,
|
||
)
|
||
from fluency.ui.sketch_widget import Sketch2DWidget
|
||
from fluency.ui.viewer_widget import Viewer3DWidget
|
||
|
||
from gui_ui import Ui_fluencyCAD # auto-generated Qt form (project root on sys.path)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
def _project_body_to_workplane(
|
||
body_shape: Any,
|
||
workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]],
|
||
) -> List[List[Tuple[float, float]]]:
|
||
"""Project ALL edges of a 3D body onto a workplane, returning UV polylines.
|
||
|
||
*workplane* is (origin, normal, x_dir). Every edge (linear and curved)
|
||
of every face of *body_shape* is projected onto the workplane by mapping
|
||
each sample point from 3D \u2192 UV (orthographic projection along the
|
||
workplane normal). The result is a list of polylines, each a list of
|
||
(u, v) points, suitable as underlay construction lines in the 2D sketch.
|
||
|
||
This lets the user see the body's silhouette from the workplane's
|
||
perspective and draw sketches precisely aligned to the body's features.
|
||
"""
|
||
import numpy as np
|
||
from OCP.TopExp import TopExp_Explorer
|
||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_WIRE
|
||
from OCP.TopoDS import TopoDS
|
||
from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
|
||
from OCP.GeomAbs import GeomAbs_Line
|
||
from OCP.gp import gp_Pnt
|
||
|
||
origin = np.asarray(workplane[0], dtype=float)
|
||
normal = np.asarray(workplane[1], dtype=float)
|
||
x_dir = np.asarray(workplane[2], dtype=float)
|
||
x_dir = x_dir / np.linalg.norm(x_dir)
|
||
normal = normal / np.linalg.norm(normal)
|
||
y_dir = np.cross(normal, x_dir)
|
||
y_dir = y_dir / np.linalg.norm(y_dir)
|
||
|
||
def world_to_uv(p: gp_Pnt) -> Tuple[float, float]:
|
||
v = np.array([p.X() - origin[0], p.Y() - origin[1], p.Z() - origin[2]])
|
||
return (float(np.dot(v, x_dir)), float(np.dot(v, y_dir)))
|
||
|
||
polylines: List[List[Tuple[float, float]]] = []
|
||
|
||
# Iterate every face of the body, then each wire/edge within.
|
||
face_expl = TopExp_Explorer(body_shape, TopAbs_FACE)
|
||
while face_expl.More():
|
||
face = face_expl.Current()
|
||
wire_expl = TopExp_Explorer(face, TopAbs_WIRE)
|
||
while wire_expl.More():
|
||
wire = wire_expl.Current()
|
||
edge_expl = TopExp_Explorer(wire, TopAbs_EDGE)
|
||
while edge_expl.More():
|
||
edge = TopoDS.Edge_s(edge_expl.Current())
|
||
try:
|
||
crv = BRepAdaptor_Curve(edge)
|
||
f = crv.FirstParameter()
|
||
l = crv.LastParameter()
|
||
is_line = crv.GetType() == GeomAbs_Line
|
||
if is_line:
|
||
pts = [crv.Value(f), crv.Value(l)]
|
||
else:
|
||
# Sample 24 segments \u2014 enough for smooth curves.
|
||
pts = [crv.Value(f + (l - f) * i / 24.0) for i in range(25)]
|
||
poly = [world_to_uv(p) for p in pts]
|
||
polylines.append(poly)
|
||
except Exception:
|
||
pass
|
||
edge_expl.Next()
|
||
wire_expl.Next()
|
||
face_expl.Next()
|
||
|
||
return polylines
|
||
|
||
|
||
def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[Tuple[float, float]]:
|
||
"""Offset a closed polygon by *distance* (positive = outward).
|
||
|
||
Uses the edge-normal method: each edge is offset along its outward
|
||
normal, then adjacent offset edges are intersected to find the new
|
||
vertex positions. Handles convex polygons well; concave (reflex)
|
||
corners may produce self-intersecting results for large offsets.
|
||
|
||
Returns the offset polygon as a list of (x, y) tuples (same length
|
||
as *points*, closed).
|
||
"""
|
||
import math
|
||
n = len(points)
|
||
if n < 3:
|
||
return list(points)
|
||
|
||
# Determine polygon orientation (signed area).
|
||
area = 0.0
|
||
for i in range(n):
|
||
j = (i + 1) % n
|
||
area += points[i][0] * points[j][1] - points[j][0] * points[i][1]
|
||
is_ccw = area > 0.0
|
||
|
||
# Compute edge directions and left normals.
|
||
edges: List[Tuple[float, float]] = []
|
||
normals: List[Tuple[float, float]] = []
|
||
for i in range(n):
|
||
j = (i + 1) % n
|
||
dx = points[j][0] - points[i][0]
|
||
dy = points[j][1] - points[i][1]
|
||
length = math.hypot(dx, dy)
|
||
if length < 1e-9:
|
||
edges.append((0.0, 0.0))
|
||
normals.append((0.0, 0.0))
|
||
else:
|
||
ux = dx / length
|
||
uy = dy / length
|
||
edges.append((ux, uy))
|
||
# Left normal: (-uy, ux)
|
||
normals.append((-uy, ux))
|
||
|
||
# For CCW polygons the left normal points *inward*; flip for outward.
|
||
if is_ccw:
|
||
normals = [(-nx, -ny) for (nx, ny) in normals]
|
||
|
||
result: List[Tuple[float, float]] = []
|
||
for i in range(n):
|
||
prev_i = (i - 1 + n) % n
|
||
n_prev = normals[prev_i] # outward normal of edge (prev, i)
|
||
n_curr = normals[i] # outward normal of edge (i, next)
|
||
|
||
# Intersect the two offset edge lines to find the new vertex.
|
||
# Line 1: through points[prev_i] + d*n_prev, direction = edges[prev_i]
|
||
# Line 2: through points[i] + d*n_curr, direction = edges[i]
|
||
p1x = points[prev_i][0] + distance * n_prev[0]
|
||
p1y = points[prev_i][1] + distance * n_prev[1]
|
||
d1x, d1y = edges[prev_i]
|
||
|
||
p2x = points[i][0] + distance * n_curr[0]
|
||
p2y = points[i][1] + distance * n_curr[1]
|
||
d2x, d2y = edges[i]
|
||
|
||
det = d1x * d2y - d1y * d2x
|
||
if abs(det) < 1e-9:
|
||
# Parallel edges — fall back to normal offset.
|
||
result.append((points[i][0] + distance * n_curr[0],
|
||
points[i][1] + distance * n_curr[1]))
|
||
else:
|
||
diff_x = p2x - p1x
|
||
diff_y = p2y - p1y
|
||
t1 = (diff_x * d2y - diff_y * d2x) / det
|
||
result.append((p1x + t1 * d1x, p1y + t1 * d1y))
|
||
|
||
return result
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
"""Main application window."""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
logger.info("Initializing MainWindow")
|
||
|
||
self._project = Project()
|
||
self._kernel = OCGeometryKernel()
|
||
logger.info("Created Project and OCGeometryKernel")
|
||
|
||
self._current_component: Optional[Component] = None
|
||
self._current_sketch: Optional[Sketch] = None
|
||
self._selected_body: Optional[Body] = None
|
||
|
||
self._component_buttons: List[QPushButton] = []
|
||
self._component_group: Optional[QButtonGroup] = None
|
||
|
||
# Assembly state
|
||
self._assembly_component_buttons: List[QPushButton] = []
|
||
self._assembly_component_group: Optional[QButtonGroup] = None
|
||
self._assembly_view_active: bool = False
|
||
self._selected_assembly_component_id: Optional[str] = None
|
||
|
||
# Connector two-click state
|
||
self._connector_first_pick: Optional[Dict[str, Any]] = None
|
||
self._connector_second_ac_id: Optional[str] = None
|
||
self._connector_align_pos: Any = None
|
||
|
||
# Drag-move state for assembly components
|
||
self._asm_move_ac_id: Optional[str] = None
|
||
self._asm_move_start_pos: Any = None
|
||
# Rigid-group drag state: maps every component id in the dragged
|
||
# rigid group to its start position, so the whole group translates
|
||
# together and connected partners keep their solved relative
|
||
# transforms. Keyed by AssemblyComponent.id.
|
||
self._asm_move_group_start: Dict[str, Any] = {}
|
||
# Cached rigid-group membership for the current drag (avoids recomputing
|
||
# the BFS graph on every mouse-move event).
|
||
self._asm_move_group_ids: List[str] = []
|
||
# Cache of render object IDs per assembly component, so drag updates
|
||
# can replace only the moved component's shapes without clearing the
|
||
# entire scene (avoids camera flicker).
|
||
self._asm_render_objects: Dict[str, List[str]] = {}
|
||
|
||
# ── Project file state ──
|
||
# The path the project was loaded from / last saved to. None means
|
||
# the project is unsaved (the title bar will show "Untitled").
|
||
# ``_dirty`` is set on any edit; cleared after a successful save.
|
||
self._project_path: Optional[str] = None
|
||
self._dirty: bool = False
|
||
# Suppresses ``_mark_dirty`` while we're setting up the default
|
||
# project (init / new / open), so a freshly-created empty project
|
||
# doesn't immediately show as "modified" in the title bar.
|
||
self._suspend_dirty: bool = True
|
||
|
||
# ── Settings (persistent preferences) ──
|
||
self._settings = QSettings("FluencyCAD", "FluencyCAD")
|
||
|
||
self._setup_ui()
|
||
self._setup_connections()
|
||
self._create_initial_component()
|
||
self._create_initial_assembly()
|
||
self._setup_recent_projects()
|
||
self._suspend_dirty = False
|
||
self._update_window_title()
|
||
logger.info("MainWindow initialization complete")
|
||
|
||
def _setup_ui(self):
|
||
self.setWindowTitle("Fluency CAD 2.0")
|
||
self.setMinimumSize(1400, 900)
|
||
|
||
# Central widget first so ``self._ui`` is populated when we wire
|
||
# the File menu actions to their handlers.
|
||
self._create_central_widget()
|
||
self._create_menus()
|
||
self._create_dock_widgets()
|
||
self._setup_ui_aliases()
|
||
|
||
logger.info("Ready")
|
||
|
||
def _create_menus(self):
|
||
"""Wire up the File menu actions defined in the .ui file.
|
||
|
||
The File menu and its QActions (``actionNew_Project``,
|
||
``actionOpen_Project``, ``actionSave_Project``, etc.) are all
|
||
declared in ``gui.ui`` so they integrate cleanly with the macOS
|
||
system menubar. This method only connects the actions to their
|
||
handlers and adds the runtime-only View / Help menus.
|
||
"""
|
||
# ── File menu actions (defined in gui.ui) ──
|
||
self._ui.actionNew_Project.triggered.connect(self._new_project)
|
||
self._ui.actionOpen_Project.triggered.connect(self._open_project)
|
||
self._ui.actionSave_Project.triggered.connect(self._save_project)
|
||
self._ui.actionSave_Project_As.triggered.connect(self._save_project_as)
|
||
self._ui.actionImport_File.triggered.connect(self._import_file)
|
||
self._ui.actionExport_Step.triggered.connect(self._export_step)
|
||
self._ui.actionExport_Iges.triggered.connect(self._export_iges)
|
||
self._ui.actionExport_Stl.triggered.connect(self._export_stl)
|
||
self._ui.actionExit.triggered.connect(self.close)
|
||
|
||
# ── Recent Projects submenu (runtime-only) ──
|
||
file_menu = self._ui.menuFile
|
||
self._recent_projects_menu = QMenu("Recent Projects", self)
|
||
file_menu.addMenu(self._recent_projects_menu)
|
||
self._update_recent_menu()
|
||
|
||
# ── Load Last Project on Startup toggle ──
|
||
file_menu.addSeparator()
|
||
self._action_load_last = QAction("Load Last Project on Startup", self)
|
||
self._action_load_last.setCheckable(True)
|
||
self._action_load_last.setChecked(self._settings.value("load_last_on_startup", False, type=bool))
|
||
self._action_load_last.toggled.connect(self._toggle_load_last_project)
|
||
file_menu.addAction(self._action_load_last)
|
||
|
||
# ── View menu (runtime-only, not in the .ui) ──
|
||
view_menu = self.menuBar().addMenu("&View")
|
||
view_menu.addAction("Fit All", self._fit_view)
|
||
view_menu.addAction("Reset View", self._reset_view)
|
||
view_menu.addSeparator()
|
||
|
||
for view_name in ["Isometric", "Top", "Front", "Right", "Back", "Left", "Bottom"]:
|
||
action = QAction(view_name, self)
|
||
action.triggered.connect(
|
||
lambda checked, v=view_name.lower(): self._viewer_3d.set_view(v)
|
||
)
|
||
view_menu.addAction(action)
|
||
|
||
# ── Help menu (runtime-only, not in the .ui) ──
|
||
help_menu = self.menuBar().addMenu("&Help")
|
||
help_menu.addAction("About", self._show_about)
|
||
|
||
def _create_central_widget(self):
|
||
"""Load the compiled UI file and add programmatic custom widgets."""
|
||
self._ui = Ui_fluencyCAD()
|
||
self._ui.setupUi(self)
|
||
|
||
# Keep a reference to the grid for panel-focus management.
|
||
self._grid = self._ui.gridLayout
|
||
|
||
# -- Add programmatic custom widgets to their placeholder locations --
|
||
|
||
# Sketch2DWidget goes in the sketch tab’s QVBoxLayout.
|
||
self._sketch_widget = Sketch2DWidget()
|
||
self._ui.sketch_tab.layout().addWidget(self._sketch_widget)
|
||
|
||
# Viewer3DWidget goes in the gl_box QHBoxLayout.
|
||
self._viewer_3d = Viewer3DWidget()
|
||
self._ui.gl_box.layout().addWidget(self._viewer_3d)
|
||
|
||
# Code editor — use the UI’s textEdit with our custom font.
|
||
self._code_edit = self._ui.textEdit
|
||
self._code_edit.setFont(QFont("Monaco", 10))
|
||
self._code_edit.setPlaceholderText("# Enter Python code here...")
|
||
|
||
# Component buttons (dynamically generated per component, not in UI).
|
||
self._component_box = QWidget()
|
||
self._component_box_layout = QHBoxLayout(self._component_box)
|
||
self._component_box_layout.setAlignment(Qt.AlignLeft)
|
||
self._component_group = QButtonGroup(self)
|
||
self._component_group.setExclusive(True)
|
||
# Add to the Components group box from the UI.
|
||
compo_layout = self._ui.compo_box.layout()
|
||
if compo_layout is None:
|
||
compo_layout = QHBoxLayout(self._ui.compo_box)
|
||
compo_layout.setContentsMargins(0, 0, 0, 0)
|
||
compo_layout.addWidget(self._component_box)
|
||
compo_layout.addStretch()
|
||
|
||
# ── Assembly box (dynamic buttons like component box) ──
|
||
self._assembly_box = QWidget()
|
||
self._assembly_box_layout = QHBoxLayout(self._assembly_box)
|
||
self._assembly_box_layout.setAlignment(Qt.AlignLeft)
|
||
self._assembly_component_group = QButtonGroup(self)
|
||
self._assembly_component_group.setExclusive(True)
|
||
# Add to the Assembly group box from the UI.
|
||
asm_layout = self._ui.assembly_box.layout()
|
||
if asm_layout is None:
|
||
asm_layout = QHBoxLayout(self._ui.assembly_box)
|
||
asm_layout.setContentsMargins(0, 0, 0, 0)
|
||
asm_layout.addWidget(self._assembly_box)
|
||
asm_layout.addStretch()
|
||
|
||
# ── Assembly Move button (programmatic, in assembly_tools) ──
|
||
self._btn_asm_move = QPushButton("Pos")
|
||
self._btn_asm_move.setCheckable(True)
|
||
self._btn_asm_move.setMinimumSize(QSize(50, 50))
|
||
self._btn_asm_move.setMaximumSize(QSize(50, 50))
|
||
self._btn_asm_move.setToolTip("Toggle: click a body in 3D and drag to move the assembly component")
|
||
asm_tools_layout = self._ui.assembly_tools.layout()
|
||
if asm_tools_layout is not None:
|
||
asm_tools_layout.addWidget(self._btn_asm_move, 0, 2, 1, 1)
|
||
|
||
# Panel-focus mode (equal | sketch | viewer).
|
||
self._panel_focus: str = "equal"
|
||
|
||
def _setup_ui_aliases(self):
|
||
"""Create _btn_* aliases pointing to the UI-loaded widgets.
|
||
|
||
The rest of the application references widgets via ``self._btn_*``
|
||
names. This method maps those to the ``pb_*`` / ``pushButton_*``
|
||
names created by the compiled UI file so existing signal connections
|
||
and mode-switching code continues to work unchanged.
|
||
"""
|
||
ui = self._ui
|
||
# ── Workplanes ──
|
||
self._btn_wp_origin = ui.pb_origin_wp
|
||
self._btn_wp_face = ui.pb_origin_face
|
||
self._btn_wp_flip = ui.pb_flip_face
|
||
self._btn_wp_new = ui.pb_wp_new
|
||
self._btn_underlay = ui.pb_underlay
|
||
self._btn_clr_face = ui.pb_clr_face
|
||
self._btn_to_sketch = ui.pb_to_sketch
|
||
# ── Drawing ──
|
||
self._btn_line = ui.pb_linetool
|
||
self._btn_rect = ui.pb_rectool
|
||
self._btn_circle = ui.pb_circtool
|
||
self._btn_slot = ui.pb_slotool
|
||
self._btn_arc = ui.pb_arc_tool
|
||
self._btn_construct = ui.pb_enable_construct
|
||
self._btn_snap = ui.pb_enable_snap
|
||
self._btn_offset = ui.pb_offset_tool
|
||
# ── Constrain ──
|
||
self._btn_con_ptpt = ui.pb_con_ptpt
|
||
self._btn_con_ptline = ui.pb_con_line
|
||
self._btn_con_mid = ui.pb_con_mid
|
||
self._btn_con_perp = ui.pb_con_perp
|
||
self._btn_con_horiz = ui.pb_con_horiz
|
||
self._btn_con_vert = ui.pb_con_vert
|
||
self._btn_con_dist = ui.pb_con_dist
|
||
self._btn_con_sym = ui.pb_con_sym
|
||
# ── Snaps ──
|
||
self._btn_snap_point = ui.pushButton_8
|
||
self._btn_snap_mid = ui.pb_snap_midp
|
||
self._btn_snap_horiz = ui.pb_snap_horiz
|
||
self._btn_snap_vert = ui.pb_snap_vert
|
||
self._btn_snap_angle = ui.pb_snap_angle
|
||
self._btn_snap_grid = ui.pushButton_7
|
||
self._spin_snap_dist = ui.spinbox_snap_distance
|
||
self._spin_angle = ui.spinbox_angle_steps
|
||
# ── Modify ──
|
||
self._btn_extrude = ui.pb_extrdop
|
||
self._btn_cut = ui.pb_cutop
|
||
self._btn_combine = ui.pb_combop
|
||
self._btn_move = ui.pb_moveop
|
||
self._btn_revolve = ui.pb_revop
|
||
self._btn_array = ui.pb_arrayop
|
||
# ── Export ──
|
||
self._btn_export_stl = ui.pushButton_2
|
||
self._btn_export_step = ui.pb_export_step
|
||
self._btn_export_iges = ui.pb_export_iges
|
||
# ── Sketch list tools ──
|
||
self._btn_add_sketch = ui.pb_nw_sktch
|
||
self._btn_edit_sketch = ui.pb_edt_sktch
|
||
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_del_body = ui.pb_del_body
|
||
# ── Component tools ──
|
||
self._btn_new_compo = ui.pb_new_compo
|
||
self._btn_del_compo = ui.pb_del_compo
|
||
# ── Assembly / Connector ──
|
||
self._btn_compo_to_assembly = ui.pb_compo_to_assembly
|
||
self._btn_remove_compo_from_assembly = ui.pb_remove_compo_from_assembly
|
||
self._btn_add_connector = ui.pb_add_connector
|
||
self._btn_add_connector.setCheckable(True)
|
||
self._btn_del_connector = ui.pb_remove_connector
|
||
# ── Code tab ──
|
||
self._btn_apply_code = ui.pb_apply_code
|
||
self._btn_load_code = ui.pushButton_5
|
||
self._btn_save_code = ui.pushButton_4
|
||
self._btn_del_code = ui.pushButton
|
||
# ── List views & tabs ──
|
||
self._sketch_list = ui.sketch_list
|
||
self._body_list = ui.body_list
|
||
self._input_tabs = ui.InputTab
|
||
|
||
def _toggle_panel_focus(self):
|
||
"""Cycle the sketch/viewer split: equal → sketch → viewer → equal.
|
||
|
||
Driven by Spacebar and the Layout button (§_setup_connections).
|
||
"""
|
||
order = ["equal", "sketch", "viewer"]
|
||
try:
|
||
nxt = order[(order.index(self._panel_focus) + 1) % len(order)]
|
||
except (AttributeError, ValueError):
|
||
nxt = "equal"
|
||
self._set_panel_focus(nxt)
|
||
|
||
def _set_panel_focus(self, panel: str):
|
||
"""Set the sketch/viewer column stretches based on the focus mode."""
|
||
if not hasattr(self, "_grid"):
|
||
self._panel_focus = panel
|
||
return
|
||
self._panel_focus = panel
|
||
if panel == "viewer":
|
||
# Viewer 2/3, sketch 1/3 — more room for 3D work, sketch stays visible.
|
||
self._grid.setColumnStretch(1, 1)
|
||
self._grid.setColumnStretch(2, 2)
|
||
elif panel == "sketch":
|
||
# Sketch 2/3, viewer 1/3 — comfortable sketching, 3D stays visible.
|
||
self._grid.setColumnStretch(1, 2)
|
||
self._grid.setColumnStretch(2, 1)
|
||
else: # equal
|
||
self._grid.setColumnStretch(1, 1)
|
||
self._grid.setColumnStretch(2, 1)
|
||
logger.info(f"Panel focus -> {self._panel_focus}")
|
||
|
||
def keyPressEvent(self, event):
|
||
# Spacebar cycles the sketch/viewer split so you can grow the side you're
|
||
# working in without leaving the keyboard.
|
||
if event.key() == Qt.Key_Space:
|
||
self._toggle_panel_focus()
|
||
event.accept()
|
||
return
|
||
super().keyPressEvent(event)
|
||
|
||
def _create_dock_widgets(self):
|
||
pass
|
||
|
||
def _setup_connections(self):
|
||
self._btn_line.clicked.connect(lambda: self._set_sketch_mode("line"))
|
||
self._btn_rect.clicked.connect(lambda: self._set_sketch_mode("rectangle"))
|
||
self._btn_circle.clicked.connect(lambda: self._set_sketch_mode("circle"))
|
||
self._btn_arc.clicked.connect(lambda: self._set_sketch_mode("arc"))
|
||
self._btn_slot.clicked.connect(lambda: self._set_sketch_mode("slot"))
|
||
self._btn_construct.clicked.connect(self._on_construct_change)
|
||
|
||
self._btn_con_ptpt.clicked.connect(lambda: self._set_sketch_mode("constrain_coincident"))
|
||
self._btn_con_ptline.clicked.connect(lambda: self._set_sketch_mode("constrain_ptline"))
|
||
self._btn_con_horiz.clicked.connect(lambda: self._set_sketch_mode("constrain_horizontal"))
|
||
self._btn_con_vert.clicked.connect(lambda: self._set_sketch_mode("constrain_vertical"))
|
||
self._btn_con_mid.clicked.connect(lambda: self._set_sketch_mode("constrain_midpoint"))
|
||
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_snap_point.clicked.connect(
|
||
lambda c: self._sketch_widget.set_snap_mode("point", c)
|
||
)
|
||
self._btn_snap_mid.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("mpoint", c))
|
||
self._btn_snap_horiz.clicked.connect(
|
||
lambda c: self._sketch_widget.set_snap_mode("horiz", c)
|
||
)
|
||
self._btn_snap_vert.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("vert", c))
|
||
self._btn_snap_angle.clicked.connect(
|
||
lambda c: self._sketch_widget.set_snap_mode("angle", c)
|
||
)
|
||
self._btn_snap_grid.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("grid", c))
|
||
|
||
self._spin_snap_dist.valueChanged.connect(self._sketch_widget.set_snap_distance)
|
||
self._spin_angle.valueChanged.connect(self._sketch_widget.set_angle_steps)
|
||
|
||
self._btn_extrude.clicked.connect(self._extrude_sketch)
|
||
self._btn_cut.clicked.connect(self._boolean_cut)
|
||
self._btn_combine.clicked.connect(self._boolean_union)
|
||
self._btn_revolve.clicked.connect(self._revolve_sketch)
|
||
|
||
self._btn_add_sketch.clicked.connect(self._add_sketch_to_component)
|
||
self._btn_edit_sketch.clicked.connect(self._edit_sketch)
|
||
self._btn_del_sketch.clicked.connect(self._delete_sketch)
|
||
self._btn_wp_face.toggled.connect(self._on_face_sketch_toggled)
|
||
self._viewer_3d.facePicked.connect(self._on_face_picked)
|
||
self._viewer_3d.pickFaceCancelled.connect(
|
||
lambda: self._btn_wp_face.setChecked(False)
|
||
)
|
||
|
||
self._btn_new_compo.clicked.connect(self._new_component)
|
||
self._btn_del_compo.clicked.connect(self._delete_component)
|
||
|
||
self._btn_compo_to_assembly.clicked.connect(self._add_component_to_assembly)
|
||
self._btn_remove_compo_from_assembly.clicked.connect(self._remove_component_from_assembly)
|
||
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._viewer_3d.connectorPicked.connect(self._on_connector_picked)
|
||
self._viewer_3d.connectorHover.connect(self._on_connector_hover)
|
||
self._viewer_3d.connectorPickCancelled.connect(
|
||
lambda: self._btn_add_connector.setChecked(False)
|
||
)
|
||
self._viewer_3d.assemblyComponentActivated.connect(
|
||
self._on_assembly_move_activated
|
||
)
|
||
self._viewer_3d.assemblyComponentDragged.connect(
|
||
self._on_assembly_move_dragged
|
||
)
|
||
self._viewer_3d.assemblyMoveFinished.connect(
|
||
self._on_assembly_move_finished
|
||
)
|
||
|
||
self._btn_update_body.clicked.connect(self._redraw_bodies)
|
||
self._btn_del_body.clicked.connect(self._delete_body)
|
||
|
||
self._btn_export_stl.clicked.connect(self._export_stl)
|
||
self._btn_export_step.clicked.connect(self._export_step)
|
||
self._btn_export_iges.clicked.connect(self._export_iges)
|
||
|
||
self._sketch_widget.constrain_done.connect(self._on_constrain_done)
|
||
self._sketch_widget.sketch_updated.connect(self._on_sketch_updated)
|
||
|
||
self._sketch_list.currentItemChanged.connect(self._on_sketch_selected)
|
||
self._body_list.currentItemChanged.connect(self._on_body_list_changed)
|
||
# Per-body visibility toggle: the user clicks the checkbox 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.
|
||
# (itemChanged also fires for selection changes; the handler
|
||
# filters on the check-state role.)
|
||
self._body_list.itemChanged.connect(self._on_body_visibility_changed)
|
||
|
||
self._btn_wp_origin.clicked.connect(self._new_sketch_origin)
|
||
self._btn_wp_new.clicked.connect(self._new_workplane)
|
||
self._btn_wp_flip.clicked.connect(self._flip_workplane)
|
||
# Underlay show/hide, ClrFace, and ToSketch — all stay in sync
|
||
# with the source face state managed by set_source_face /
|
||
# clear_source_face / _project_body_to_active_wp.
|
||
self._btn_underlay.toggled.connect(self._on_underlay_toggled)
|
||
self._btn_clr_face.clicked.connect(self._on_clear_source_face)
|
||
self._btn_to_sketch.clicked.connect(self._on_convert_underlay_to_sketch)
|
||
|
||
# Generic buttons
|
||
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)
|
||
|
||
# Snap toggle
|
||
self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c))
|
||
|
||
def _create_initial_component(self):
|
||
self._new_component()
|
||
|
||
def _create_initial_assembly(self):
|
||
"""Create the initial assembly in the project."""
|
||
self._project.add_assembly()
|
||
logger.info("Created initial assembly")
|
||
|
||
def _set_sketch_mode(self, mode: str):
|
||
self._sketch_widget.set_mode(mode)
|
||
|
||
for btn in [
|
||
self._btn_line,
|
||
self._btn_rect,
|
||
self._btn_circle,
|
||
self._btn_arc,
|
||
self._btn_slot,
|
||
self._btn_con_ptpt,
|
||
self._btn_con_ptline,
|
||
self._btn_con_horiz,
|
||
self._btn_con_vert,
|
||
self._btn_con_mid,
|
||
self._btn_con_perp,
|
||
self._btn_con_dist,
|
||
self._btn_con_sym,
|
||
]:
|
||
btn.setChecked(False)
|
||
|
||
if mode in ["line", "rectangle", "circle", "arc", "slot"]:
|
||
if mode == "line":
|
||
self._btn_line.setChecked(True)
|
||
elif mode == "rectangle":
|
||
self._btn_rect.setChecked(True)
|
||
elif mode == "circle":
|
||
self._btn_circle.setChecked(True)
|
||
elif mode == "arc":
|
||
self._btn_arc.setChecked(True)
|
||
elif mode == "slot":
|
||
self._btn_slot.setChecked(True)
|
||
elif mode.startswith("constrain_"):
|
||
if mode == "constrain_coincident":
|
||
self._btn_con_ptpt.setChecked(True)
|
||
elif mode == "constrain_horizontal":
|
||
self._btn_con_horiz.setChecked(True)
|
||
elif mode == "constrain_vertical":
|
||
self._btn_con_vert.setChecked(True)
|
||
|
||
def _on_construct_change(self, checked):
|
||
self._sketch_widget.set_construct_mode(checked)
|
||
|
||
def _on_constrain_done(self):
|
||
for btn in [
|
||
self._btn_line,
|
||
self._btn_rect,
|
||
self._btn_circle,
|
||
self._btn_arc,
|
||
self._btn_slot,
|
||
self._btn_con_ptpt,
|
||
self._btn_con_ptline,
|
||
self._btn_con_horiz,
|
||
self._btn_con_vert,
|
||
self._btn_con_mid,
|
||
self._btn_con_perp,
|
||
self._btn_con_dist,
|
||
self._btn_con_sym,
|
||
]:
|
||
btn.setChecked(False)
|
||
self._sketch_widget.set_mode(None)
|
||
|
||
def _on_sketch_updated(self):
|
||
pass
|
||
|
||
def _get_active_component_index(self) -> int:
|
||
for i, btn in enumerate(self._component_buttons):
|
||
if btn.isChecked():
|
||
return i
|
||
return 0
|
||
|
||
def _new_component(self):
|
||
logger.info("=== NEW COMPONENT ===")
|
||
comp = self._project.add_component()
|
||
self._current_component = comp
|
||
self._mark_dirty()
|
||
logger.info(f"Created component: {comp.name}")
|
||
|
||
btn = QPushButton(str(len(self._project.components)))
|
||
btn.setCheckable(True)
|
||
btn.setFixedSize(QSize(40, 40))
|
||
btn.clicked.connect(self._on_component_button_clicked)
|
||
btn.setChecked(True)
|
||
|
||
for b in self._component_buttons:
|
||
b.setChecked(False)
|
||
|
||
self._component_buttons.append(btn)
|
||
self._component_group.addButton(btn)
|
||
self._component_box_layout.addWidget(btn)
|
||
|
||
self._refresh_lists()
|
||
logger.info(f"Created component: {comp.name}")
|
||
|
||
def _delete_component(self):
|
||
idx = self._get_active_component_index()
|
||
comp_ids = list(self._project.components.keys())
|
||
if idx < len(comp_ids):
|
||
comp_id = comp_ids[idx]
|
||
del self._project.components[comp_id]
|
||
|
||
if self._component_buttons:
|
||
btn = self._component_buttons.pop(idx)
|
||
self._component_group.removeButton(btn)
|
||
btn.deleteLater()
|
||
|
||
if self._component_buttons:
|
||
self._component_buttons[0].setChecked(True)
|
||
|
||
self._refresh_lists()
|
||
logger.info(f"Deleted component")
|
||
|
||
def _on_component_button_clicked(self):
|
||
idx = self._get_active_component_index()
|
||
comp_ids = list(self._project.components.keys())
|
||
if idx < len(comp_ids):
|
||
self._current_component = self._project.components[comp_ids[idx]]
|
||
self._assembly_view_active = False
|
||
self._refresh_lists()
|
||
self._redraw_bodies()
|
||
|
||
def _refresh_lists(self):
|
||
self._sketch_list.clear()
|
||
self._body_list.clear()
|
||
|
||
if self._current_component:
|
||
for sketch_id, sketch in self._current_component.sketches.items():
|
||
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
|
||
# look up the right body without relying on display text.
|
||
item = QListWidgetItem(body.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"))
|
||
self._body_list.addItem(item)
|
||
|
||
def _redraw_bodies(self):
|
||
self._viewer_3d.clear_scene()
|
||
|
||
if self._current_component:
|
||
for body_id, body in self._current_component.bodies.items():
|
||
if body.geometry:
|
||
logger.debug(f"Redrawing body: {body.name}")
|
||
shape = self._kernel._get_shape(body.geometry)
|
||
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||
logger.info(f"Redraw render object: {body.render_object}")
|
||
|
||
# Re-add workplane visuals after the clear.
|
||
for wp_id, wp in self._current_component.workplanes.items():
|
||
if wp.visible:
|
||
wp.render_object = self._viewer_3d.show_workplane(
|
||
origin=wp.origin,
|
||
normal=wp.normal,
|
||
x_dir=wp.x_dir,
|
||
size=250.0,
|
||
name=f"workplane_{wp.id}",
|
||
)
|
||
|
||
self._viewer_3d.fit_camera()
|
||
|
||
# ────────────────────────────────────────────────────────────────────
|
||
# Assembly methods
|
||
# ────────────────────────────────────────────────────────────────────
|
||
|
||
def _get_assembly(self) -> Optional[Any]:
|
||
"""Get the active assembly from the project."""
|
||
assembly = self._project.get_active_assembly()
|
||
if assembly is None:
|
||
assembly = self._project.add_assembly()
|
||
return assembly
|
||
|
||
def _add_component_to_assembly(self):
|
||
"""Add the currently selected component to the assembly.
|
||
|
||
Creates a new button in the assembly box and stores the
|
||
component instance in the assembly model.
|
||
"""
|
||
if self._current_component is None:
|
||
logger.warning("No active component to add to assembly")
|
||
return
|
||
|
||
assembly = self._get_assembly()
|
||
|
||
# Create an instance of the current component in the assembly.
|
||
ac = assembly.add_component_instance(
|
||
component_id=self._current_component.id,
|
||
name=f"{self._current_component.name}",
|
||
)
|
||
logger.info(
|
||
f"Added component '{self._current_component.name}' "
|
||
f"to assembly '{assembly.name}' (instance={ac.id})"
|
||
)
|
||
|
||
# Create a button for this assembly component.
|
||
instance_num = len(assembly.components)
|
||
label = f"{instance_num}"
|
||
btn = QPushButton(label)
|
||
btn.setCheckable(True)
|
||
btn.setFixedSize(QSize(40, 40))
|
||
btn.setToolTip(f"{ac.name} (instance {list(assembly.components.keys()).index(ac.id) + 1})")
|
||
# Store the assembly component id in the button.
|
||
btn._assembly_component_id = ac.id
|
||
btn.clicked.connect(self._on_assembly_component_clicked)
|
||
|
||
# Uncheck all other assembly buttons, check this one.
|
||
for b in self._assembly_component_buttons:
|
||
b.setChecked(False)
|
||
btn.setChecked(True)
|
||
|
||
self._assembly_component_buttons.append(btn)
|
||
self._assembly_component_group.addButton(btn)
|
||
self._assembly_box_layout.addWidget(btn)
|
||
|
||
# Store the selected id and activate assembly view.
|
||
self._selected_assembly_component_id = ac.id
|
||
self._assembly_view_active = True
|
||
self._mark_dirty()
|
||
|
||
# Show the assembly in the viewer, framing all components.
|
||
self._show_assembly_in_viewer(fit=True)
|
||
|
||
def _remove_component_from_assembly(self):
|
||
"""Remove the currently selected assembly component."""
|
||
assembly = self._get_assembly()
|
||
if not assembly or not assembly.components:
|
||
logger.warning("Assembly is empty, nothing to remove")
|
||
return
|
||
|
||
# Find the active assembly component id from the checked button.
|
||
active_id = self._get_active_assembly_component_id()
|
||
if active_id is None:
|
||
logger.warning("No assembly component selected to remove")
|
||
return
|
||
|
||
# Find the button index for this assembly component.
|
||
idx = -1
|
||
for i, btn in enumerate(self._assembly_component_buttons):
|
||
if getattr(btn, '_assembly_component_id', None) == active_id:
|
||
idx = i
|
||
break
|
||
|
||
if idx >= 0:
|
||
assembly.remove_component_instance(active_id)
|
||
|
||
btn = self._assembly_component_buttons.pop(idx)
|
||
self._assembly_component_group.removeButton(btn)
|
||
btn.deleteLater()
|
||
|
||
# Select the first remaining button if any.
|
||
if self._assembly_component_buttons:
|
||
self._assembly_component_buttons[0].setChecked(True)
|
||
first_id = getattr(self._assembly_component_buttons[0], '_assembly_component_id', None)
|
||
self._selected_assembly_component_id = first_id
|
||
self._assembly_view_active = True
|
||
else:
|
||
self._selected_assembly_component_id = None
|
||
self._assembly_view_active = False
|
||
# Fall back to normal component view.
|
||
self._redraw_bodies()
|
||
return
|
||
|
||
logger.info(f"Removed assembly component instance {active_id}")
|
||
self._mark_dirty()
|
||
self._show_assembly_in_viewer(fit=True)
|
||
|
||
def _get_active_assembly_component_id(self) -> Optional[str]:
|
||
"""Get the assembly component id of the currently checked button."""
|
||
for btn in self._assembly_component_buttons:
|
||
if btn.isChecked():
|
||
return getattr(btn, '_assembly_component_id', None)
|
||
return None
|
||
|
||
def _on_assembly_component_clicked(self):
|
||
"""Handle an assembly component button click.
|
||
|
||
Shows all components from the assembly in the 3D viewer,
|
||
with the clicked component highlighted/selected.
|
||
"""
|
||
# Find which assembly component id was clicked.
|
||
active_id = self._get_active_assembly_component_id()
|
||
if active_id is None:
|
||
return
|
||
|
||
self._selected_assembly_component_id = active_id
|
||
self._assembly_view_active = True
|
||
|
||
self._show_assembly_in_viewer(fit=True)
|
||
|
||
def _apply_transform(self, shape: Any, position, rotation) -> Any:
|
||
"""Apply a position translation and rotation matrix to a shape.
|
||
|
||
Returns a new transformed TopoDS_Shape. If position is zero and
|
||
rotation is identity the original shape is returned unchanged.
|
||
"""
|
||
import numpy as np
|
||
from OCP.gp import gp_Trsf
|
||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||
|
||
pos = np.asarray(position, dtype=float)
|
||
rot = np.asarray(rotation, dtype=float)
|
||
|
||
# Skip if identity.
|
||
if np.allclose(pos, 0.0) and np.allclose(rot, np.eye(3)):
|
||
return shape
|
||
|
||
# Use SetValues for a combined rotation + translation transform.
|
||
# gp_Trsf.SetValues takes 12 values forming a 3x4 matrix:
|
||
# [R11 R12 R13 Tx]
|
||
# [R21 R22 R23 Ty]
|
||
# [R31 R32 R33 Tz]
|
||
trsf = gp_Trsf()
|
||
trsf.SetValues(
|
||
float(rot[0, 0]), float(rot[0, 1]), float(rot[0, 2]), float(pos[0]),
|
||
float(rot[1, 0]), float(rot[1, 1]), float(rot[1, 2]), float(pos[1]),
|
||
float(rot[2, 0]), float(rot[2, 1]), float(rot[2, 2]), float(pos[2]),
|
||
)
|
||
|
||
transformer = BRepBuilderAPI_Transform(shape, trsf, False)
|
||
transformer.Build()
|
||
return transformer.Shape()
|
||
|
||
def _make_connector_marker(self, position: Tuple[float, float, float],
|
||
color: Tuple[float, float, float] = (1.0, 0.3, 0.0)) -> Optional[Any]:
|
||
"""Create a sphere marker for a connector at *position*.
|
||
|
||
Returns the TopoDS_Shape of a sphere, or None on failure.
|
||
"""
|
||
try:
|
||
from OCP.gp import gp_Pnt
|
||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
|
||
sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), 4.0).Shape()
|
||
return sphere
|
||
except Exception as exc:
|
||
logger.debug(f"Failed to create connector marker: {exc}")
|
||
return None
|
||
|
||
def _show_assembly_in_viewer(self, fit: bool = False):
|
||
"""Show all components from the assembly in the 3D viewer.
|
||
|
||
All bodies from all assembly component instances are displayed
|
||
together with their position/rotation transforms applied.
|
||
The component whose button is checked gets a highlight color;
|
||
the rest are shown in a neutral/dimmed color.
|
||
Connector markers (small orange spheres) are also shown.
|
||
|
||
Pass *fit=True* to also frame all visible components with the
|
||
camera (use when switching to assembly view via button clicks;
|
||
omit during drag to avoid camera flicker).
|
||
"""
|
||
assembly = self._get_assembly()
|
||
if not assembly or not assembly.components:
|
||
self._viewer_3d.clear_scene()
|
||
return
|
||
|
||
self._viewer_3d.clear_scene()
|
||
# Reset the render-object cache; it will be rebuilt below.
|
||
self._asm_render_objects.clear()
|
||
|
||
highlight_color = (0.2, 0.6, 1.0) # Bright blue for selected
|
||
dim_color = (0.5, 0.5, 0.5) # Grey for non-selected
|
||
|
||
shown_any = False
|
||
for ac_id, ac in assembly.components.items():
|
||
comp = self._project.get_component_by_id(ac.component_id)
|
||
if comp is None:
|
||
logger.debug(f"Assembly component {ac_id} references missing component {ac.component_id}")
|
||
continue
|
||
|
||
is_selected = (ac_id == self._selected_assembly_component_id)
|
||
color = highlight_color if is_selected else dim_color
|
||
|
||
render_ids: List[str] = []
|
||
for body_id, body in comp.bodies.items():
|
||
if body.geometry:
|
||
try:
|
||
shape = self._kernel._get_shape(body.geometry)
|
||
# Apply component instance transform.
|
||
transformed = self._apply_transform(
|
||
shape, ac.position, ac.rotation
|
||
)
|
||
obj_id = f"asm_{ac_id}_{body_id}"
|
||
render_obj = self._viewer_3d.show_shape(
|
||
transformed,
|
||
color=color,
|
||
name=obj_id,
|
||
)
|
||
render_ids.append(obj_id)
|
||
shown_any = True
|
||
except Exception as exc:
|
||
logger.debug(f"Failed to show body {body_id} in assembly: {exc}")
|
||
|
||
self._asm_render_objects[ac_id] = render_ids
|
||
|
||
# Show connector markers for this instance.
|
||
# Connector positions are stored in component-local coords;
|
||
# transform to world coords for rendering.
|
||
for conn_id, conn in ac.connectors.items():
|
||
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))
|
||
if sphere_shape is not None:
|
||
self._viewer_3d.show_shape(
|
||
sphere_shape,
|
||
color=(1.0, 0.3, 0.0), # Orange
|
||
name=f"conn_{ac_id}_{conn_id}",
|
||
)
|
||
except Exception as exc:
|
||
logger.debug(f"Failed to show connector {conn_id}: {exc}")
|
||
|
||
if shown_any and fit:
|
||
self._viewer_3d.fit_camera()
|
||
|
||
def _update_assembly_component_in_viewer(self, ac_id: str):
|
||
"""Replace only the shapes of a single assembly component in-place.
|
||
|
||
Removes the existing render objects for *ac_id* from the viewer
|
||
and recreates them at the component's current position/rotation.
|
||
Connector markers are also updated to follow the component.
|
||
Other components are left untouched — no scene clear, so the
|
||
camera stays perfectly still.
|
||
"""
|
||
assembly = self._get_assembly()
|
||
ac = assembly.components.get(ac_id) if assembly else None
|
||
if ac is None:
|
||
return
|
||
|
||
comp = self._project.get_component_by_id(ac.component_id)
|
||
if comp is None:
|
||
return
|
||
|
||
# Remove old render objects for this component.
|
||
old_ids = self._asm_render_objects.pop(ac_id, [])
|
||
for oid in old_ids:
|
||
try:
|
||
self._viewer_3d.remove_mesh(oid)
|
||
except Exception:
|
||
pass
|
||
|
||
# Remove old connector markers for this component.
|
||
for conn_id in list(ac.connectors.keys()):
|
||
try:
|
||
self._viewer_3d.remove_mesh(f"conn_{ac_id}_{conn_id}")
|
||
except Exception:
|
||
pass
|
||
|
||
is_selected = (ac_id == self._selected_assembly_component_id)
|
||
color = (0.2, 0.6, 1.0) if is_selected else (0.5, 0.5, 0.5)
|
||
|
||
new_ids: List[str] = []
|
||
for body_id, body in comp.bodies.items():
|
||
if body.geometry:
|
||
try:
|
||
shape = self._kernel._get_shape(body.geometry)
|
||
transformed = self._apply_transform(
|
||
shape, ac.position, ac.rotation
|
||
)
|
||
obj_id = f"asm_{ac_id}_{body_id}"
|
||
self._viewer_3d.show_shape(
|
||
transformed,
|
||
color=color,
|
||
name=obj_id,
|
||
)
|
||
new_ids.append(obj_id)
|
||
except Exception as exc:
|
||
logger.debug(f"Failed to update body {body_id}: {exc}")
|
||
|
||
# Re-add connector markers at updated world positions.
|
||
import numpy as np
|
||
for conn_id, conn in ac.connectors.items():
|
||
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))
|
||
if sphere_shape is not None:
|
||
self._viewer_3d.show_shape(
|
||
sphere_shape,
|
||
color=(1.0, 0.3, 0.0), # Orange
|
||
name=f"conn_{ac_id}_{conn_id}",
|
||
)
|
||
new_ids.append(f"conn_{ac_id}_{conn_id}")
|
||
except Exception as exc:
|
||
logger.debug(f"Failed to update connector {conn_id}: {exc}")
|
||
|
||
self._asm_render_objects[ac_id] = new_ids
|
||
|
||
# ────────────────────────────────────────────────────────────────────
|
||
# Assembly 3D drag-move
|
||
# ────────────────────────────────────────────────────────────────────
|
||
|
||
def _on_assembly_move_toggled(self, checked: bool):
|
||
"""Toggle 3D drag-to-move mode in the viewer.
|
||
|
||
When active, clicking a body in the assembly view and dragging
|
||
moves its assembly component in real-time. Shift+drag moves in Z.
|
||
"""
|
||
if checked and not self._assembly_view_active:
|
||
self._btn_asm_move.setChecked(False)
|
||
QMessageBox.warning(self, "Assembly View",
|
||
"Switch to assembly view first by clicking an assembly component button.")
|
||
return
|
||
self._viewer_3d.set_assembly_move_mode(checked)
|
||
if checked:
|
||
self._viewer_3d.setFocus()
|
||
self._viewer_3d.activateWindow()
|
||
self.setStatusTip("Drag a body to move it; Shift+drag for Z depth")
|
||
else:
|
||
self.setStatusTip("")
|
||
|
||
def _on_assembly_move_activated(self, owner_obj_id: str):
|
||
"""Called when the user clicks a body in move mode.
|
||
|
||
Parse the assembly component id, compute the rigid group it belongs
|
||
to (transitively via mated connectors), and snapshot EVERY member's
|
||
start position so the whole group can translate together during the
|
||
drag. The first-picked component of each mated pair stays as the
|
||
grounded reference frame for the solver; for a pure-translation
|
||
drag that just means we preserve all current relative transforms.
|
||
"""
|
||
import numpy as np
|
||
|
||
ac_id = self._parse_ac_id(owner_obj_id)
|
||
if ac_id is None:
|
||
return
|
||
|
||
assembly = self._get_assembly()
|
||
ac = assembly.components.get(ac_id)
|
||
if ac is None:
|
||
return
|
||
|
||
self._asm_move_ac_id = ac_id
|
||
# Rigid group membership (BFS over mated-connector connections).
|
||
group_ids = assembly.get_rigid_group(ac_id)
|
||
self._asm_move_group_ids = group_ids
|
||
self._asm_move_group_start = {}
|
||
for gid in group_ids:
|
||
g_ac = assembly.components.get(gid)
|
||
if g_ac is not None:
|
||
self._asm_move_group_start[gid] = np.array(g_ac.position, dtype=float)
|
||
# Keep the legacy single-component start for backwards compatibility.
|
||
self._asm_move_start_pos = np.array(ac.position, dtype=float)
|
||
|
||
def _on_assembly_move_dragged(self, owner_obj_id: str, dx: float, dy: float, dz: float):
|
||
"""Propagate a drag move across the entire rigid group, in-place.
|
||
|
||
Every component in the dragged rigid group receives the SAME world
|
||
translation delta (relative to its own start position), so the mated
|
||
relative transforms are preserved exactly and SolveSpace's solved
|
||
alignment stays valid throughout the drag. Each member is updated
|
||
in-place via ``_update_assembly_component_in_viewer`` so the camera
|
||
never flickers.
|
||
"""
|
||
if self._asm_move_ac_id is None or self._asm_move_start_pos is None:
|
||
return
|
||
|
||
ac_id = self._asm_move_ac_id
|
||
assembly = self._get_assembly()
|
||
ac = assembly.components.get(ac_id)
|
||
if ac is None:
|
||
return
|
||
|
||
import numpy as np
|
||
delta = np.array([dx, dy, dz], dtype=float)
|
||
# Propagate the same delta to every rigid-group member.
|
||
group_ids = self._asm_move_group_ids or [ac_id]
|
||
for gid in group_ids:
|
||
start = self._asm_move_group_start.get(gid)
|
||
if start is None:
|
||
continue
|
||
g_ac = assembly.components.get(gid)
|
||
if g_ac is None:
|
||
continue
|
||
g_ac.position = start + delta
|
||
# Update only this component's shapes — no scene clear.
|
||
self._update_assembly_component_in_viewer(gid)
|
||
|
||
def _on_assembly_move_finished(self, owner_obj_id: str):
|
||
"""Finalize the drag move."""
|
||
if self._asm_move_ac_id is not None:
|
||
members = len(self._asm_move_group_ids) if self._asm_move_group_ids else 1
|
||
logger.info(
|
||
f"Moved assembly rigid group led by {self._asm_move_ac_id} "
|
||
f"({members} member(s)) to final position"
|
||
)
|
||
self._mark_dirty()
|
||
self._asm_move_ac_id = None
|
||
self._asm_move_start_pos = None
|
||
self._asm_move_group_start = {}
|
||
self._asm_move_group_ids = []
|
||
|
||
# ────────────────────────────────────────────────────────────────────
|
||
# Connector methods — two-click selection + preview dialog
|
||
# ────────────────────────────────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _parse_ac_id(owner_obj_id: str) -> Optional[str]:
|
||
"""Extract the assembly component id from a renderer owner_obj_id.
|
||
|
||
Format: asm_{ac_id}_{body_id}
|
||
"""
|
||
if not owner_obj_id or not owner_obj_id.startswith("asm_"):
|
||
return None
|
||
parts = owner_obj_id.split("_")
|
||
if len(parts) >= 3:
|
||
return parts[1]
|
||
return owner_obj_id[4:]
|
||
|
||
def _on_start_connector_placement(self, checked: bool):
|
||
"""Toggle connector pick mode.
|
||
|
||
First click selects the first component's connection entity.
|
||
Second click selects the second component and triggers SolveSpace alignment.
|
||
"""
|
||
if not self._assembly_view_active:
|
||
self._btn_add_connector.setChecked(False)
|
||
QMessageBox.warning(self, "Assembly View",
|
||
"Switch to assembly view first by clicking an assembly component button.")
|
||
return
|
||
|
||
# Reset any in-progress two-click state.
|
||
self._connector_first_pick = None
|
||
self._connector_second_ac_id = None
|
||
self._connector_align_pos = None
|
||
|
||
self._viewer_3d.set_connector_pick_mode(checked)
|
||
if checked:
|
||
self._viewer_3d.setFocus()
|
||
self._viewer_3d.activateWindow()
|
||
self.setStatusTip("Click on the first component's connection point/face/edge/hole")
|
||
else:
|
||
self.setStatusTip("")
|
||
|
||
def _on_connector_hover(self, info) -> None:
|
||
"""Show entity-type feedback in the status bar during connector pick.
|
||
|
||
The gizmo itself is drawn by the viewer; this just reports what
|
||
entity is under the cursor so the user knows what they will snap to.
|
||
"""
|
||
if info is None:
|
||
self.statusBar().showMessage("Move over a face / edge / hole / vertex to snap")
|
||
return
|
||
entity_type = info.get("type", "")
|
||
feature_type = info.get("feature_type", "")
|
||
suggestion = info.get("suggestion", "")
|
||
names = {
|
||
"planar_face": "Face",
|
||
"cylindrical_face": "Hole",
|
||
"edge": "Edge",
|
||
"vertex": "Vertex",
|
||
}
|
||
name = names.get(entity_type, names.get(feature_type, "Entity"))
|
||
ac_id = self._parse_ac_id(info.get("owner_obj_id", ""))
|
||
comp_name = ""
|
||
if ac_id is not None:
|
||
assembly = self._get_assembly()
|
||
ac = assembly.components.get(ac_id) if assembly else None
|
||
if ac is not None:
|
||
comp_name = f" on {ac.name}"
|
||
# Show suggestion if available, otherwise generic message.
|
||
if suggestion:
|
||
self.statusBar().showMessage(f"{name}{comp_name}: {suggestion} — click to pick")
|
||
else:
|
||
self.statusBar().showMessage(f"Snap target: {name}{comp_name} — click to pick")
|
||
|
||
def _on_connector_picked(self, origin, normal, x_dir, entity_type, raw_shape, owner_obj_id):
|
||
"""Handle a connector entity pick — first or second click.
|
||
|
||
Snaps to faces, cylindrical holes, edges, or vertices.
|
||
Stores connector in component-local coordinates so it stays
|
||
valid when the component is moved by the solver.
|
||
"""
|
||
import numpy as np
|
||
|
||
ac_id = self._parse_ac_id(owner_obj_id)
|
||
if ac_id is None:
|
||
QMessageBox.warning(self, "Pick Error",
|
||
"Could not identify which assembly component was clicked.")
|
||
return
|
||
|
||
assembly = self._get_assembly()
|
||
ac = assembly.components.get(ac_id)
|
||
if ac is None:
|
||
QMessageBox.warning(self, "Pick Error",
|
||
"The clicked component was not found in the assembly.")
|
||
return
|
||
|
||
# Convert world-space connector to component-local coordinates.
|
||
# p_local = R^T @ (p_world - P)
|
||
pos_world = np.array(origin, dtype=float)
|
||
rot = ac.rotation
|
||
pos_local = rot.T @ (pos_world - ac.position)
|
||
|
||
n_world = np.array(normal, dtype=float)
|
||
n_local = rot.T @ n_world
|
||
n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
|
||
|
||
x_world = np.array(x_dir, dtype=float) if x_dir else np.array([1.0, 0.0, 0.0])
|
||
x_local = rot.T @ x_world
|
||
x_local = x_local / max(np.linalg.norm(x_local), 1e-12)
|
||
|
||
# ── First pick ──
|
||
if self._connector_first_pick is None:
|
||
self._connector_first_pick = {
|
||
"ac_id": ac_id,
|
||
"origin_local": tuple(pos_local),
|
||
"normal_local": tuple(n_local),
|
||
"x_dir_local": tuple(x_local),
|
||
"origin_world": tuple(origin),
|
||
"normal_world": tuple(normal),
|
||
"entity_type": entity_type,
|
||
"owner_obj_id": owner_obj_id,
|
||
}
|
||
# Highlight the first face if planar.
|
||
if entity_type in ("planar_face", "cylindrical_face"):
|
||
self._viewer_3d.highlight_face(raw_shape)
|
||
self.setStatusTip("Now click on the second component's connection point/face/edge/hole")
|
||
logger.info(f"Connector first pick: {ac.name} at {origin} ({entity_type})")
|
||
return
|
||
|
||
# ── Second pick ──
|
||
first = self._connector_first_pick
|
||
|
||
# Don't allow picking the same component twice.
|
||
if ac_id == first["ac_id"]:
|
||
QMessageBox.warning(self, "Same Component",
|
||
"Pick a different component for the second connection point.")
|
||
return
|
||
|
||
self._connector_second_ac_id = ac_id
|
||
self._viewer_3d.clear_face_highlight()
|
||
# Keep gizmo visible until next hover so user sees what was picked.
|
||
self._viewer_3d.set_connector_pick_mode(False, clear_gizmo=False)
|
||
self._btn_add_connector.setChecked(False)
|
||
self.setStatusTip("")
|
||
|
||
logger.info(f"Connector second pick: {ac.name} at {origin} ({entity_type})")
|
||
|
||
# Build connector records (local coords).
|
||
second_pick = {
|
||
"ac_id": ac_id,
|
||
"origin_local": tuple(pos_local),
|
||
"normal_local": tuple(n_local),
|
||
"x_dir_local": tuple(x_local),
|
||
"origin_world": tuple(origin),
|
||
"normal_world": tuple(normal),
|
||
"entity_type": entity_type,
|
||
"owner_obj_id": owner_obj_id,
|
||
}
|
||
|
||
# SolveSpace alignment: move appropriate component so its connector
|
||
# coincides with the anchor's connector. The chronologically first
|
||
# component added to the assembly is the global anchor — it stays
|
||
# locked in world space. All solving keeps it fixed.
|
||
first_ac = assembly.components.get(first["ac_id"])
|
||
second_ac = ac
|
||
anchor_ac_id = next(iter(assembly.components.keys()))
|
||
|
||
# Compute the world target normal (from the anchor's connector).
|
||
anchor_pick_source = first if anchor_ac_id == first["ac_id"] else second_pick
|
||
target_pos = np.array(anchor_pick_source["origin_world"], dtype=float)
|
||
target_normal = np.array(anchor_pick_source["normal_world"], dtype=float)
|
||
target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
|
||
|
||
solved = self._solve_assembly_alignment(
|
||
first_ac=first_ac,
|
||
second_ac=second_ac,
|
||
first_pick=first,
|
||
second_pick=second_pick,
|
||
anchor_component_id=anchor_ac_id,
|
||
)
|
||
|
||
if solved is None:
|
||
QMessageBox.warning(self, "Solver Error",
|
||
"SolveSpace could not align the components.")
|
||
self._connector_first_pick = None
|
||
self._connector_second_ac_id = None
|
||
self._show_assembly_in_viewer(fit=True)
|
||
return
|
||
|
||
# Apply solved transform to the component the solver actually moved.
|
||
moved_ac_id = solved["moved_ac_id"]
|
||
moved_ac = assembly.components.get(moved_ac_id)
|
||
if moved_ac is not None:
|
||
moved_ac.position = solved["position"]
|
||
moved_ac.rotation = solved["rotation"]
|
||
|
||
# Chain auto-offset: if the anchor already has a rigid group (>1
|
||
# member), auto-offset the moved component along the connector
|
||
# normal so it doesn't stack at the same point.
|
||
if assembly.get_group_size(anchor_ac_id) > 1 and moved_ac is not None:
|
||
auto_offset = 50.0
|
||
moved_ac.position = moved_ac.position + target_normal * auto_offset
|
||
|
||
# Show dialog with live preview (rotation offset along normal).
|
||
moved_comp_before_dialog = assembly.components.get(moved_ac_id)
|
||
rotation, offset, flip = self._show_connector_dialog_with_preview(
|
||
first_ac=first_ac,
|
||
second_ac=second_ac,
|
||
first_pick=first,
|
||
second_pick=second_pick,
|
||
solved=solved,
|
||
mover_ac=moved_ac,
|
||
)
|
||
|
||
if rotation is None:
|
||
# User cancelled — restore original position.
|
||
if moved_comp_before_dialog is not None:
|
||
moved_comp_before_dialog.position = np.array(solved["original_position"], dtype=float)
|
||
moved_comp_before_dialog.rotation = np.array(solved["original_rotation"], dtype=float)
|
||
self._connector_first_pick = None
|
||
self._connector_second_ac_id = None
|
||
self._show_assembly_in_viewer(fit=True)
|
||
return
|
||
|
||
# Apply dialog adjustments (rotation + offset + flip).
|
||
import numpy as np
|
||
# Build rotation matrix: rotate second connector normal around
|
||
# the target normal axis by rotation degrees.
|
||
angle_rad = np.radians(rotation)
|
||
# Rodrigues' rotation formula around target_normal.
|
||
k = target_normal
|
||
K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
|
||
R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
|
||
|
||
# Apply dialog adjustments to the MOVED component.
|
||
if moved_ac is not None:
|
||
moved_ac.rotation = R_axis @ moved_ac.rotation
|
||
flip_sign = -1.0 if flip else 1.0
|
||
moved_ac.position = moved_ac.position + flip_sign * target_normal * offset
|
||
|
||
# Determine which pick is the anchor and which is the mover.
|
||
anchor_pick = first if anchor_ac_id == first["ac_id"] else second_pick
|
||
mover_pick = second_pick if anchor_ac_id == first["ac_id"] else first
|
||
anchor_comp = assembly.components.get(anchor_ac_id)
|
||
mover_comp = assembly.components.get(mover_pick["ac_id"])
|
||
|
||
# Create connectors on both sides and link them as a mated pair.
|
||
conn_a = None
|
||
conn_m = None
|
||
if anchor_comp:
|
||
conn_a = anchor_comp.add_connector(
|
||
position=anchor_pick["origin_local"],
|
||
normal=anchor_pick["normal_local"],
|
||
x_dir=anchor_pick["x_dir_local"],
|
||
source_obj_id=anchor_pick["owner_obj_id"],
|
||
name=f"Conn {anchor_pick['entity_type']} anchor",
|
||
)
|
||
conn_a.axis_rotation = rotation
|
||
conn_a.offset = offset
|
||
conn_a.is_grounded = True
|
||
|
||
if mover_comp:
|
||
conn_m = mover_comp.add_connector(
|
||
position=mover_pick["origin_local"],
|
||
normal=mover_pick["normal_local"],
|
||
x_dir=mover_pick["x_dir_local"],
|
||
source_obj_id=mover_pick["owner_obj_id"],
|
||
name=f"Conn {mover_pick['entity_type']} mover",
|
||
)
|
||
conn_m.axis_rotation = rotation
|
||
conn_m.offset = offset
|
||
|
||
# Cross-link the partners and register the pair on the assembly graph.
|
||
if conn_a is not None and conn_m is not None:
|
||
conn_a.partner_ac_id = mover_comp.id if mover_comp else ""
|
||
conn_a.partner_connector_id = conn_m.id
|
||
conn_m.partner_ac_id = anchor_comp.id if anchor_comp else ""
|
||
conn_m.partner_connector_id = conn_a.id
|
||
assembly.add_connection(anchor_ac_id, moved_ac_id)
|
||
|
||
logger.info(f"Connected: anchor={anchor_ac_id} ↔ moved={moved_ac_id}, "
|
||
f"rotation={rotation}°, offset={offset}mm, flip={flip}")
|
||
self._connector_first_pick = None
|
||
self._connector_second_ac_id = None
|
||
self._mark_dirty()
|
||
self._show_assembly_in_viewer(fit=True)
|
||
|
||
@staticmethod
|
||
def _rotation_between_vectors(a, b):
|
||
"""Return a 3×3 rotation that maps vector *a* onto vector *b*.
|
||
|
||
Handles the two degenerate cases that plain Rodrigues' formula gets
|
||
wrong when the cross-product axis collapses to zero:
|
||
|
||
* ``a ≈ b`` → identity (no rotation needed).
|
||
* ``a ≈ -b`` → a 180° rotation about any axis orthogonal to *a*
|
||
(picked by a stable reference-vector projection).
|
||
|
||
Vectors are internally normalized so callers may pass non-unit input.
|
||
"""
|
||
import numpy as _np
|
||
import math as _math
|
||
a = _np.asarray(a, dtype=float)
|
||
b = _np.asarray(b, dtype=float)
|
||
an = _np.linalg.norm(a); bn = _np.linalg.norm(b)
|
||
if an < 1e-12 or bn < 1e-12:
|
||
return _np.eye(3)
|
||
a = a / an; b = b / bn
|
||
dot = float(_np.dot(a, b))
|
||
cross = _np.cross(a, b)
|
||
cross_norm = _np.linalg.norm(cross)
|
||
if cross_norm < 1e-9:
|
||
if dot > 0.0:
|
||
# Already aligned.
|
||
return _np.eye(3)
|
||
# Anti-parallel: 180° about an axis orthogonal to *a*.
|
||
ref = _np.array([1.0, 0.0, 0.0]) if abs(a[0]) < 0.9 else _np.array([0.0, 1.0, 0.0])
|
||
axis = ref - a * _np.dot(ref, a)
|
||
axis = axis / max(_np.linalg.norm(axis), 1e-12)
|
||
K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
|
||
# sin(180°)=0, 1-cos(180°)=2 → R = I + 2 (K @ K)
|
||
return _np.eye(3) + 2.0 * (K @ K)
|
||
axis = cross / cross_norm
|
||
angle = _math.acos(max(-1.0, min(1.0, dot)))
|
||
K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
|
||
return _np.eye(3) + _np.sin(angle) * K + (1.0 - _np.cos(angle)) * (K @ K)
|
||
|
||
def _solve_assembly_alignment(
|
||
self,
|
||
first_ac: Any,
|
||
second_ac: Any,
|
||
first_pick: Dict[str, Any],
|
||
second_pick: Dict[str, Any],
|
||
anchor_component_id: Optional[str] = None,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Use SolveSpace to align the second component to the first.
|
||
|
||
The anchor component (either ``anchor_component_id`` or, failing that,
|
||
the ``first_ac``) is treated as fixed (grounded). The solver moves
|
||
the *other* component so its connector coincides with the anchor's
|
||
connector (position + normal alignment).
|
||
|
||
Returns a dict with:
|
||
* ``position`` — new world position for the moved component.
|
||
* ``rotation`` — new 3×3 rotation matrix for the moved component.
|
||
* ``moved_ac_id`` — which component was moved.
|
||
* ``original_position`` / ``original_rotation`` — for cancellation.
|
||
"""
|
||
import numpy as np
|
||
try:
|
||
from python_solvespace import SolverSystem, ResultFlag, Entity
|
||
except ImportError:
|
||
logger.warning("python_solvespace not available, falling back to direct alignment")
|
||
return self._align_direct(first_ac, second_ac, first_pick, second_pick,
|
||
anchor_component_id=anchor_component_id)
|
||
|
||
# ── Determine anchor and mover ──
|
||
# The anchor component stays locked. Prefer anchor_component_id
|
||
# (the first-added component); fall back to first_ac (the first click).
|
||
assembly = self._get_assembly()
|
||
if anchor_component_id:
|
||
anchor_ac = assembly.components.get(anchor_component_id) if assembly else None
|
||
else:
|
||
anchor_ac = first_ac
|
||
if anchor_ac is None:
|
||
anchor_ac = first_ac
|
||
|
||
# The mover is whichever of first_ac / second_ac is NOT the anchor.
|
||
if second_ac.id == anchor_ac.id:
|
||
mover_ac = first_ac
|
||
mover_pick = first_pick
|
||
anchor_pick = second_pick
|
||
else:
|
||
mover_ac = second_ac
|
||
mover_pick = second_pick
|
||
anchor_pick = first_pick
|
||
|
||
# Save original transform for cancellation.
|
||
orig_pos = np.array(mover_ac.position, dtype=float)
|
||
orig_rot = np.array(mover_ac.rotation, dtype=float)
|
||
|
||
# World positions of anchor connector (grounded).
|
||
a_world = np.array(anchor_pick["origin_world"], dtype=float)
|
||
n_anchor = np.array(anchor_pick["normal_world"], dtype=float)
|
||
n_anchor = n_anchor / max(np.linalg.norm(n_anchor), 1e-12)
|
||
|
||
# Local positions of mover connector (solved).
|
||
m_local = np.array(mover_pick["origin_local"], dtype=float)
|
||
n_local = np.array(mover_pick["normal_local"], dtype=float)
|
||
n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
|
||
|
||
# Build solver.
|
||
#
|
||
# IMPORTANT: SolveSpace's SLVS_C_PARALLEL and SLVS_C_SAME_ORIENTATION
|
||
# both generate multi-equation residuals that trigger a hard C-level
|
||
# assertion in this python_solvespace build's Newton iterator
|
||
# ("Expected constraint to generate a single equation"), aborting the
|
||
# whole process. We therefore avoid line-parallel / orientation
|
||
# constraints entirely and instead drive BOTH translation AND axis
|
||
# alignment with a pair of coincident point constraints:
|
||
#
|
||
# * coincident(pt_anchor, pt_mover) — forces the connector points
|
||
# together (3 trans DOF)
|
||
# * coincident(pt_anchor_tip, tip_mover) — pins the mover's axis
|
||
# tip onto the anchor's
|
||
# normal line (2 rot DOF)
|
||
#
|
||
# That's 6 single-equation-coincident residuals against 6 free point
|
||
# parameters — a well-posed 0-DOF system — so it converges cleanly.
|
||
# The remaining free rotation around the axis is left for the
|
||
# rotation_spinner in the dialog.
|
||
sys = SolverSystem()
|
||
|
||
# Anchor (grounded) reference frame.
|
||
pt_anchor = sys.add_point_3d(float(a_world[0]), float(a_world[1]), float(a_world[2]))
|
||
sys.dragged(pt_anchor, Entity.FREE_IN_3D)
|
||
pt_anchor_tip = sys.add_point_3d(
|
||
float(a_world[0] + n_anchor[0]),
|
||
float(a_world[1] + n_anchor[1]),
|
||
float(a_world[2] + n_anchor[2]),
|
||
)
|
||
sys.dragged(pt_anchor_tip, Entity.FREE_IN_3D)
|
||
|
||
# Mover (free) points, seeded near its current world connector.
|
||
m_world_current = orig_pos + orig_rot @ m_local
|
||
pt_mover = sys.add_point_3d(
|
||
float(m_world_current[0]), float(m_world_current[1]), float(m_world_current[2])
|
||
)
|
||
n_world_current = orig_rot @ n_local
|
||
tip_mover = sys.add_point_3d(
|
||
float(m_world_current[0] + n_world_current[0]),
|
||
float(m_world_current[1] + n_world_current[1]),
|
||
float(m_world_current[2] + n_world_current[2]),
|
||
)
|
||
|
||
# Constraints: pivot coincidence + axis-tip coincidence.
|
||
sys.coincident(pt_anchor, pt_mover, Entity.FREE_IN_3D)
|
||
sys.coincident(pt_anchor_tip, tip_mover, Entity.FREE_IN_3D)
|
||
|
||
# Solve.
|
||
result = sys.solve()
|
||
if result != ResultFlag.OKAY:
|
||
logger.warning(f"SolveSpace solve failed: {result}")
|
||
return self._align_direct(first_ac, second_ac, first_pick, second_pick,
|
||
anchor_component_id=anchor_component_id)
|
||
|
||
# Extract solved positions.
|
||
p_solved = np.array(sys.params(pt_mover.params), dtype=float)
|
||
tip_solved = np.array(sys.params(tip_mover.params), dtype=float)
|
||
n_solved = tip_solved - p_solved
|
||
n_solved = n_solved / max(np.linalg.norm(n_solved), 1e-12)
|
||
|
||
# Compute the new component transform.
|
||
R_align = self._rotation_between_vectors(n_local, n_solved)
|
||
new_rot = R_align @ orig_rot
|
||
new_pos = p_solved - new_rot @ m_local
|
||
|
||
return {
|
||
"position": new_pos,
|
||
"rotation": new_rot,
|
||
"moved_ac_id": mover_ac.id,
|
||
"original_position": orig_pos,
|
||
"original_rotation": orig_rot,
|
||
}
|
||
|
||
def _align_direct(
|
||
self,
|
||
first_ac: Any,
|
||
second_ac: Any,
|
||
first_pick: Dict[str, Any],
|
||
second_pick: Dict[str, Any],
|
||
anchor_component_id: Optional[str] = None,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Direct geometric alignment (fallback when SolveSpace unavailable).
|
||
|
||
Moves the non-anchor component so its connector coincides with the
|
||
anchor's connector.
|
||
"""
|
||
import numpy as np
|
||
|
||
# ── Determine anchor and mover ──
|
||
assembly = self._get_assembly()
|
||
if anchor_component_id:
|
||
anchor_ac = assembly.components.get(anchor_component_id) if assembly else None
|
||
else:
|
||
anchor_ac = first_ac
|
||
if anchor_ac is None:
|
||
anchor_ac = first_ac
|
||
|
||
if second_ac.id == anchor_ac.id:
|
||
mover_ac = first_ac
|
||
mover_pick = first_pick
|
||
anchor_pick = second_pick
|
||
else:
|
||
mover_ac = second_ac
|
||
mover_pick = second_pick
|
||
anchor_pick = first_pick
|
||
|
||
orig_pos = np.array(mover_ac.position, dtype=float)
|
||
orig_rot = np.array(mover_ac.rotation, dtype=float)
|
||
|
||
# World position of the anchor connector (locked target).
|
||
a_world = np.array(anchor_pick["origin_world"], dtype=float)
|
||
n_anchor = np.array(anchor_pick["normal_world"], dtype=float)
|
||
n_anchor = n_anchor / max(np.linalg.norm(n_anchor), 1e-12)
|
||
|
||
# Mover's connector in local coords.
|
||
m_local = np.array(mover_pick["origin_local"], dtype=float)
|
||
n_local = np.array(mover_pick["normal_local"], dtype=float)
|
||
n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
|
||
|
||
# Align mover's normal to anchor's normal.
|
||
R_align = self._rotation_between_vectors(n_local, n_anchor)
|
||
new_rot = R_align @ orig_rot
|
||
new_pos = a_world - new_rot @ m_local
|
||
|
||
return {
|
||
"position": new_pos,
|
||
"rotation": new_rot,
|
||
"moved_ac_id": mover_ac.id,
|
||
"original_position": orig_pos,
|
||
"original_rotation": orig_rot,
|
||
}
|
||
|
||
def _show_connector_dialog_with_preview(
|
||
self,
|
||
first_ac: Any,
|
||
second_ac: Any,
|
||
first_pick: Dict[str, Any],
|
||
second_pick: Dict[str, Any],
|
||
solved: Dict[str, Any],
|
||
mover_ac: Any = None,
|
||
) -> Tuple[Optional[float], Optional[float], bool]:
|
||
"""Show connector dialog with live 3D preview of the alignment.
|
||
|
||
Returns (rotation_degrees, offset_mm, flip) or (None, None, False) if cancelled.
|
||
"""
|
||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
|
||
QLabel, QDoubleSpinBox, QPushButton,
|
||
QFrame, QCheckBox)
|
||
|
||
if second_ac is None:
|
||
return (None, None, False)
|
||
# The component to preview adjustments on — defaults to second_ac
|
||
# but can be overridden via mover_ac (for anchor-aware solving).
|
||
preview_target = mover_ac if mover_ac is not None else second_ac
|
||
|
||
dialog = QDialog(self)
|
||
dialog.setWindowTitle("Connector — Connection Properties")
|
||
dialog.setMinimumWidth(340)
|
||
|
||
layout = QVBoxLayout(dialog)
|
||
|
||
entity_names = {
|
||
"planar_face": "Face",
|
||
"cylindrical_face": "Hole",
|
||
"edge": "Edge",
|
||
"vertex": "Vertex",
|
||
}
|
||
t1 = entity_names.get(first_pick.get("entity_type", ""), "Entity")
|
||
t2 = entity_names.get(second_pick.get("entity_type", ""), "Entity")
|
||
layout.addWidget(QLabel(f"<b>{t1}</b> on {first_ac.name} → <b>{t2}</b> on {second_ac.name}"))
|
||
layout.addWidget(QLabel("Adjust the connection:"))
|
||
|
||
# Rotation around normal axis.
|
||
rot_layout = QHBoxLayout()
|
||
rot_layout.addWidget(QLabel("Rotation around axis (°):"))
|
||
rotation_spin = QDoubleSpinBox()
|
||
rotation_spin.setDecimals(1)
|
||
rotation_spin.setRange(-360, 360)
|
||
rotation_spin.setValue(0.0)
|
||
rotation_spin.setSuffix("°")
|
||
rot_layout.addWidget(rotation_spin)
|
||
layout.addLayout(rot_layout)
|
||
|
||
# Offset along normal.
|
||
off_layout = QHBoxLayout()
|
||
off_layout.addWidget(QLabel("Offset along normal (mm):"))
|
||
offset_spin = QDoubleSpinBox()
|
||
offset_spin.setDecimals(2)
|
||
offset_spin.setRange(-10000, 10000)
|
||
offset_spin.setValue(0.0)
|
||
off_layout.addWidget(offset_spin)
|
||
layout.addLayout(off_layout)
|
||
|
||
# Flip alignment direction.
|
||
flip_check = QCheckBox("Flip connection direction (normals opposed)")
|
||
flip_check.setChecked(False)
|
||
layout.addWidget(flip_check)
|
||
|
||
line = QFrame()
|
||
line.setFrameShape(QFrame.HLine)
|
||
layout.addWidget(line)
|
||
|
||
btn_layout = QHBoxLayout()
|
||
ok_btn = QPushButton("Connect")
|
||
cancel_btn = QPushButton("Cancel")
|
||
btn_layout.addWidget(ok_btn)
|
||
btn_layout.addWidget(cancel_btn)
|
||
layout.addLayout(btn_layout)
|
||
|
||
import numpy as np
|
||
target_normal = np.array(first_pick["normal_world"], dtype=float)
|
||
target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
|
||
|
||
# ── Live preview callback ──
|
||
def _update_preview(*args):
|
||
rot_deg = rotation_spin.value()
|
||
off = offset_spin.value()
|
||
flip = flip_check.isChecked()
|
||
|
||
# Start from solved transform.
|
||
base_pos = np.array(solved["position"], dtype=float)
|
||
base_rot = np.array(solved["rotation"], dtype=float)
|
||
|
||
# Apply axis rotation around target_normal.
|
||
angle_rad = np.radians(rot_deg)
|
||
k = target_normal
|
||
K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
|
||
R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
|
||
preview_rot = R_axis @ base_rot
|
||
|
||
# Apply offset (with flip).
|
||
flip_sign = -1.0 if flip else 1.0
|
||
preview_pos = base_pos + flip_sign * target_normal * off
|
||
|
||
preview_target.position = preview_pos
|
||
preview_target.rotation = preview_rot
|
||
self._show_assembly_in_viewer() # no fit — keep camera steady
|
||
|
||
rotation_spin.valueChanged.connect(_update_preview)
|
||
offset_spin.valueChanged.connect(_update_preview)
|
||
flip_check.toggled.connect(_update_preview)
|
||
|
||
# Initial preview.
|
||
_update_preview()
|
||
|
||
ok_btn.clicked.connect(dialog.accept)
|
||
cancel_btn.clicked.connect(dialog.reject)
|
||
|
||
if dialog.exec():
|
||
return (rotation_spin.value(), offset_spin.value(), flip_check.isChecked())
|
||
return (None, None, False)
|
||
|
||
def _on_delete_connector(self):
|
||
"""Delete the connector nearest to the selected assembly component."""
|
||
active_id = self._get_active_assembly_component_id()
|
||
if active_id is None:
|
||
QMessageBox.warning(self, "No Selection",
|
||
"Select an assembly component first")
|
||
return
|
||
|
||
assembly = self._get_assembly()
|
||
ac = assembly.components.get(active_id)
|
||
if ac is None or not ac.connectors:
|
||
QMessageBox.information(self, "No Connectors",
|
||
"This component has no connectors to remove.")
|
||
return
|
||
|
||
# List connectors in a simple choice dialog.
|
||
conn_names = list(ac.connectors.keys())
|
||
conn_labels = [f"{c.name} at {c.position}" for c in ac.connectors.values()]
|
||
|
||
from PySide6.QtWidgets import QInputDialog
|
||
label, ok = QInputDialog.getItem(
|
||
self, "Remove Connector", "Select connector:", conn_labels, 0, False
|
||
)
|
||
if ok and label:
|
||
idx = conn_labels.index(label)
|
||
conn_id = conn_names[idx]
|
||
conn = ac.connectors.get(conn_id)
|
||
# Un-partner the mate and drop the rigid-group edge so stale
|
||
# connections don't linger in the BFS graph.
|
||
if conn is not None:
|
||
partner_ac_id = conn.partner_ac_id
|
||
partner_conn_id = conn.partner_connector_id
|
||
if partner_ac_id is not None and partner_conn_id is not None:
|
||
partner_ac = assembly.components.get(partner_ac_id)
|
||
if partner_ac is not None and partner_conn_id in partner_ac.connectors:
|
||
pc = partner_ac.connectors[partner_conn_id]
|
||
pc.partner_ac_id = None
|
||
pc.partner_connector_id = None
|
||
pc.is_grounded = False
|
||
# Remove the connection edge either side references this pair.
|
||
assembly.connections = [
|
||
c for c in assembly.connections
|
||
if not (
|
||
(c.first_ac_id == active_id and c.second_ac_id == partner_ac_id)
|
||
or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id)
|
||
)
|
||
] if partner_ac_id is not None else assembly.connections
|
||
ac.remove_connector(conn_id)
|
||
logger.info(f"Removed connector {conn_id}")
|
||
self._show_assembly_in_viewer(fit=True)
|
||
|
||
def _new_workplane(self):
|
||
"""Open the orientation dialog and create a new independent workplane.
|
||
|
||
The workplane is shown as a semi-transparent plane in the 3D view
|
||
(with live preview as the user adjusts options in the dialog).
|
||
A fresh sketch is created on it ready for drawing, and body outlines
|
||
are projected as underlay construction lines for precise alignment.
|
||
"""
|
||
dialog = WorkplaneOrientationDialog(self)
|
||
origin = (0.0, 0.0, 0.0)
|
||
_preview_obj_id: Optional[str] = None
|
||
|
||
def _preview_callback(orientation):
|
||
"""Live-preview the workplane orientation in the 3D viewer."""
|
||
nonlocal _preview_obj_id
|
||
if orientation is None:
|
||
# Dialog closing — clear the preview.
|
||
if _preview_obj_id is not None:
|
||
self._viewer_3d.remove_workplane(_preview_obj_id)
|
||
_preview_obj_id = None
|
||
return
|
||
normal, x_dir = orientation
|
||
# Replace the previous preview (same ID = update in place).
|
||
if _preview_obj_id is not None:
|
||
self._viewer_3d.remove_workplane(_preview_obj_id)
|
||
_preview_obj_id = self._viewer_3d.show_workplane(
|
||
origin=origin,
|
||
normal=normal,
|
||
x_dir=x_dir,
|
||
size=250.0,
|
||
name="__wp_preview__",
|
||
)
|
||
|
||
dialog.set_preview_callback(_preview_callback)
|
||
|
||
if not dialog.exec():
|
||
# Preview already cleared by dialog.hideEvent → callback(None).
|
||
return
|
||
|
||
normal, x_dir, wp_name = dialog.get_orientation()
|
||
|
||
if not self._current_component:
|
||
self._current_component = self._project.add_component()
|
||
|
||
# Create the Workplane model.
|
||
from fluency.models.data_model import Workplane
|
||
wp = self._current_component.add_workplane(
|
||
Workplane(
|
||
name=wp_name,
|
||
origin=origin,
|
||
normal=normal,
|
||
x_dir=x_dir, )
|
||
)
|
||
|
||
# The preview visual becomes the permanent workplane; just update
|
||
# its name so it can be found later for removal.
|
||
if _preview_obj_id is not None:
|
||
# Store the render object ID in the workplane model.
|
||
wp.render_object = _preview_obj_id
|
||
# The show_workplane_plane method replaced the preview already,
|
||
# so the visual is showing the final orientation.
|
||
else:
|
||
# Fallback: create a new visual (shouldn't happen).
|
||
wp.render_object = self._viewer_3d.show_workplane(
|
||
origin=origin,
|
||
normal=normal,
|
||
x_dir=x_dir,
|
||
size=250.0,
|
||
name=f"workplane_{wp.id}",
|
||
)
|
||
self._mark_dirty()
|
||
|
||
# Create a sketch on this workplane and set up the 2D widget.
|
||
sketch = self._current_component.add_sketch()
|
||
self._mark_dirty()
|
||
sketch.name = f"Sketch on {wp.name}"
|
||
sketch.set_workplane(origin, normal, x_dir)
|
||
sketch._source_workplane_id = wp.id
|
||
|
||
# Prepare the OCC sketch in the widget.
|
||
if sketch.occ_sketch is None or sketch.occ_sketch.get_entity_count() > 0:
|
||
sketch.occ_sketch = self._sketch_widget.create_sketch()
|
||
sketch.apply_workplane()
|
||
self._sketch_widget.set_sketch(sketch.occ_sketch)
|
||
self._current_sketch = sketch
|
||
|
||
# Project body outlines onto the workplane for alignment.
|
||
self._project_body_to_active_wp()
|
||
|
||
self._sketch_widget.set_mode("line")
|
||
self._btn_line.setChecked(True)
|
||
|
||
self._refresh_lists()
|
||
self._set_panel_focus("sketch")
|
||
self.statusBar().showMessage(
|
||
f"Workplane '{wp.name}' created — sketch on it to draw. "
|
||
f"Body outlines projected as underlay.",
|
||
6000,
|
||
)
|
||
logger.info(f"New workplane '{wp.name}' with orientation n={normal} x={x_dir}")
|
||
|
||
def _project_body_to_active_wp(self) -> None:
|
||
"""Project all body outlines in the current component onto the active
|
||
sketch's workplane as underlay construction lines.
|
||
|
||
This lets the user see the 3D body's silhouette from the workplane's
|
||
perspective and position their 2D sketch precisely relative to the
|
||
existing geometry. Uses the same external-entity mechanism as
|
||
face-projected underlay (``set_source_face``).
|
||
"""
|
||
if not self._current_component or not self._current_sketch:
|
||
return
|
||
occ_sketch = self._current_sketch.occ_sketch
|
||
if occ_sketch is None:
|
||
return
|
||
wp = occ_sketch.get_workplane()
|
||
if not wp:
|
||
return
|
||
origin = wp[0] # (ox, oy, oz)
|
||
normal = wp[1] # (nx, ny, nz)
|
||
x_dir = wp[2] # (xx, xy, xz)
|
||
|
||
# Collect all body shapes from the current component.
|
||
body_shapes = []
|
||
kernel = self._kernel
|
||
for body in self._current_component.bodies.values():
|
||
if body.geometry is not None:
|
||
shape = kernel._get_shape(body.geometry)
|
||
if shape is not None:
|
||
body_shapes.append(shape)
|
||
|
||
if not body_shapes:
|
||
self._sketch_widget.clear_source_face()
|
||
self._btn_underlay.setEnabled(False)
|
||
self._btn_underlay.setChecked(True)
|
||
self._btn_clr_face.setEnabled(False)
|
||
self._btn_to_sketch.setEnabled(False)
|
||
return
|
||
|
||
# Project edges of all bodies onto the workplane.
|
||
workplane_data = (origin, normal, x_dir)
|
||
all_polylines: List[List[Tuple[float, float]]] = []
|
||
for shape in body_shapes:
|
||
try:
|
||
polys = _project_body_to_workplane(shape, workplane_data)
|
||
all_polylines.extend(polys)
|
||
except Exception as exc:
|
||
logger.debug("body projection failed for a shape: %s", exc)
|
||
|
||
if not all_polylines:
|
||
return
|
||
|
||
# Import the polylines as external/underlay entities in the sketch.
|
||
# First clear any existing external entities, then add the new ones.
|
||
occ_sketch.remove_external_entities()
|
||
imported_count = 0
|
||
for poly in all_polylines:
|
||
if len(poly) < 2:
|
||
continue
|
||
try:
|
||
_, lines = occ_sketch.add_external_polyline(
|
||
[(float(u), float(v)) for (u, v) in poly]
|
||
)
|
||
imported_count += len(lines)
|
||
except Exception as exc:
|
||
logger.debug("workplane underlay polyline import failed: %s", exc)
|
||
|
||
if imported_count > 0:
|
||
logger.info(
|
||
"Imported %d construction-line segments from body outlines",
|
||
imported_count,
|
||
)
|
||
# Refresh the 2D widget's entity tracking. We do NOT set
|
||
# _source_underlay_uv here because body projections produce
|
||
# many disjoint polylines — the fill paintEvent draws from
|
||
# _source_underlay_uv[0] would look wrong. The external
|
||
# entities themselves (orange dashed lines) provide the
|
||
# visual underlay.
|
||
self._sketch_widget._rebuild_from_sketch()
|
||
self._sketch_widget._source_workplane = workplane_data
|
||
self._sketch_widget._source_underlay_uv = []
|
||
self._sketch_widget._underlay_visible = True
|
||
self._sketch_widget.update()
|
||
# Enable the underlay toggle so the user can hide lines.
|
||
self._btn_underlay.setEnabled(True)
|
||
self._btn_underlay.setChecked(True)
|
||
self._btn_clr_face.setEnabled(True)
|
||
self._btn_to_sketch.setEnabled(True)
|
||
|
||
def _new_sketch_origin(self):
|
||
self._sketch_widget.create_sketch()
|
||
self._sketch_widget.set_mode("line")
|
||
self._btn_line.setChecked(True)
|
||
logger.info("New sketch at origin")
|
||
|
||
def _flip_workplane(self):
|
||
logger.info("Flip workplane (not implemented)")
|
||
|
||
def _move_workplane(self):
|
||
logger.info("Move workplane: use middle-click pan in 3D view")
|
||
|
||
def _translate_body(self):
|
||
if not self._selected_body or not self._selected_body.geometry:
|
||
QMessageBox.warning(self, "No Body", "Select a body first")
|
||
return
|
||
dx, ok1 = QInputDialog.getDouble(self, "Translate", "DX (mm):", 0, -10000, 10000, 2)
|
||
if not ok1:
|
||
return
|
||
dy, ok2 = QInputDialog.getDouble(self, "Translate", "DY (mm):", 0, -10000, 10000, 2)
|
||
if not ok2:
|
||
return
|
||
dz, ok3 = QInputDialog.getDouble(self, "Translate", "DZ (mm):", 0, -10000, 10000, 2)
|
||
if not ok3:
|
||
return
|
||
try:
|
||
new_geom = self._kernel.translate(self._selected_body.geometry, (dx, dy, dz))
|
||
self._selected_body.geometry = new_geom
|
||
self._redraw_bodies()
|
||
logger.info(f"Translated body by ({dx}, {dy}, {dz})")
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Translation failed: {e}")
|
||
|
||
def _pattern_array(self):
|
||
logger.info("Pattern array not yet implemented")
|
||
|
||
# ─── Offset sketch ─────────────────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _find_parent_point_entities(
|
||
sketch: OCCSketch,
|
||
positions: List[Tuple[float, float]],
|
||
tolerance: float = 0.01,
|
||
) -> List[Optional[OCCSketchEntity]]:
|
||
"""Match position tuples to the corresponding point entities in the sketch.
|
||
|
||
Searches ``sketch._entities`` for point entities whose geometry
|
||
matches each entry in *positions* within *tolerance*. Returns a
|
||
list parallel to *positions*; unmatched entries are *None*.
|
||
Skips external / centerline / construction entities so we only
|
||
pick up user-drawn boundary points.
|
||
"""
|
||
matches: List[Optional[OCCSketchEntity]] = []
|
||
for (tx, ty) in positions:
|
||
found: Optional[OCCSketchEntity] = None
|
||
for eid, entity in sketch._entities.items():
|
||
if entity.entity_type != "point":
|
||
continue
|
||
if entity.is_external or entity.is_construction:
|
||
continue
|
||
if entity.id in sketch._centerline_ids:
|
||
continue
|
||
if entity.geometry is not None:
|
||
ex, ey = entity.geometry
|
||
if abs(ex - tx) < tolerance and abs(ey - ty) < tolerance:
|
||
found = entity
|
||
break
|
||
matches.append(found)
|
||
return matches
|
||
|
||
def _offset_sketch(self) -> None:
|
||
"""Open the offset dialog and apply an offset to the selected sketch face.
|
||
|
||
The user must first select a closed face (region) in the 2D sketch.
|
||
When the Offset button is pressed:
|
||
1. The selected face's outer boundary is read.
|
||
2. An OffsetDialog appears with a number spinner.
|
||
3. Live preview shows the offset result in the 2D view.
|
||
4. On OK, new point & line entities are created in the sketch
|
||
at the offset position, duplicating the original boundary.
|
||
5. Distance constraints auto-connect each offset point to its
|
||
parent so the offset stays parametric.
|
||
6. The region between the original and offset boundaries forms
|
||
a selectable wall face (e.g. for extrusion into a thin wall).
|
||
"""
|
||
# Ensure we have a sketch and a selected face.
|
||
sketch = self._sketch_widget.get_sketch()
|
||
if sketch is None:
|
||
QMessageBox.warning(self, "No Sketch", "Please create and select a sketch first.")
|
||
return
|
||
|
||
selected_face = self._sketch_widget._selected_face
|
||
if selected_face is None:
|
||
QMessageBox.warning(
|
||
self, "No Face Selected",
|
||
"Click inside a closed face (region) in the sketch to select it, "
|
||
"then press Offset."
|
||
)
|
||
return
|
||
|
||
outer = selected_face.get("outer")
|
||
if outer is None:
|
||
QMessageBox.warning(self, "No Outer Boundary", "Selected face has no outer boundary.")
|
||
return
|
||
|
||
# ── Extract boundary points ──
|
||
if outer["type"] == "circle":
|
||
cx, cy = outer["center"]
|
||
radius = outer["radius"]
|
||
is_circle = True
|
||
elif outer["type"] == "polygon":
|
||
pts = list(outer["points"])
|
||
if len(pts) < 3:
|
||
QMessageBox.warning(self, "Invalid Polygon", "Face boundary has fewer than 3 points.")
|
||
return
|
||
# Remove closing duplicate (last == first) if present.
|
||
if len(pts) > 1 and pts[-1] == pts[0]:
|
||
pts.pop()
|
||
is_circle = False
|
||
else:
|
||
QMessageBox.warning(self, "Unsupported Face", f"Face type '{outer.get('type')}' not supported.")
|
||
return
|
||
|
||
# ── Find the ORIGINAL point entities so we can constrain to them ──
|
||
if is_circle:
|
||
parent_center = self._find_parent_point_entities(sketch, [(cx, cy)], tolerance=0.01)
|
||
parent_center_entity = parent_center[0] if parent_center else None
|
||
else:
|
||
parent_entities = self._find_parent_point_entities(sketch, pts, tolerance=0.01)
|
||
|
||
# ── Open dialog with live preview ──
|
||
dialog = OffsetDialog(self)
|
||
|
||
def _compute_offset_preview(distance: float, inward: bool) -> Optional[List[Tuple[float, float]]]:
|
||
"""Return offset polygon points, or None for circles."""
|
||
d = -distance if inward else distance
|
||
if is_circle:
|
||
return None # circles not drawn as polygon preview
|
||
try:
|
||
return _offset_polygon(pts, d)
|
||
except Exception as exc:
|
||
logger.debug("offset preview compute failed: %s", exc)
|
||
return None
|
||
|
||
def _preview_callback(values):
|
||
if values is None:
|
||
self._sketch_widget.clear_offset_preview()
|
||
return
|
||
distance, inward = values
|
||
preview_pts = _compute_offset_preview(distance, inward)
|
||
self._sketch_widget.set_offset_preview(preview_pts)
|
||
|
||
dialog.set_preview_callback(_preview_callback)
|
||
|
||
if not dialog.exec():
|
||
# Preview already cleared by hideEvent.
|
||
self._sketch_widget.clear_offset_preview()
|
||
return
|
||
|
||
self._sketch_widget.clear_offset_preview()
|
||
|
||
distance, inward = dialog.get_values()
|
||
d = -distance if inward else distance
|
||
logger.info(f"Offset distance: {abs(d):.2f} mm {'inward' if inward else 'outward'}")
|
||
|
||
try:
|
||
# ── Apply offset: create new entities in the sketch ──
|
||
if is_circle:
|
||
self._apply_circle_offset(
|
||
sketch, cx, cy, radius, d, selected_face,
|
||
parent_center_entity=parent_center_entity,
|
||
offset_distance=abs(d),
|
||
)
|
||
else:
|
||
self._apply_polygon_offset(
|
||
sketch, pts, d, selected_face,
|
||
parent_entities=parent_entities,
|
||
offset_distance=abs(d),
|
||
)
|
||
|
||
self._sketch_widget._rebuild_from_sketch()
|
||
self._sketch_widget._solve_and_sync()
|
||
self._sketch_widget.sketch_updated.emit()
|
||
self._sketch_widget.update()
|
||
|
||
self.statusBar().showMessage(
|
||
f"Offset sketch by {abs(d):.2f} mm {'inward' if inward else 'outward'}", 4000
|
||
)
|
||
logger.info("Offset complete")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Offset failed: {e}")
|
||
QMessageBox.critical(self, "Error", f"Offset failed: {e}")
|
||
|
||
def _apply_polygon_offset(
|
||
self, sketch: OCCSketch,
|
||
pts: List[Tuple[float, float]],
|
||
distance: float,
|
||
face: Dict[str, Any],
|
||
parent_entities: Optional[List[Optional[OCCSketchEntity]]] = None,
|
||
offset_distance: float = 0.0,
|
||
) -> None:
|
||
"""Duplicate a polygon boundary at *distance* offset and add to the sketch.
|
||
|
||
Creates new point + line entities for the offset boundary and
|
||
re-applies any holes from the original face (offset by the same
|
||
distance, clipped if they collapse). The region between the
|
||
original boundary and the offset boundary becomes a selectable
|
||
face (e.g. a thin wall for extrusion). When *parent_entities*
|
||
is provided, a distance constraint is added between each parent
|
||
point and the corresponding offset point with the
|
||
*offset_distance* value.
|
||
"""
|
||
offset_pts = _offset_polygon(pts, distance)
|
||
|
||
# Create new point entities at the offset positions.
|
||
new_points = []
|
||
for (x, y) in offset_pts:
|
||
pt = sketch.add_point(float(x), float(y))
|
||
new_points.append(pt)
|
||
|
||
# Create line entities connecting the new points.
|
||
new_lines = []
|
||
for i in range(len(new_points)):
|
||
j = (i + 1) % len(new_points)
|
||
line = sketch.add_line(new_points[i], new_points[j])
|
||
new_lines.append(line)
|
||
|
||
# ── Auto-constrain: distance constraint between each parent
|
||
# point and its corresponding offset point ──
|
||
if parent_entities and offset_distance > 0:
|
||
constrained = 0
|
||
for parent_ent, new_pt in zip(parent_entities, new_points):
|
||
if parent_ent is not None:
|
||
try:
|
||
sketch.constrain_distance(parent_ent, new_pt, offset_distance)
|
||
constrained += 1
|
||
except Exception as exc:
|
||
logger.debug(
|
||
"distance constraint failed for parent id=%s: %s",
|
||
parent_ent.id, exc,
|
||
)
|
||
if constrained:
|
||
logger.info("Added %d distance constraints to offset polygon", constrained)
|
||
|
||
# ── Offset holes ──
|
||
holes = face.get("holes", [])
|
||
for hole in holes:
|
||
if hole["type"] != "polygon":
|
||
continue
|
||
hole_pts = list(hole["points"])
|
||
if len(hole_pts) < 3:
|
||
continue
|
||
if len(hole_pts) > 1 and hole_pts[-1] == hole_pts[0]:
|
||
hole_pts.pop()
|
||
# Holes are offset in the OPPOSITE direction (a positive outer
|
||
# offset should make holes smaller, not larger).
|
||
offset_hole = _offset_polygon(hole_pts, -distance)
|
||
if len(offset_hole) < 3:
|
||
logger.debug("Hole offset collapsed — skipping")
|
||
continue
|
||
hole_points = []
|
||
for (x, y) in offset_hole:
|
||
pt = sketch.add_point(float(x), float(y))
|
||
hole_points.append(pt)
|
||
for i in range(len(hole_points)):
|
||
j = (i + 1) % len(hole_points)
|
||
sketch.add_line(hole_points[i], hole_points[j])
|
||
|
||
logger.info(
|
||
"Created %d offset points and %d offset lines for polygon boundary + %d holes",
|
||
len(offset_pts), len(offset_pts),
|
||
len([h for h in holes if h.get("type") == "polygon"]),
|
||
)
|
||
|
||
def _apply_circle_offset(
|
||
self, sketch: OCCSketch,
|
||
cx: float, cy: float, radius: float,
|
||
distance: float,
|
||
face: Dict[str, Any],
|
||
parent_center_entity: Optional[OCCSketchEntity] = None,
|
||
offset_distance: float = 0.0,
|
||
) -> None:
|
||
"""Duplicate a circle at *distance* offset and add to the sketch.
|
||
|
||
For circles the offset is simply a new circle with (radius ± distance).
|
||
A new center point is created so the original is not disturbed.
|
||
The region between the original and offset circles becomes a
|
||
selectable face (annular wall for extrusion). When
|
||
*parent_center_entity* is provided a distance constraint links
|
||
it to the new center.
|
||
"""
|
||
new_radius = radius + distance
|
||
if new_radius <= 0:
|
||
logger.warning("Offset radius would be non-positive — skipping")
|
||
return
|
||
|
||
# Create a new center point (slightly nudged so it's distinct).
|
||
new_cx = cx + 0.001 if abs(distance) < 0.01 else cx
|
||
new_cy = cy + 0.001 if abs(distance) < 0.01 else cy
|
||
center_pt = sketch.add_point(float(new_cx), float(new_cy))
|
||
sketch.add_circle(center_pt, float(new_radius))
|
||
|
||
# ── Auto-constrain: distance from parent center to new center ──
|
||
if parent_center_entity is not None and offset_distance > 0:
|
||
try:
|
||
sketch.constrain_distance(parent_center_entity, center_pt, offset_distance)
|
||
logger.info("Added distance constraint to offset circle center")
|
||
except Exception as exc:
|
||
logger.debug("circle distance constraint failed: %s", exc)
|
||
|
||
# Also offset any holes.
|
||
holes = face.get("holes", [])
|
||
for hole in holes:
|
||
if hole["type"] != "circle":
|
||
continue
|
||
h_cx, h_cy = hole["center"]
|
||
h_r = hole["radius"]
|
||
new_h_r = h_r - distance # holes shrink when outer grows
|
||
if new_h_r <= 0:
|
||
logger.debug("Hole circle offset collapsed — skipping")
|
||
continue
|
||
h_center = sketch.add_point(float(h_cx), float(h_cy))
|
||
sketch.add_circle(h_center, float(new_h_r))
|
||
|
||
logger.info(
|
||
"Created offset circle: center=(%.2f, %.2f), radius=%.2f",
|
||
new_cx, new_cy, new_radius,
|
||
)
|
||
|
||
# ─── Sketch-on-surface (face pick) ────────────────────────────────────
|
||
|
||
def _on_face_sketch_toggled(self, checked: bool) -> None:
|
||
"""Toggle the 3D viewer's face-pick mode (WP Face button)."""
|
||
self._viewer_3d.set_pick_face_mode(checked)
|
||
if checked:
|
||
# Clear any previous face-selection tint before picking a new one.
|
||
self._viewer_3d.clear_face_highlight()
|
||
# Make sure the 3D viewer has focus so it receives the click.
|
||
self._viewer_3d.setFocus()
|
||
self._viewer_3d.activateWindow()
|
||
self.statusBar().showMessage(
|
||
"Pick a planar face in the 3D viewer to sketch on (Esc to cancel)",
|
||
8000,
|
||
)
|
||
|
||
def _on_face_picked(self, origin, normal, x_dir, face_shape) -> None:
|
||
"""Create a new sketch on the picked planar face and switch to 2D.
|
||
|
||
Also records *which body* the picked face belonged to on the sketch
|
||
(``sketch._source_body_id``) so a later "Perform Cut" / "Combine"
|
||
extrude operation auto-targets that body instead of the first body
|
||
in the dict. Auto-selects the new sketch in the left-hand list so
|
||
the user can immediately Extrude/Cut without hunting for the row.
|
||
"""
|
||
# ``facePicked`` carries the face shape PLUS the owning obj_id from
|
||
# ``pick_planar_face`` (the renderer matches DetectedInteractive
|
||
# against tracked AIS objects). We extract that owner so the cut
|
||
# can target the right body.
|
||
source_body = None
|
||
logger.info(
|
||
f"Face picked: origin={origin}, normal={normal}, x_dir={x_dir}"
|
||
)
|
||
# Pull the owning obj_id the renderer stashed on this pick pass.
|
||
owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None)
|
||
if owner_obj_id and self._current_component is not None:
|
||
for bid, body in self._current_component.bodies.items():
|
||
if body.render_object == owner_obj_id:
|
||
source_body = body
|
||
logger.info(f"Sketch source body: {body.name}")
|
||
break
|
||
# Tint the picked face light-blue so the selection is visible in 3D.
|
||
self._viewer_3d.highlight_face(face_shape)
|
||
# Leave pick mode (the button stays toggled until we uncheck it).
|
||
self._btn_wp_face.setChecked(False)
|
||
self._viewer_3d.set_pick_face_mode(False)
|
||
|
||
if not self._current_component:
|
||
self._current_component = self._project.add_component()
|
||
|
||
sketch = self._current_component.add_sketch()
|
||
self._mark_dirty()
|
||
sketch.name = f"Sketch on face {len(self._current_component.sketches)}"
|
||
# Place the sketch on the picked plane (sets fields + syncs occ_sketch).
|
||
sketch.set_workplane(origin, normal, x_dir)
|
||
# Keep the face reference for the projection underlay (Phase 3).
|
||
sketch._source_face = face_shape
|
||
# 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
|
||
# wasn't registered as a component body — robust fallback then).
|
||
sketch._source_body_id = source_body.id if source_body else None
|
||
|
||
# Hand the sketch to the 2D widget and focus the sketch panel.
|
||
# Always build a clean OCC sketch carrying the face workplane so the
|
||
# widget draws on the picked plane.
|
||
if sketch.occ_sketch is None or sketch.occ_sketch.get_entity_count() > 0:
|
||
sketch.occ_sketch = self._sketch_widget.create_sketch()
|
||
sketch.apply_workplane()
|
||
self._sketch_widget.set_sketch(sketch.occ_sketch)
|
||
self._sketch_widget.set_source_face(face_shape, origin, normal, x_dir)
|
||
self._current_sketch = sketch
|
||
self._sketch_widget.set_mode("line")
|
||
self._btn_line.setChecked(True)
|
||
|
||
self._refresh_lists()
|
||
# Auto-select the freshly created sketch in the left-hand list so a
|
||
# 3D op (Extrude/Cut) operates on it without the user hunting for
|
||
# the row. _on_sketch_selected loads it into the widget for editing.
|
||
for row in range(self._sketch_list.count()):
|
||
item = self._sketch_list.item(row)
|
||
if item is not None and item.text() == sketch.name:
|
||
self._sketch_list.setCurrentRow(row)
|
||
break
|
||
# Switch focus to the sketch panel so the user can draw immediately.
|
||
self._set_panel_focus("sketch")
|
||
self.statusBar().showMessage(
|
||
f"Sketch placed on face — drawing in 2D on that plane", 6000
|
||
)
|
||
# The face is now the source for the underlay construction lines:
|
||
# enable the show/hide toggle, ClrFace, and ToSketch buttons.
|
||
self._btn_underlay.setEnabled(True)
|
||
self._btn_underlay.setChecked(True)
|
||
self._btn_clr_face.setEnabled(True)
|
||
self._btn_to_sketch.setEnabled(True)
|
||
|
||
def _on_underlay_toggled(self, checked: bool) -> None:
|
||
"""Show or hide the underlay construction lines in the 2D view.
|
||
|
||
Toggling this button does NOT remove the external entities from the
|
||
sketch solver — they stay there so existing constraints that
|
||
reference them keep working. The entities are just hidden from
|
||
paint + hover + hit-test while the toggle is off.
|
||
"""
|
||
self._sketch_widget.set_underlay_visible(checked)
|
||
self.statusBar().showMessage(
|
||
f"Underlay {'visible' if checked else 'hidden'}", 2000
|
||
)
|
||
|
||
def _on_clear_source_face(self) -> None:
|
||
"""Forget the source face: remove underlay entities, keep the workplane.
|
||
|
||
After this, the sketch remains on the same plane but the face
|
||
reference and its projected construction lines are gone. The user
|
||
keeps whatever user-drawn geometry they already added (and any
|
||
constraints they already applied, since they were pinned to entity
|
||
ids that are now removed along with the underlay).
|
||
"""
|
||
self._sketch_widget.clear_source_face()
|
||
self._btn_underlay.setEnabled(False)
|
||
self._btn_underlay.setChecked(False)
|
||
self._btn_clr_face.setEnabled(False)
|
||
self._btn_to_sketch.setEnabled(False)
|
||
if self._current_sketch is not None:
|
||
# Drop the saved reference on the model so re-editing the
|
||
# sketch later doesn't re-create the underlay.
|
||
self._current_sketch._source_face = None
|
||
self.statusBar().showMessage(
|
||
"Source face cleared — underlay construction lines removed", 3000
|
||
)
|
||
|
||
def _on_convert_underlay_to_sketch(self) -> None:
|
||
"""Convert the underlay/projected construction lines into real sketch geometry.
|
||
|
||
Delegates to the widget's ``_convert_underlay_to_sketch`` which
|
||
creates regular (non-construction, non-external) point and line
|
||
entities at every underlay position. The underlay reference stays
|
||
intact so the user can still toggle it on/off.
|
||
"""
|
||
self._sketch_widget._convert_underlay_to_sketch()
|
||
# Sync the main window's underlay toggle to match the widget
|
||
# (the conversion auto-hides the underlay).
|
||
self._btn_underlay.setChecked(False)
|
||
self.statusBar().showMessage(
|
||
"Underlay converted to sketch geometry — now you can select faces, offset, and extrude",
|
||
5000,
|
||
)
|
||
|
||
def _pattern_array_placeholder(self):
|
||
pass
|
||
|
||
def _add_sketch_to_component(self):
|
||
logger.info("=== ADD SKETCH TO COMPONENT ===")
|
||
if not self._current_component:
|
||
logger.info("No current component, creating new one")
|
||
self._current_component = self._project.add_component()
|
||
|
||
sketch = self._current_component.add_sketch()
|
||
self._mark_dirty()
|
||
logger.debug(f"Created sketch: {sketch.name}")
|
||
|
||
sketch_widget_sketch = self._sketch_widget.get_sketch()
|
||
logger.debug(f"Sketch from widget: {sketch_widget_sketch}")
|
||
sketch.occ_sketch = sketch_widget_sketch
|
||
|
||
if not sketch.occ_sketch:
|
||
logger.info("Creating new sketch in widget")
|
||
sketch.occ_sketch = self._sketch_widget.create_sketch()
|
||
|
||
# Adopt the widget sketch's existing 3D workplane (e.g. set by a
|
||
# face-pick) instead of clobbering it with this Sketch's default XY
|
||
# fields — otherwise a sketch drawn on a picked face would jump back
|
||
# to the world origin plane on the next extrude.
|
||
if sketch.occ_sketch is not None and hasattr(sketch.occ_sketch, "get_workplane"):
|
||
wp = sketch.occ_sketch.get_workplane()
|
||
import numpy as _np
|
||
sketch.workplane_origin = _np.asarray(wp[0], dtype=float)
|
||
sketch.workplane_normal = _np.asarray(wp[1], dtype=float)
|
||
sketch.workplane_x_dir = _np.asarray(wp[2], dtype=float)
|
||
|
||
# Sync the sketch's workplane (origin/normal/x_dir) into the OCC sketch
|
||
# so geometry is built on the right plane.
|
||
sketch.apply_workplane()
|
||
|
||
self._current_sketch = sketch
|
||
self._refresh_lists()
|
||
self._sketch_widget.set_mode(None)
|
||
logger.info(f"Added sketch: {sketch.name}")
|
||
logger.info(f"=== SKETCH ADDED: {sketch.name} ===")
|
||
|
||
def _edit_sketch(self):
|
||
selected = self._sketch_list.currentItem()
|
||
if not selected:
|
||
return
|
||
|
||
name = selected.text()
|
||
for sketch_id, sketch in self._current_component.sketches.items():
|
||
if sketch.name == name:
|
||
self._current_sketch = sketch
|
||
if sketch.occ_sketch:
|
||
sketch.apply_workplane()
|
||
self._sketch_widget.set_sketch(sketch.occ_sketch)
|
||
# If the sketch carries a saved source face (sketch-on-
|
||
# surface), re-bind it so the underlay construction lines
|
||
# come back. set_source_face rebuilds the external
|
||
# entities and re-orients the 2D view.
|
||
if getattr(sketch, "_source_face", None) is not None and sketch.occ_sketch is not None:
|
||
wp = sketch.occ_sketch.get_workplane()
|
||
origin, normal, x_dir = wp[0], wp[1], wp[2]
|
||
self._sketch_widget.set_source_face(
|
||
sketch._source_face, origin, normal, x_dir
|
||
)
|
||
self._btn_underlay.setEnabled(True)
|
||
self._btn_underlay.setChecked(True)
|
||
self._btn_clr_face.setEnabled(True)
|
||
self._btn_to_sketch.setEnabled(True)
|
||
elif getattr(sketch, "_source_workplane_id", None) is not None:
|
||
# Sketch on an independent workplane: project body outlines.
|
||
self._project_body_to_active_wp()
|
||
self._btn_underlay.setEnabled(True)
|
||
self._btn_underlay.setChecked(True)
|
||
self._btn_clr_face.setEnabled(True)
|
||
self._btn_to_sketch.setEnabled(True)
|
||
else:
|
||
# No saved face: make sure the underlay buttons
|
||
# reflect that the widget has no source face bound.
|
||
self._btn_underlay.setEnabled(False)
|
||
self._btn_underlay.setChecked(True)
|
||
self._btn_clr_face.setEnabled(False)
|
||
self._btn_to_sketch.setEnabled(False)
|
||
self._sketch_widget.set_mode("line")
|
||
self._btn_line.setChecked(True)
|
||
logger.info(f"Editing sketch: {name}")
|
||
break
|
||
|
||
def _on_sketch_selected(self, current, previous):
|
||
"""When sketch is selected in list, load it for editing."""
|
||
if current and self._current_component:
|
||
name = current.text()
|
||
for sketch_id, sketch in self._current_component.sketches.items():
|
||
if sketch.name == name:
|
||
self._current_sketch = sketch
|
||
if sketch.occ_sketch and hasattr(sketch.occ_sketch, 'get_entity_count') and sketch.occ_sketch.get_entity_count() > 0:
|
||
self._sketch_widget.set_sketch(sketch.occ_sketch)
|
||
break
|
||
|
||
def _delete_sketch(self):
|
||
selected = self._sketch_list.currentItem()
|
||
if not selected or not self._current_component:
|
||
return
|
||
|
||
name = selected.text()
|
||
to_delete = None
|
||
for sketch_id, sketch in self._current_component.sketches.items():
|
||
if sketch.name == name:
|
||
to_delete = sketch_id
|
||
break
|
||
|
||
if to_delete:
|
||
del self._current_component.sketches[to_delete]
|
||
self._mark_dirty()
|
||
self._refresh_lists()
|
||
logger.info(f"Deleted sketch: {name}")
|
||
|
||
def _on_sketch_list_changed(self, current, previous):
|
||
if current and self._current_component:
|
||
name = current.text()
|
||
for sketch_id, sketch in self._current_component.sketches.items():
|
||
if sketch.name == name:
|
||
self._current_sketch = sketch
|
||
break
|
||
|
||
def _on_body_list_changed(self, current, previous):
|
||
if current and self._current_component:
|
||
name = current.text()
|
||
for body_id, body in self._current_component.bodies.items():
|
||
if body.name == name:
|
||
self._selected_body = body
|
||
logger.info(f"Selected: {name}")
|
||
break
|
||
|
||
def _on_body_visibility_changed(self, item: QListWidgetItem) -> None:
|
||
"""Toggle a body's 3D visibility when the user flips its checkbox.
|
||
|
||
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.
|
||
"""
|
||
if self._current_component is None:
|
||
return
|
||
body_id = item.data(Qt.UserRole)
|
||
if body_id is None:
|
||
return
|
||
body = self._current_component.bodies.get(body_id)
|
||
if body is None:
|
||
return
|
||
new_visible = item.checkState() == Qt.Checked
|
||
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:
|
||
logger.debug(
|
||
"set_visibility failed for body %s (render_object=%r)",
|
||
body.name, body.render_object,
|
||
)
|
||
logger.info(
|
||
f"{'Visible' if new_visible else 'Hidden'}: {body.name}"
|
||
)
|
||
|
||
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
|
||
|
||
def _resolve_extrude_target(
|
||
self, sketch: Sketch, exclude_body: Optional[Body] = None
|
||
) -> Optional[Body]:
|
||
"""Choose the body a cut / union should target.
|
||
|
||
Preference order:
|
||
1. the body the sketch was projected onto (``sketch._source_body_id``)
|
||
2. the first body in the component that isn't the *exclude_body*
|
||
(the freshly-extruded tool itself, which we don't want to cut
|
||
*itself*).
|
||
Returns *None* if there is no candidate (e.g. the sketch wasn't
|
||
on a face and the component has no other bodies).
|
||
"""
|
||
if self._current_component is None:
|
||
return None
|
||
bodies = self._current_component.bodies
|
||
src_id = getattr(sketch, "_source_body_id", None)
|
||
if src_id is not None and src_id in bodies:
|
||
cand = bodies[src_id]
|
||
if cand is not exclude_body:
|
||
return cand
|
||
for body in bodies.values():
|
||
if body is exclude_body:
|
||
continue
|
||
return body
|
||
return None
|
||
|
||
def _through_all_length(self, target: Body, sketch: Sketch) -> float:
|
||
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
|
||
*through* the target body.
|
||
|
||
Computes the target body's bounding-box extent along the sketch's
|
||
workplane normal direction ("extent" = how far the body reaches on
|
||
either side of the face). With ``symmetric=True`` the kernel
|
||
extrudes ``± height/2``, so to clear the full ``extent`` on each
|
||
side we need ``height = 2 × (extent + buffer)``. The 5 mm buffer
|
||
on each side guarantees the tool pokes out past the body so the
|
||
boolean reliably removes the through volume.
|
||
"""
|
||
import numpy as _np
|
||
try:
|
||
p_min, p_max = self._kernel.get_bounding_box(target.geometry)
|
||
except Exception:
|
||
logger.debug("through-all bbox failed", exc_info=True)
|
||
return 2000.0 # generous fallback if bbox fails for any reason
|
||
origin = _np.asarray(sketch.workplane_origin, dtype=float)
|
||
normal = _np.asarray(sketch.workplane_normal, dtype=float)
|
||
normal = normal / max(_np.linalg.norm(normal), 1e-12)
|
||
corners = []
|
||
for xs in (p_min.x, p_max.x):
|
||
for ys in (p_min.y, p_max.y):
|
||
for zs in (p_min.z, p_max.z):
|
||
corners.append(_np.array([xs, ys, zs]))
|
||
ds = [_np.dot(c - origin, normal) for c in corners]
|
||
extent = max(abs(min(ds)), abs(max(ds)))
|
||
# Symmetric through: cover ±(extent + 5 mm) on each side of the
|
||
# face plane, which means a total height of 2×(extent + 5).
|
||
return 2.0 * float(extent) + 10.0
|
||
|
||
def _compute_extrude_result(
|
||
self,
|
||
sketch: Sketch,
|
||
face_geom: Any,
|
||
length: float,
|
||
symmetric: bool,
|
||
invert: bool,
|
||
cut: bool,
|
||
union: bool,
|
||
through_all: bool,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Compute the *previewable* result of an extrude/cut/union.
|
||
|
||
Returns a dict with:
|
||
- "result_shape": final TopoDS_Shape (the thing to show / commit)
|
||
- "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)
|
||
Or *None* if the geometry can't be built (e.g. empty sketch).
|
||
|
||
Mutates nothing on the project — safe to call repeatedly for the
|
||
live preview. The apply path (:meth:`_extrude_sketch`) commits
|
||
the returned shape onto ``target_body`` (or creates a new body
|
||
for plain extrudes).
|
||
"""
|
||
if face_geom is None:
|
||
return None
|
||
# Resolve target (only meaningful for cut / union).
|
||
target = self._resolve_extrude_target(sketch) if (cut or union) else None
|
||
# Determine the extrude length and direction.
|
||
if through_all and target is not None:
|
||
# Pass-through: symmetric extrude large enough to clear the body
|
||
# on both sides of the face plane (direction-agnostic).
|
||
extrude_length = self._through_all_length(target, sketch)
|
||
symmetric = True
|
||
invert = False
|
||
else:
|
||
# Cut targeting a body must go *into* the body — the picked face's
|
||
# outward normal points AWAY from the body, so a non-inverted
|
||
# extrude would build a boss ABOVE the face and the boolean cut
|
||
# would remove nothing. Force the tool into the body so
|
||
# "Perform Cut" always carves a real pocket.
|
||
if cut and target is not None:
|
||
invert = True
|
||
extrude_length = -length if invert else length
|
||
try:
|
||
tool_geom = self._kernel.extrude(
|
||
face_geom, extrude_length, symmetric=symmetric
|
||
)
|
||
except Exception as exc:
|
||
logger.debug("preview extrude failed: %s", exc)
|
||
return None
|
||
if tool_geom is None:
|
||
return None
|
||
tool_shape = self._kernel._get_shape(tool_geom)
|
||
if target is not None:
|
||
try:
|
||
if cut:
|
||
result_geom = self._kernel.boolean_difference(
|
||
target.geometry, tool_geom
|
||
)
|
||
else: # union
|
||
result_geom = self._kernel.boolean_union(
|
||
target.geometry, tool_geom
|
||
)
|
||
except Exception as exc:
|
||
logger.debug("preview boolean failed: %s", exc)
|
||
return None
|
||
result_shape = self._kernel._get_shape(result_geom)
|
||
return {
|
||
"result_shape": result_shape,
|
||
"result_geom": result_geom,
|
||
"target_body": target,
|
||
"tool_geom": tool_geom,
|
||
"tool_shape": tool_shape,
|
||
}
|
||
# Plain extrude: the tool IS the result.
|
||
return {
|
||
"result_shape": tool_shape,
|
||
"result_geom": tool_geom,
|
||
"target_body": None,
|
||
"tool_geom": tool_geom,
|
||
"tool_shape": tool_shape,
|
||
}
|
||
|
||
def _start_extrude_preview(self, dialog: ExtrudeDialog, sketch: Sketch, face_geom: Any) -> None:
|
||
"""Install a live-preview callback on *dialog* for *sketch*.
|
||
|
||
The host dims the body the cut/union will target (if any) so the
|
||
previewed result reads clearly on top of it. The dimming is
|
||
reverted on dialog close (see hideEvent → callback(None)).
|
||
"""
|
||
# Track which bodies we dimmed so we can restore their transparency
|
||
# exactly (they might have had a non-zero transparency to start, in
|
||
# 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"]:
|
||
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))
|
||
|
||
def _clear():
|
||
self._viewer_3d.clear_preview()
|
||
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()
|
||
|
||
def _callback(values):
|
||
if values is None:
|
||
_clear()
|
||
return
|
||
length, symmetric, invert, cut, union, through_all, _rounded = values
|
||
result = self._compute_extrude_result(
|
||
sketch, face_geom,
|
||
length, symmetric, invert, bool(cut), bool(union),
|
||
bool(through_all),
|
||
)
|
||
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"])
|
||
|
||
dialog.set_preview_callback(_callback)
|
||
|
||
def _extrude_sketch(self):
|
||
logger.info("=== EXTRUDE SKETCH ===")
|
||
if not self._current_component:
|
||
logger.warning("No current component")
|
||
return
|
||
|
||
sketch = self._current_sketch
|
||
logger.debug(f"Current sketch: {sketch}")
|
||
if not sketch or not sketch.occ_sketch:
|
||
sketch_entity = self._sketch_widget.get_sketch()
|
||
logger.debug(f"Sketch from widget: {sketch_entity}")
|
||
if not sketch_entity:
|
||
logger.warning("No sketch entity found")
|
||
QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
|
||
return
|
||
sketch.occ_sketch = sketch_entity
|
||
|
||
# Resolve the profile geometry *before* opening the dialog so the
|
||
# live preview can use it. Prefer the selected face (which can
|
||
# include holes) over the full sketch.
|
||
face_geom = self._sketch_widget.get_selected_face_geometry()
|
||
if face_geom is not None:
|
||
logger.info("Using selected face geometry (with holes)")
|
||
else:
|
||
face_geom = sketch.occ_sketch.get_geometry()
|
||
logger.debug(f"Geometry: {face_geom}")
|
||
if not face_geom:
|
||
logger.error("No geometry from sketch")
|
||
QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
|
||
return
|
||
|
||
dialog = ExtrudeDialog(self)
|
||
# Wire up the live preview: every spinbox/checkbox change rebuilds
|
||
# the result via the shared helper and shows it transparent.
|
||
self._start_extrude_preview(dialog, sketch, face_geom)
|
||
accepted = dialog.exec()
|
||
# The dialog's hideEvent already fired the callback with *None* to
|
||
# clear the preview and un-dim any body — but be defensive in case
|
||
# a subclass swallows the event.
|
||
self._viewer_3d.clear_preview()
|
||
if not accepted:
|
||
logger.info("Extrude dialog cancelled")
|
||
return
|
||
|
||
length, symmetric, invert, cut, union, through_all, rounded = dialog.get_values()
|
||
logger.info(
|
||
f"Extrude params: length={length}, symmetric={symmetric}, "
|
||
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}"
|
||
)
|
||
|
||
try:
|
||
result = self._compute_extrude_result(
|
||
sketch, face_geom,
|
||
length, symmetric, invert, bool(cut), bool(union),
|
||
bool(through_all),
|
||
)
|
||
if result is None or result["result_geom"] is None:
|
||
logger.warning("Extrude produced no geometry")
|
||
QMessageBox.warning(self, "No Geometry", "Extrude produced no geometry")
|
||
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).
|
||
target.geometry = result["result_geom"]
|
||
if target.render_object is not None:
|
||
self._viewer_3d.remove_mesh(target.render_object)
|
||
shape = self._kernel._get_shape(target.geometry)
|
||
target.render_object = self._viewer_3d.show_shape(
|
||
shape, target.color, target.name
|
||
)
|
||
op = "cut" if cut else "union"
|
||
logger.info(
|
||
f"{op.capitalize()} applied: {target.name} now holds the result"
|
||
)
|
||
body_name = target.name
|
||
else:
|
||
# Plain extrude: create a new body for the extrusion.
|
||
body = self._current_component.add_body(
|
||
Body(
|
||
name=f"Extrusion_{len(self._current_component.bodies) + 1}",
|
||
geometry=result["result_geom"],
|
||
source_sketch=sketch,
|
||
source_operation="extrude",
|
||
)
|
||
)
|
||
self._mark_dirty()
|
||
logger.info(f"Created body: {body.name}")
|
||
logger.debug("Adding shape to OCC viewer")
|
||
shape = self._kernel._get_shape(body.geometry)
|
||
body.render_object = self._viewer_3d.show_shape(
|
||
shape, body.color, body.name
|
||
)
|
||
logger.info(f"Render object: {body.render_object}")
|
||
body_name = body.name
|
||
|
||
self._refresh_lists()
|
||
self._viewer_3d.fit_camera()
|
||
logger.info(f"Extruded: {body_name}")
|
||
logger.info("=== EXTRUDE COMPLETE ===")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Extrude failed: {e}")
|
||
QMessageBox.critical(self, "Error", f"Extrude failed: {e}")
|
||
|
||
def _revolve_sketch(self):
|
||
logger.info("=== REVOLVE SKETCH ===")
|
||
if not self._current_component:
|
||
logger.warning("No current component")
|
||
return
|
||
|
||
sketch = self._current_sketch
|
||
if not sketch or not sketch.occ_sketch:
|
||
sketch_entity = self._sketch_widget.get_sketch()
|
||
if not sketch_entity:
|
||
QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
|
||
return
|
||
sketch.occ_sketch = sketch_entity
|
||
|
||
dialog = RevolveDialog(self)
|
||
if not dialog.exec():
|
||
logger.info("Revolve dialog cancelled")
|
||
return
|
||
|
||
angle = dialog.angle_input.value()
|
||
|
||
try:
|
||
face_geom = self._sketch_widget.get_selected_face_geometry()
|
||
if face_geom is not None:
|
||
geometry = face_geom
|
||
else:
|
||
geometry = sketch.occ_sketch.get_geometry()
|
||
if not geometry:
|
||
QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
|
||
return
|
||
|
||
body_geometry = self._kernel.revolve(geometry, angle)
|
||
body = self._current_component.add_body(
|
||
Body(
|
||
name=f"Revolution_{len(self._current_component.bodies) + 1}",
|
||
geometry=body_geometry,
|
||
source_sketch=sketch,
|
||
source_operation="revolve",
|
||
)
|
||
)
|
||
self._mark_dirty()
|
||
|
||
logger.debug("Adding shape to OCC viewer")
|
||
shape = self._kernel._get_shape(body_geometry)
|
||
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||
logger.info(f"Render object: {body.render_object}")
|
||
|
||
self._refresh_lists()
|
||
self._viewer_3d.fit_camera()
|
||
logger.info(f"Revolved: {body.name}")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Revolve failed: {e}")
|
||
QMessageBox.critical(self, "Error", f"Revolve failed: {e}")
|
||
|
||
def _boolean_cut(self):
|
||
logger.info("=== BOOLEAN CUT ===")
|
||
if not self._current_component or len(self._current_component.bodies) < 2:
|
||
QMessageBox.warning(self, "Need Bodies", "Need at least 2 bodies to perform cut.\nCreate multiple bodies first.")
|
||
return
|
||
|
||
# Use the first body in the list as base, last as tool
|
||
body_ids = list(self._current_component.bodies.keys())
|
||
if len(body_ids) < 2:
|
||
return
|
||
|
||
# Let user pick which body to use as tool
|
||
body_names = [self._current_component.bodies[bid].name for bid in body_ids]
|
||
tool_name, ok = QInputDialog.getItem(
|
||
self, "Select Tool Body", "Body to subtract (tool):", body_names, len(body_names) - 1, False
|
||
)
|
||
if not ok:
|
||
return
|
||
|
||
tool_id = None
|
||
base_id = None
|
||
for bid in body_ids:
|
||
if self._current_component.bodies[bid].name == tool_name:
|
||
tool_id = bid
|
||
else:
|
||
base_id = bid
|
||
|
||
if tool_id is None or base_id is None:
|
||
return
|
||
|
||
base_body = self._current_component.bodies[base_id]
|
||
tool_body = self._current_component.bodies[tool_id]
|
||
|
||
if not base_body.geometry or not tool_body.geometry:
|
||
QMessageBox.warning(self, "No Geometry", "One of the bodies has no geometry")
|
||
return
|
||
|
||
try:
|
||
result_geom = self._kernel.boolean_difference(base_body.geometry, tool_body.geometry)
|
||
new_body = self._current_component.add_body(
|
||
Body(
|
||
name=f"Cut_{len(self._current_component.bodies) + 1}",
|
||
geometry=result_geom,
|
||
source_operation="boolean_cut",
|
||
)
|
||
)
|
||
self._mark_dirty()
|
||
|
||
logger.debug("Adding shape to OCC viewer")
|
||
shape = self._kernel._get_shape(result_geom)
|
||
new_body.render_object = self._viewer_3d.show_shape(shape, new_body.color, new_body.name)
|
||
logger.info(f"Render object: {new_body.render_object}")
|
||
|
||
self._refresh_lists()
|
||
self._viewer_3d.fit_camera()
|
||
logger.info(f"Cut complete: {new_body.name}")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Boolean cut failed: {e}")
|
||
QMessageBox.critical(self, "Error", f"Boolean cut failed: {e}")
|
||
|
||
def _boolean_union(self):
|
||
logger.info("=== BOOLEAN UNION ===")
|
||
if not self._current_component or len(self._current_component.bodies) < 2:
|
||
QMessageBox.warning(self, "Need Bodies", "Need at least 2 bodies to perform union.")
|
||
return
|
||
|
||
bodies = list(self._current_component.bodies.values())
|
||
geometries = [b.geometry for b in bodies if b.geometry]
|
||
|
||
if len(geometries) < 2:
|
||
QMessageBox.warning(self, "Need Bodies", "Not enough bodies with valid geometry.")
|
||
return
|
||
|
||
try:
|
||
result_geom = self._kernel.boolean_union(*geometries)
|
||
new_body = self._current_component.add_body(
|
||
Body(
|
||
name=f"Union_{len(self._current_component.bodies) + 1}",
|
||
geometry=result_geom,
|
||
source_operation="boolean_union",
|
||
)
|
||
)
|
||
self._mark_dirty()
|
||
|
||
logger.debug("Adding shape to OCC viewer")
|
||
shape = self._kernel._get_shape(result_geom)
|
||
new_body.render_object = self._viewer_3d.show_shape(shape, new_body.color, new_body.name)
|
||
logger.info(f"Render object: {new_body.render_object}")
|
||
|
||
self._refresh_lists()
|
||
self._viewer_3d.fit_camera()
|
||
logger.info(f"Union complete: {new_body.name}")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Boolean union failed: {e}")
|
||
QMessageBox.critical(self, "Error", f"Boolean union failed: {e}")
|
||
|
||
def _delete_body(self):
|
||
selected = self._body_list.currentItem()
|
||
if not selected or not self._current_component:
|
||
return
|
||
|
||
name = selected.text()
|
||
to_delete = None
|
||
for body_id, body in self._current_component.bodies.items():
|
||
if body.name == name:
|
||
to_delete = body_id
|
||
if body.render_object:
|
||
self._viewer_3d.remove_mesh(body.render_object)
|
||
break
|
||
|
||
if to_delete:
|
||
del self._current_component.bodies[to_delete]
|
||
self._mark_dirty()
|
||
self._refresh_lists()
|
||
logger.info(f"Deleted body: {name}")
|
||
|
||
# ── Recent Projects ──────────────────────────────────────────────
|
||
|
||
def _setup_recent_projects(self) -> None:
|
||
"""Restore the recent projects menu from settings on startup."""
|
||
self._update_recent_menu()
|
||
# Auto-load last project if the preference is enabled.
|
||
if self._settings.value("load_last_on_startup", False, type=bool):
|
||
recent = self._settings.value("recent_projects", [], type=list)
|
||
if recent and os.path.isfile(recent[0]):
|
||
self._suspend_dirty = True
|
||
try:
|
||
self._open_project_file(recent[0])
|
||
except Exception as exc:
|
||
logger.warning("Failed to auto-load last project: %s", exc)
|
||
finally:
|
||
self._suspend_dirty = False
|
||
|
||
def _get_recent_projects(self) -> List[str]:
|
||
"""Return the list of recent project paths from QSettings."""
|
||
return self._settings.value("recent_projects", [], type=list)
|
||
|
||
def _add_recent_project(self, path: str) -> None:
|
||
"""Add *path* to the top of the recent-projects list."""
|
||
recent = self._get_recent_projects()
|
||
# Normalize and deduplicate.
|
||
path = os.path.abspath(path)
|
||
if path in recent:
|
||
recent.remove(path)
|
||
recent.insert(0, path)
|
||
# Trim to max.
|
||
recent = recent[:MAX_RECENT_PROJECTS]
|
||
self._settings.setValue("recent_projects", recent)
|
||
self._update_recent_menu()
|
||
|
||
def _update_recent_menu(self) -> None:
|
||
"""Rebuild the Recent Projects submenu from the stored list."""
|
||
self._recent_projects_menu.clear()
|
||
recent = self._get_recent_projects()
|
||
if not recent:
|
||
action = self._recent_projects_menu.addAction("(Empty)")
|
||
action.setEnabled(False)
|
||
return
|
||
for path in recent:
|
||
name = os.path.basename(path)
|
||
action = self._recent_projects_menu.addAction(f"{name} — {path}")
|
||
# Use the full path as data so we can open it.
|
||
action.setData(path)
|
||
action.triggered.connect(self._open_recent_project)
|
||
self._recent_projects_menu.addSeparator()
|
||
clear_action = self._recent_projects_menu.addAction("Clear Recent Projects")
|
||
clear_action.triggered.connect(self._clear_recent_projects)
|
||
|
||
@Slot()
|
||
def _open_recent_project(self) -> None:
|
||
"""Open the project whose action was clicked."""
|
||
action = self.sender()
|
||
if action is None:
|
||
return
|
||
path = action.data()
|
||
if path and os.path.isfile(path):
|
||
self._open_project_file(path)
|
||
else:
|
||
QMessageBox.warning(self, "File Not Found", f"Project not found:\n{path}")
|
||
|
||
@Slot(bool)
|
||
def _toggle_load_last_project(self, checked: bool) -> None:
|
||
"""Persist the "Load last project on startup" preference."""
|
||
self._settings.setValue("load_last_on_startup", checked)
|
||
|
||
@Slot()
|
||
def _clear_recent_projects(self) -> None:
|
||
"""Empty the recent projects list."""
|
||
self._settings.setValue("recent_projects", [])
|
||
self._update_recent_menu()
|
||
|
||
# ── Project save / load ─────────────────────────────────────────
|
||
|
||
def _new_project(self):
|
||
if not self._confirm_discard_changes():
|
||
return
|
||
# Suppress dirty while we reset the scene; the new project starts
|
||
# as a fresh empty one and shouldn't show as "modified".
|
||
self._suspend_dirty = True
|
||
try:
|
||
self._project = Project()
|
||
self._current_component = None
|
||
self._current_sketch = None
|
||
self._selected_body = None
|
||
|
||
for btn in self._component_buttons:
|
||
btn.deleteLater()
|
||
self._component_buttons.clear()
|
||
|
||
# set_sketch(None) clears the underlay entities via the new
|
||
# set_sketch guard, but we also need to drop the saved source face
|
||
# and reset the workplane buttons to their disabled state.
|
||
self._sketch_widget.clear_source_face()
|
||
self._sketch_widget.set_sketch(None)
|
||
self._viewer_3d.clear_scene()
|
||
self._refresh_lists()
|
||
self._btn_underlay.setEnabled(False)
|
||
self._btn_underlay.setChecked(True)
|
||
self._btn_clr_face.setEnabled(False)
|
||
self._btn_to_sketch.setEnabled(False)
|
||
|
||
self._create_initial_component()
|
||
finally:
|
||
self._suspend_dirty = False
|
||
self._project_path = None
|
||
self._dirty = False
|
||
self._update_window_title()
|
||
logger.info("New project created")
|
||
|
||
# ────────────────────────────────────────────────────────────────────
|
||
# Project save / load
|
||
# ────────────────────────────────────────────────────────────────────
|
||
|
||
def _mark_dirty(self) -> None:
|
||
"""Mark the project as having unsaved changes.
|
||
|
||
Called from any UI path that mutates the model (adding components,
|
||
sketches, bodies, etc.). The setter is intentionally a no-op if the
|
||
project is already dirty to keep the title-bar updates cheap — the
|
||
title flips from "Untitled" / "name.fluency" to "Untitled*" /
|
||
"name.fluency*" on the first edit and stays there until :meth:`_save_project`
|
||
clears it.
|
||
|
||
When :attr:`_suspend_dirty` is set (during programmatic init or a
|
||
``_new_project`` reset) the call is a no-op so the freshly-created
|
||
default project doesn't immediately appear as "modified" in the
|
||
title bar.
|
||
"""
|
||
if self._dirty or self._suspend_dirty:
|
||
return
|
||
self._dirty = True
|
||
self._update_window_title()
|
||
|
||
def _update_window_title(self) -> None:
|
||
"""Refresh the title bar to reflect current file + dirty state."""
|
||
if self._project_path:
|
||
name = os.path.basename(self._project_path)
|
||
else:
|
||
name = "Untitled"
|
||
suffix = " *" if self._dirty else ""
|
||
self.setWindowTitle(f"Fluency CAD 2.0 — {name}{suffix}")
|
||
|
||
def _collect_view_state(self) -> Dict[str, Any]:
|
||
"""Snapshot the camera + active-tab state for the saved view_state."""
|
||
try:
|
||
eye, at_, up = self._viewer_3d.get_camera_position()
|
||
except Exception:
|
||
eye = [1.0, 1.0, 1.0]
|
||
at_ = [0.0, 0.0, 0.0]
|
||
up = [0.0, 0.0, 1.0]
|
||
# eye / at / up may be tuples, lists, or numpy arrays depending on
|
||
# the renderer — coerce to plain 3-lists.
|
||
def _flat3(v):
|
||
if v is None:
|
||
return [0.0, 0.0, 0.0]
|
||
if hasattr(v, "tolist"):
|
||
v = v.tolist()
|
||
seq = list(v)
|
||
if len(seq) < 3:
|
||
seq = seq + [0.0] * (3 - len(seq))
|
||
return [float(seq[0]), float(seq[1]), float(seq[2])]
|
||
return {
|
||
"active_tab": self._input_tabs.currentIndex() if hasattr(self, "_input_tabs") else 0,
|
||
"active_component_id": self._current_component.id if self._current_component else None,
|
||
"active_sketch_id": self._current_sketch.id if self._current_sketch else None,
|
||
"selected_body_id": self._selected_body.id if self._selected_body else None,
|
||
"camera_eye": _flat3(eye),
|
||
"camera_at": _flat3(at_),
|
||
"camera_up": _flat3(up),
|
||
"panel_focus": getattr(self, "_panel_focus", "equal"),
|
||
"assembly_view_active": bool(self._assembly_view_active),
|
||
"selected_assembly_component_id": self._selected_assembly_component_id,
|
||
}
|
||
|
||
def _restore_view_state(self, view_state: Dict[str, Any]) -> None:
|
||
"""Apply a saved view_state dict to the camera + UI selection."""
|
||
if not view_state:
|
||
return
|
||
try:
|
||
eye = view_state.get("camera_eye")
|
||
at_ = view_state.get("camera_at")
|
||
up = view_state.get("camera_up")
|
||
if eye and at_ and up:
|
||
self._viewer_3d.set_camera_position(
|
||
(float(eye[0]), float(eye[1]), float(eye[2])),
|
||
(float(at_[0]), float(at_[1]), float(at_[2])),
|
||
up=(float(up[0]), float(up[1]), float(up[2])),
|
||
)
|
||
except Exception as exc:
|
||
logger.debug("Failed to restore camera: %s", exc)
|
||
# Active tab.
|
||
try:
|
||
tab_idx = int(view_state.get("active_tab", 0))
|
||
if hasattr(self, "_input_tabs"):
|
||
self._input_tabs.setCurrentIndex(max(0, tab_idx))
|
||
except Exception:
|
||
pass
|
||
# Panel focus.
|
||
try:
|
||
focus = view_state.get("panel_focus")
|
||
if focus in ("equal", "sketch", "viewer"):
|
||
self._set_panel_focus(focus)
|
||
except Exception:
|
||
pass
|
||
|
||
def _confirm_discard_changes(self) -> bool:
|
||
"""Return True if it's safe to discard the current project.
|
||
|
||
Pops a Save / Discard / Cancel dialog when the project is dirty.
|
||
Returns True (= proceed with discard) for the Save and Discard
|
||
choices; False for Cancel. When the project is clean, returns
|
||
True immediately so the call site doesn't have to special-case it.
|
||
"""
|
||
if not self._dirty:
|
||
return True
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Warning)
|
||
box.setWindowTitle("Unsaved Changes")
|
||
box.setText("This project has unsaved changes.")
|
||
box.setInformativeText("Save before continuing?")
|
||
box.setStandardButtons(
|
||
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel
|
||
)
|
||
box.setDefaultButton(QMessageBox.Save)
|
||
choice = box.exec()
|
||
if choice == QMessageBox.Cancel:
|
||
return False
|
||
if choice == QMessageBox.Save:
|
||
return self._save_project()
|
||
return True # Discard
|
||
|
||
def _save_project(self) -> bool:
|
||
"""Save the current project. Returns True on success."""
|
||
if not self._project_path:
|
||
return self._save_project_as()
|
||
return self._write_project_to_disk(self._project_path)
|
||
|
||
def _save_project_as(self) -> bool:
|
||
"""Prompt for a path and save. Returns True on success."""
|
||
# Default to the current file name so Save-As is one click away
|
||
# from a normal Save.
|
||
default = self._project_path or os.path.join(
|
||
os.path.expanduser("~"), "untitled.fluency"
|
||
)
|
||
path, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"Save Project",
|
||
default,
|
||
"Fluency Project (*.fluency)",
|
||
)
|
||
if not path:
|
||
return False
|
||
path = project_zip_path(path)
|
||
return self._write_project_to_disk(path)
|
||
|
||
def _write_project_to_disk(self, path: str) -> bool:
|
||
"""Write the project to *path* and update internal state on success."""
|
||
try:
|
||
view_state = self._collect_view_state()
|
||
save_project(
|
||
self._project,
|
||
path,
|
||
view_state=view_state,
|
||
kernel=self._kernel,
|
||
)
|
||
self._project_path = path
|
||
self._project.file_path = path
|
||
self._dirty = False
|
||
self._update_window_title()
|
||
self._add_recent_project(path)
|
||
self.statusBar().showMessage(f"Saved: {os.path.basename(path)}", 5000)
|
||
logger.info("Saved project: %s", path)
|
||
return True
|
||
except Exception as exc:
|
||
QMessageBox.critical(
|
||
self, "Save Failed", f"Could not save the project:\n{exc}"
|
||
)
|
||
return False
|
||
|
||
def _open_project(self) -> bool:
|
||
"""Prompt for and open a ``.fluency`` file. Returns True on success."""
|
||
if not self._confirm_discard_changes():
|
||
return False
|
||
path, _ = QFileDialog.getOpenFileName(
|
||
self,
|
||
"Open Project",
|
||
os.path.expanduser("~"),
|
||
"Fluency Project (*.fluency);;All files (*)",
|
||
)
|
||
if not path:
|
||
return False
|
||
return self._open_project_file(path)
|
||
|
||
def _open_project_file(self, path: str) -> bool:
|
||
"""Load *path* into the running app. Returns True on success."""
|
||
try:
|
||
project, view_state = load_project(path)
|
||
except Exception as exc:
|
||
QMessageBox.critical(
|
||
self, "Open Failed", f"Could not open the project:\n{exc}"
|
||
)
|
||
return False
|
||
|
||
# Suppress dirty tracking while we replace the in-memory project
|
||
# and rebuild the UI. The loaded project starts clean until the
|
||
# user touches it again.
|
||
self._suspend_dirty = True
|
||
try:
|
||
# Replace the in-memory project + UI state. This is the same path
|
||
# that New Project would take, but populated with the loaded data.
|
||
self._project = project
|
||
# Reuse the running kernel so we keep the same OCC viewer context.
|
||
# The loaded body's STEP data has already been parsed by the new
|
||
# kernel inside load_project; we copy those bodies' references in
|
||
# via the dict already, but we still want the live ``_kernel`` in
|
||
# this window to match for new operations.
|
||
self._kernel = project.kernel
|
||
|
||
# Reset all UI state.
|
||
for btn in self._component_buttons:
|
||
btn.deleteLater()
|
||
self._component_buttons.clear()
|
||
for btn in self._assembly_component_buttons:
|
||
btn.deleteLater()
|
||
self._assembly_component_buttons.clear()
|
||
|
||
self._current_component = None
|
||
self._current_sketch = None
|
||
self._selected_body = None
|
||
self._selected_assembly_component_id = None
|
||
self._assembly_view_active = False
|
||
self._sketch_widget.clear_source_face()
|
||
self._sketch_widget.set_sketch(None)
|
||
self._viewer_3d.clear_scene()
|
||
self._refresh_lists()
|
||
|
||
# Rebuild component buttons (one per component, numbered).
|
||
for idx, comp in enumerate(self._project.components.values(), start=1):
|
||
btn = QPushButton(str(idx))
|
||
btn.setCheckable(True)
|
||
btn.setFixedSize(QSize(40, 40))
|
||
btn.clicked.connect(self._on_component_button_clicked)
|
||
self._component_buttons.append(btn)
|
||
self._component_group.addButton(btn)
|
||
self._component_box_layout.addWidget(btn)
|
||
|
||
# Pick which component to activate: explicit saved selection,
|
||
# falling back to the project's active_component, then the first.
|
||
target_comp_id: Optional[str] = None
|
||
if view_state.get("active_component_id") in self._project.components:
|
||
target_comp_id = view_state["active_component_id"]
|
||
elif self._project.active_component in self._project.components:
|
||
target_comp_id = self._project.active_component
|
||
elif self._project.components:
|
||
target_comp_id = next(iter(self._project.components.keys()))
|
||
|
||
if target_comp_id is not None:
|
||
self._current_component = self._project.components[target_comp_id]
|
||
idx = list(self._project.components.keys()).index(target_comp_id)
|
||
if 0 <= idx < len(self._component_buttons):
|
||
for b in self._component_buttons:
|
||
b.setChecked(False)
|
||
self._component_buttons[idx].setChecked(True)
|
||
|
||
# Rebuild assembly component buttons (one per assembly instance).
|
||
for assembly in self._project.assemblies.values():
|
||
for ac_id, ac in assembly.components.items():
|
||
instance_num = len(self._assembly_component_buttons) + 1
|
||
btn = QPushButton(str(instance_num))
|
||
btn.setCheckable(True)
|
||
btn.setFixedSize(QSize(40, 40))
|
||
btn.setToolTip(f"{ac.name} (instance {instance_num})")
|
||
btn._assembly_component_id = ac.id
|
||
btn.clicked.connect(self._on_assembly_component_clicked)
|
||
self._assembly_component_buttons.append(btn)
|
||
self._assembly_component_group.addButton(btn)
|
||
self._assembly_box_layout.addWidget(btn)
|
||
# Restore the active assembly component selection.
|
||
if assembly.active_assembly_component and assembly.active_assembly_component in assembly.components:
|
||
for b in self._assembly_component_buttons:
|
||
if getattr(b, '_assembly_component_id', None) == assembly.active_assembly_component:
|
||
b.setChecked(True)
|
||
self._selected_assembly_component_id = assembly.active_assembly_component
|
||
break
|
||
|
||
# If the saved view says we're in assembly view, switch over.
|
||
if view_state.get("assembly_view_active") and self._project.assemblies:
|
||
self._assembly_view_active = True
|
||
self._show_assembly_in_viewer(fit=True)
|
||
else:
|
||
self._assembly_view_active = False
|
||
self._redraw_bodies()
|
||
|
||
# Restore camera + active tab.
|
||
self._restore_view_state(view_state)
|
||
self._refresh_lists()
|
||
# Try to activate the saved sketch (without re-rendering the
|
||
# underlay from a now-stale source face — we don't persist those).
|
||
target_sk_id = view_state.get("active_sketch_id")
|
||
if (
|
||
target_sk_id
|
||
and self._current_component
|
||
and target_sk_id in self._current_component.sketches
|
||
):
|
||
sk = self._current_component.sketches[target_sk_id]
|
||
if sk.occ_sketch is not None:
|
||
self._sketch_widget.set_sketch(sk.occ_sketch)
|
||
self._current_sketch = sk
|
||
finally:
|
||
self._suspend_dirty = False
|
||
|
||
# Clear dirty + update title.
|
||
self._project_path = path
|
||
self._dirty = False
|
||
self._update_window_title()
|
||
self._add_recent_project(path)
|
||
self.statusBar().showMessage(f"Opened: {os.path.basename(path)}", 5000)
|
||
logger.info("Opened project: %s", path)
|
||
return True
|
||
|
||
def closeEvent(self, event) -> None:
|
||
"""Prompt to save on window close if there are unsaved changes."""
|
||
if not self._confirm_discard_changes():
|
||
event.ignore()
|
||
return
|
||
event.accept()
|
||
|
||
def _import_file(self):
|
||
filepath, _ = QFileDialog.getOpenFileName(
|
||
self, "Import File", "", "STEP Files (*.step *.stp);;IGES Files (*.iges *.igs)"
|
||
)
|
||
if filepath:
|
||
try:
|
||
if filepath.lower().endswith((".step", ".stp")):
|
||
geometry = self._kernel.import_step(filepath)
|
||
else:
|
||
geometry = self._kernel.import_iges(filepath)
|
||
|
||
if not self._current_component:
|
||
self._current_component = self._project.add_component()
|
||
|
||
body = self._current_component.add_body(
|
||
Body(name="Imported", geometry=geometry, source_operation="import")
|
||
)
|
||
self._mark_dirty()
|
||
|
||
vertices, faces = body.get_mesh(self._kernel)
|
||
body.render_object = self._viewer_3d.add_mesh(
|
||
vertices, faces, body.color, body.name
|
||
)
|
||
|
||
self._refresh_lists()
|
||
self._viewer_3d.fit_camera()
|
||
logger.info(f"Imported: {filepath}")
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to import: {e}")
|
||
|
||
def _export_step(self):
|
||
if not self._selected_body:
|
||
QMessageBox.warning(self, "No Selection", "Please select a body")
|
||
return
|
||
|
||
filepath, _ = QFileDialog.getSaveFileName(
|
||
self, "Export STEP", "", "STEP Files (*.step *.stp)"
|
||
)
|
||
if filepath:
|
||
if self._kernel.export_step(self._selected_body.geometry, filepath):
|
||
logger.info(f"Exported: {filepath}")
|
||
else:
|
||
QMessageBox.warning(self, "Export Failed", "Failed to export STEP")
|
||
|
||
def _export_iges(self):
|
||
if not self._selected_body:
|
||
QMessageBox.warning(self, "No Selection", "Please select a body")
|
||
return
|
||
|
||
filepath, _ = QFileDialog.getSaveFileName(
|
||
self, "Export IGES", "", "IGES Files (*.iges *.igs)"
|
||
)
|
||
if filepath:
|
||
if self._kernel.export_iges(self._selected_body.geometry, filepath):
|
||
logger.info(f"Exported: {filepath}")
|
||
else:
|
||
QMessageBox.warning(self, "Export Failed", "Failed to export IGES")
|
||
|
||
def _export_stl(self):
|
||
if not self._selected_body:
|
||
QMessageBox.warning(self, "No Selection", "Please select a body")
|
||
return
|
||
|
||
filepath, _ = QFileDialog.getSaveFileName(self, "Export STL", "", "STL Files (*.stl)")
|
||
if filepath:
|
||
if self._kernel.export_stl(self._selected_body.geometry, filepath):
|
||
logger.info(f"Exported: {filepath}")
|
||
else:
|
||
QMessageBox.warning(self, "Export Failed", "Failed to export STL")
|
||
|
||
def _fit_view(self):
|
||
self._viewer_3d.fit_camera()
|
||
|
||
def _reset_view(self):
|
||
self._viewer_3d.set_camera_position((100, 100, 100), (0, 0, 0))
|
||
|
||
def _show_about(self):
|
||
QMessageBox.about(
|
||
self,
|
||
"About Fluency CAD",
|
||
"Fluency CAD 2.0\n\n"
|
||
"A parametric CAD application built on:\n"
|
||
"- OpenCASCADE Technology (OCCT)\n"
|
||
"- CadQuery Python bindings\n"
|
||
"- pygfx WebGPU renderer\n\n"
|
||
"Features:\n"
|
||
"- STEP/IGES import/export\n"
|
||
"- Parametric sketching\n"
|
||
"- Boolean operations\n"
|
||
"- Fillets and chamfers\n"
|
||
"- Component timeline",
|
||
)
|
||
|
||
|