"""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"{t1} on {first_ac.name} → {t2} 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", )