Files
fluencyCAD/src/fluency/ui/main_window.py
T
2026-07-11 21:29:58 +02:00

3807 lines
160 KiB
Python
Raw Blame History

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