"""Main application window — application shell, menus, panels, operations."""
from __future__ import annotations
import math
import logging
import os
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, Slot, QSize, QSettings
from PySide6.QtGui import (
QAction,
QColor,
QFont,
QKeySequence,
)
MAX_RECENT_PROJECTS = 10
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QDialog,
QDoubleSpinBox,
QFileDialog,
QFrame,
QHBoxLayout,
QInputDialog,
QLabel,
QListWidgetItem,
QMainWindow,
QMenu,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.geometry_occ.sketch import OCCSketch
from fluency.io.project_io import load_project, project_zip_path, save_project
from fluency.models.data_model import Project, Component, Sketch, Body, Workplane, Feature
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 _make_body_thumbnail(
body,
kernel,
size: QSize = QSize(64, 48),
):
"""Render a small isometric thumbnail of a body using Pillow.
Returns a QPixmap or None on failure.
"""
try:
import numpy as np
from PIL import Image, ImageDraw
from PySide6.QtGui import QImage, QPixmap
verts, faces = body.get_mesh(kernel)
if verts is None or len(verts) == 0:
return None
verts = np.asarray(verts, dtype=np.float64)
faces = np.asarray(faces, dtype=np.int32)
mins = verts.min(axis=0)
maxs = verts.max(axis=0)
center = (mins + maxs) / 2
extent = maxs - mins
max_dim = max(extent)
if max_dim < 1e-10:
return None
# Normalize vertices to [-1, 1] range centered at origin
v = (verts - center) / (max_dim * 0.7)
# Simple isometric projection (rotation + orthographic)
angle_y = np.radians(-45)
angle_x = np.radians(25)
cos_y, sin_y = np.cos(angle_y), np.sin(angle_y)
cos_x, sin_x = np.cos(angle_x), np.sin(angle_x)
# Rotate Y
x1 = v[:, 0] * cos_y - v[:, 2] * sin_y
z1 = v[:, 0] * sin_y + v[:, 2] * cos_y
y1 = v[:, 1]
# Rotate X
y2 = y1 * cos_x - z1 * sin_x
z2 = y1 * sin_x + z1 * cos_x
x2 = x1
# Project to 2D (orthographic)
w_px, h_px = size.width(), size.height()
# Compute 2D bounding box of projected vertices for tight framing
all_px = x2
all_py = -y2
px_min, px_max = all_px.min(), all_px.max()
py_min, py_max = all_py.min(), all_py.max()
span_x = px_max - px_min
span_y = py_max - py_min
if span_x < 1e-10 or span_y < 1e-10:
return None
# Scale to fill ~90% of the image
margin = 0.10
avail_w = w_px * (1.0 - margin)
avail_h = h_px * (1.0 - margin)
scale = min(avail_w / span_x, avail_h / span_y)
center_x = (px_min + px_max) / 2
center_y = (py_min + py_max) / 2
px = (all_px * scale + w_px / 2 - center_x * scale).astype(np.float64)
py = (all_py * scale + h_px / 2 - center_y * scale).astype(np.float64)
depth = z2 # for painter's algorithm
# Dark grey background
img = Image.new("RGBA", (w_px, h_px), (55, 55, 60, 255))
draw = ImageDraw.Draw(img)
# Compute face normals for backface culling & shading
v0 = np.stack([px[faces[:, 0]], py[faces[:, 0]], depth[faces[:, 0]]], axis=1)
v1 = np.stack([px[faces[:, 1]], py[faces[:, 1]], depth[faces[:, 1]]], axis=1)
v2 = np.stack([px[faces[:, 2]], py[faces[:, 2]], depth[faces[:, 2]]], axis=1)
# 2D cross product for winding
cross = (v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) - (v1[:, 1] - v0[:, 1]) * (
v2[:, 0] - v0[:, 0]
)
# Average depth per face
avg_depth = (v0[:, 2] + v1[:, 2] + v2[:, 2]) / 3.0
# Sort faces by depth (painter's algorithm: draw far faces first)
order = np.argsort(-avg_depth)
# Ceramic white body with shading
base_r, base_g, base_b = 220, 218, 215
for i in order:
# Backface culling
if cross[i] <= 0:
continue
pts = [
(float(px[faces[i, 0]]), float(py[faces[i, 0]])),
(float(px[faces[i, 1]]), float(py[faces[i, 1]])),
(float(px[faces[i, 2]]), float(py[faces[i, 2]])),
]
# Shading: stronger contrast for depth perception
brightness = 0.5 + 0.5 * max(0.0, min(1.0, (avg_depth[i] + 1) / 2))
r = int(base_r * brightness)
g = int(base_g * brightness)
b = int(base_b * brightness)
draw.polygon(pts, fill=(r, g, b, 255))
# Convert PIL image to QPixmap
data = img.tobytes("raw", "RGBA")
qimg = QImage(data, w_px, h_px, w_px * 4, QImage.Format_RGBA8888)
pixmap = QPixmap.fromImage(qimg.copy())
return pixmap
except Exception as e:
logger.debug(f"Thumbnail generation failed: {e}")
return None
def _make_component_thumbnail(
component,
kernel,
size: QSize = QSize(96, 96),
):
"""Render a small isometric thumbnail of all bodies in a component.
Combines the meshes of all visible bodies and renders them together.
Returns a QPixmap or None on failure.
"""
try:
import numpy as np
from PIL import Image, ImageDraw
from PySide6.QtGui import QImage, QPixmap
# Collect meshes from all visible bodies with geometry
all_verts = []
all_faces = []
vertex_offset = 0
for body in component.bodies.values():
if not body.visible or not body.geometry:
continue
verts, faces = body.get_mesh(kernel)
if verts is None or len(verts) == 0:
continue
verts = np.asarray(verts, dtype=np.float64)
faces = np.asarray(faces, dtype=np.int32)
# Apply body transform
if hasattr(body, "position") and body.position is not None:
if hasattr(body, "rotation") and body.rotation is not None:
verts = verts @ body.rotation.T
verts = verts + body.position
all_verts.append(verts)
all_faces.append(faces + vertex_offset)
vertex_offset += len(verts)
if not all_verts:
return None
verts = np.concatenate(all_verts, axis=0)
faces = np.concatenate(all_faces, axis=0)
mins = verts.min(axis=0)
maxs = verts.max(axis=0)
center = (mins + maxs) / 2
extent = maxs - mins
max_dim = max(extent)
if max_dim < 1e-10:
return None
# Normalize vertices to [-1, 1] range centered at origin
v = (verts - center) / (max_dim * 0.7)
# Simple isometric projection (rotation + orthographic)
angle_y = np.radians(-45)
angle_x = np.radians(25)
cos_y, sin_y = np.cos(angle_y), np.sin(angle_y)
cos_x, sin_x = np.cos(angle_x), np.sin(angle_x)
# Rotate Y
x1 = v[:, 0] * cos_y - v[:, 2] * sin_y
z1 = v[:, 0] * sin_y + v[:, 2] * cos_y
y1 = v[:, 1]
# Rotate X
y2 = y1 * cos_x - z1 * sin_x
z2 = y1 * sin_x + z1 * cos_x
x2 = x1
# Project to 2D (orthographic)
w_px, h_px = size.width(), size.height()
# Compute 2D bounding box of projected vertices for tight framing
all_px = x2
all_py = -y2
px_min, px_max = all_px.min(), all_px.max()
py_min, py_max = all_py.min(), all_py.max()
span_x = px_max - px_min
span_y = py_max - py_min
if span_x < 1e-10 or span_y < 1e-10:
return None
# Scale to fill ~90% of the image
margin = 0.10
avail_w = w_px * (1.0 - margin)
avail_h = h_px * (1.0 - margin)
scale = min(avail_w / span_x, avail_h / span_y)
center_x = (px_min + px_max) / 2
center_y = (py_min + py_max) / 2
px = (all_px * scale + w_px / 2 - center_x * scale).astype(np.float64)
py = (all_py * scale + h_px / 2 - center_y * scale).astype(np.float64)
depth = z2 # for painter's algorithm
# Dark grey background
img = Image.new("RGBA", (w_px, h_px), (55, 55, 60, 255))
draw = ImageDraw.Draw(img)
# Compute face normals for backface culling & shading
v0 = np.stack([px[faces[:, 0]], py[faces[:, 0]], depth[faces[:, 0]]], axis=1)
v1 = np.stack([px[faces[:, 1]], py[faces[:, 1]], depth[faces[:, 1]]], axis=1)
v2 = np.stack([px[faces[:, 2]], py[faces[:, 2]], depth[faces[:, 2]]], axis=1)
# 2D cross product for winding
cross = (v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) - (v1[:, 1] - v0[:, 1]) * (
v2[:, 0] - v0[:, 0]
)
# Average depth per face
avg_depth = (v0[:, 2] + v1[:, 2] + v2[:, 2]) / 3.0
# Sort faces by depth (painter's algorithm: draw far faces first)
order = np.argsort(-avg_depth)
# Ceramic white body with shading
base_r, base_g, base_b = 220, 218, 215
for i in order:
# Backface culling
if cross[i] <= 0:
continue
pts = [
(float(px[faces[i, 0]]), float(py[faces[i, 0]])),
(float(px[faces[i, 1]]), float(py[faces[i, 1]])),
(float(px[faces[i, 2]]), float(py[faces[i, 2]])),
]
# Shading: stronger contrast for depth perception
brightness = 0.5 + 0.5 * max(0.0, min(1.0, (avg_depth[i] + 1) / 2))
r = int(base_r * brightness)
g = int(base_g * brightness)
b = int(base_b * brightness)
draw.polygon(pts, fill=(r, g, b, 255))
# Convert PIL image to QPixmap
data = img.tobytes("raw", "RGBA")
qimg = QImage(data, w_px, h_px, w_px * 4, QImage.Format_RGBA8888)
pixmap = QPixmap.fromImage(qimg.copy())
return pixmap
except Exception as e:
logger.debug(f"Component thumbnail generation failed: {e}")
return None
# ── Button sizing & styling constants ────────────────────────────────
_BTN_MIN = 40 # minimum button dimension (px)
_BTN_MAX = 160 # maximum button dimension (px)
_BTN_PAD = 4 # padding between icon and button edge
_BTN_UNSELECTED_STYLE = (
"QPushButton {"
" background-color: #3a3a3e;"
" border: 2px solid #555560;"
" border-radius: 6px;"
"}"
"QPushButton:hover {"
" background-color: #4a4a50;"
"}"
"QPushButton:checked {"
" background-color: #2d5f8a;"
" border: 2px solid #4da6ff;"
"}"
)
def _set_button_style(btn: QPushButton) -> None:
"""Apply the standard button style sheet."""
btn.setStyleSheet(_BTN_UNSELECTED_STYLE)
def _resize_row_buttons(scroll_area: QScrollArea, buttons: list[QPushButton]) -> None:
"""Resize every button in *buttons* to fill the scroll area height.
Computes a square size from the parent viewport height so buttons
always use all available vertical space. Width follows height
(1∶1 aspect) clamped to [_BTN_MIN, _BTN_MAX].
"""
vh = scroll_area.viewport().height()
if vh < 20 or not buttons:
return
size = max(_BTN_MIN, min(_BTN_MAX, vh - 8)) # 8 px margin
for btn in buttons:
btn.setFixedSize(QSize(size, size))
icon_size = QSize(size - _BTN_PAD, size - _BTN_PAD)
if btn.icon().isNull():
continue
btn.setIconSize(icon_size)
def _scroll_to_button(btn: QPushButton, scroll_area: QScrollArea) -> None:
"""Ensure *btn* is visible inside a horizontal scroll area."""
ha = scroll_area.horizontalScrollBar()
viewport_w = scroll_area.viewport().width()
parent = btn.parent()
if parent:
x = btn.pos().x()
right = x + btn.width()
scroll_left = ha.value()
scroll_right = scroll_left + viewport_w
if x < scroll_left:
ha.setValue(x)
elif right > scroll_right:
ha.setValue(right - viewport_w)
def _create_component_button(
num: int,
name: str,
component,
kernel,
group: QButtonGroup,
layout: QHBoxLayout,
click_handler,
scroll_area: QScrollArea,
) -> QPushButton:
"""Create a component button with thumbnail."""
btn = QPushButton(str(num))
btn.setCheckable(True)
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(name)
btn.clicked.connect(click_handler)
_set_button_style(btn)
# Render thumbnail from all bodies in the component
has_geometry = any(b.visible and b.geometry for b in component.bodies.values())
if has_geometry:
pixmap = _make_component_thumbnail(component, kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
btn.setText("")
group.addButton(btn)
layout.addWidget(btn)
return btn
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
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).
"""
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
# ── Parametric feature replay ──────────────────────────────────────────────
def _ensure_feature_history(body: Body) -> List[Feature]:
"""Return *body*'s feature list, migrating legacy bodies lazily.
Bodies saved before feature history existed only carry the flat
``source_sketch`` / ``extrude_*`` fields describing the LAST
operation. Migration synthesizes a feature list from them:
- plain extrude → a single "extrude" feature (replays cleanly,
no information was lost);
- cut / union → a frozen "base" snapshot of the current geometry
plus the cut/union feature. The previous operation's effect
stays baked into the snapshot (a permanent scar), but every
FUTURE sketch edit re-applies cleanly instead of duplicating.
"""
if body.features:
return body.features
if body.source_sketch is None or body.extrude_length is None:
return [] # imported / baked body — nothing parametric to replay
if body.extrude_cut or body.extrude_union:
if body.geometry is not None:
body.features.append(Feature(operation="base", geometry=body.geometry))
op = "cut" if body.extrude_cut else "union"
else:
op = "extrude"
body.features.append(
Feature(
operation=op,
sketch=body.source_sketch,
length=body.extrude_length,
symmetric=body.extrude_symmetric,
invert=body.extrude_invert,
through_all=body.extrude_through_all,
cut_all_bodies=body.extrude_cut_all_bodies,
face_index=body.extrude_face_index,
)
)
logger.info(f"Body '{body.name}': migrated legacy params to feature history")
return body.features
def _feature_face_geometry(body: Body, feat: Feature, occ_sketch: OCCSketch) -> Optional[Any]:
"""Resolve the profile geometry for a feature's sketch.
Prefers the stored selected face (which can include holes);
falls back to the whole-sketch profile when the face topology
changed or no face was selected.
"""
face_geom: Optional[Any] = None
if feat.face_index is not None:
faces = occ_sketch.detect_faces()
if 0 <= feat.face_index < len(faces):
face_geom = occ_sketch.build_face_geometry(faces[feat.face_index])
elif faces:
# Face index out of range (topology changed) — fall back
# to the first face.
face_geom = occ_sketch.build_face_geometry(faces[0])
logger.info(
f"Body '{body.name}': face index {feat.face_index} invalid, fell back to face 0"
)
if face_geom is None:
face_geom = occ_sketch.get_geometry()
return face_geom
def _replay_body_features(
kernel: OCGeometryKernel,
body: Body,
features: List[Feature],
through_all_length_fn: Callable[[Any, Sketch], float],
) -> Optional[Any]:
"""Replay *features* in order and return the resulting geometry.
Returns *None* when the replay cannot complete (missing sketch,
empty profile, failed kernel op) — the caller then keeps the
body's previous geometry.
"""
geom: Optional[Any] = None
for feat in features:
if feat.operation == "base":
geom = feat.geometry
continue
sketch = feat.sketch
if sketch is None or sketch.occ_sketch is None:
logger.warning(
f"Body '{body.name}': {feat.operation} feature has no sketch, replay aborted"
)
return None
# Re-solve the sketch so geometry reflects any edits.
sketch.apply_workplane()
sketch.solve()
face_geom = _feature_face_geometry(body, feat, sketch.occ_sketch)
if face_geom is None:
logger.warning(
f"Body '{body.name}': no profile geometry for {feat.operation}, replay aborted"
)
return None
if feat.operation == "revolve":
geom = kernel.revolve(face_geom, feat.angle)
if geom is None:
return None
continue
# extrude / cut / union all need the extruded profile as tool.
if feat.through_all and geom is not None:
# Pass-through: size the tool against the solid built so far.
length = through_all_length_fn(geom, sketch)
symmetric = True
invert = False
elif feat.operation == "cut" and geom is not None:
# Mirror _compute_extrude_result: a cut tool must go INTO
# the solid (the picked face's outward normal points AWAY),
# so force the extrude direction inward.
length = feat.length if feat.length is not None else 10.0
symmetric = feat.symmetric
invert = True
else:
length = feat.length if feat.length is not None else 10.0
symmetric = feat.symmetric
invert = feat.invert
tool_geom = kernel.extrude(face_geom, -length if invert else length, symmetric=symmetric)
if tool_geom is None:
return None
if feat.operation == "extrude":
geom = tool_geom # plain extrude: the tool IS the result
elif feat.operation == "cut":
if geom is None:
logger.warning(f"Body '{body.name}': cut feature has no base, replay aborted")
return None
geom = kernel.boolean_difference(geom, tool_geom)
elif feat.operation == "union":
if geom is None:
logger.warning(f"Body '{body.name}': union feature has no base, replay aborted")
return None
geom = kernel.boolean_union(geom, tool_geom)
else:
logger.warning(f"Body '{body.name}': unknown feature op '{feat.operation}', skipped")
if geom is None:
return None
return geom
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)
# ── Edit menu (runtime-only, not in the .ui) ──
edit_menu = self.menuBar().addMenu("&Edit")
self._action_undo = QAction("Undo", self)
self._action_undo.setShortcut(QKeySequence("Ctrl+Z"))
self._action_undo.triggered.connect(self._undo_sketch)
self._action_undo.setEnabled(False)
edit_menu.addAction(self._action_undo)
self._action_redo = QAction("Redo", self)
self._action_redo.setShortcut(QKeySequence("Ctrl+Shift+Z"))
self._action_redo.triggered.connect(self._redo_sketch)
self._action_redo.setEnabled(False)
edit_menu.addAction(self._action_redo)
# ── View menu (runtime-only, not in the .ui) ──
view_menu = self.menuBar().addMenu("&View")
view_menu.addAction("Fit All", self._fit_view)
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)
view_menu.addSeparator()
self._action_render = QAction("Render...", self)
self._action_render.setShortcut(QKeySequence("Ctrl+R"))
self._action_render.triggered.connect(self._open_render_window)
view_menu.addAction(self._action_render)
# ── Help menu (runtime-only, not in the .ui) ──
help_menu = self.menuBar().addMenu("&Help")
help_menu.addAction("About", self._show_about)
def _create_central_widget(self):
"""Load the compiled UI file and add programmatic custom widgets."""
self._ui = Ui_fluencyCAD()
self._ui.setupUi(self)
# Keep a reference to the grid for panel-focus management.
self._grid = self._ui.gridLayout
# -- Add programmatic custom widgets to their placeholder locations --
# Sketch2DWidget goes in the sketch tab’s QVBoxLayout.
self._sketch_widget = Sketch2DWidget()
self._ui.sketch_tab.layout().addWidget(self._sketch_widget)
# Viewer3DWidget goes in the gl_box QHBoxLayout.
self._viewer_3d = Viewer3DWidget()
self._ui.gl_box.layout().addWidget(self._viewer_3d)
# Code editor — use the UI's textEdit with our custom font.
self._code_edit = self._ui.textEdit
self._code_edit.setFont(QFont("Monaco", 10))
self._code_edit.setPlaceholderText("# Enter Python code here...")
# ── Render tab (adds "Render" tab to InputTab) ─────────────
from fluency.ui.render_window import RenderTabContent
self._render_tab = RenderTabContent()
self._ui.InputTab.addTab(self._render_tab, "Render")
# Component buttons (dynamically generated per component, not in UI).
# Wrapped in a QScrollArea so many components can scroll horizontally.
self._component_box = QWidget()
self._component_box_layout = QHBoxLayout(self._component_box)
self._component_box_layout.setAlignment(Qt.AlignLeft)
self._component_box_layout.setContentsMargins(2, 2, 2, 2)
self._component_group = QButtonGroup(self)
self._component_group.setExclusive(True)
self._component_scroll = QScrollArea()
self._component_scroll.setWidget(self._component_box)
self._component_scroll.setWidgetResizable(True)
self._component_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self._component_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._component_scroll.setFrameShape(QFrame.NoFrame)
self._component_scroll.setMaximumHeight(95)
# 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_scroll)
# ── 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_box_layout.setContentsMargins(2, 2, 2, 2)
self._assembly_component_group = QButtonGroup(self)
self._assembly_component_group.setExclusive(True)
self._assembly_scroll = QScrollArea()
self._assembly_scroll.setWidget(self._assembly_box)
self._assembly_scroll.setWidgetResizable(True)
self._assembly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self._assembly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._assembly_scroll.setFrameShape(QFrame.NoFrame)
self._assembly_scroll.setMaximumHeight(95)
# 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_scroll)
# ── 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)
# 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
self._btn_con_diameter = ui.pb_con_diameter
# ── 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_body_hide = ui.pb_body_hide
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
# ── Connection list ──
self._connection_list = ui.connection_list
self._btn_del_connection = ui.pb_del_connection
# ── Code tab ──
self._btn_apply_code = ui.pb_apply_code
self._btn_load_code = ui.pushButton_5
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_con_diameter.clicked.connect(lambda: self._set_sketch_mode("constrain_diameter"))
self._btn_snap_point.clicked.connect(
lambda c: self._sketch_widget.set_snap_mode("point", c)
)
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._btn_del_connection.clicked.connect(self._on_delete_connection_from_list)
self._viewer_3d.connectorPicked.connect(self._on_connector_picked)
self._viewer_3d.connectorHover.connect(self._on_connector_hover)
self._viewer_3d.connectorPickCancelled.connect(
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._update_and_redraw)
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_widget.solver_warning.connect(self._on_solver_warning)
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)
# Per-body hide/show toggle: the user clicks pb_body_hide 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.
self._btn_body_hide.clicked.connect(self._on_body_hide_button_clicked)
# Snap toggle
self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c))
# Sync 3D viewport camera → render tab when rendering is active.
self._viewer_3d.cameraChanged.connect(self._on_camera_changed)
# Auto-load selected body when switching to the Render tab.
self._ui.InputTab.currentChanged.connect(self._on_tab_changed)
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,
self._btn_con_diameter,
]:
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):
"""Handle the "Cstrct" toolbar button.
The button is checkable, so a click both toggles the construct
mode for *new* geometry and (when the user has hovered an
existing line) promotes that line to a construction line. The
line-conversion path lets a user "select" a line by hovering
it and then press the construction button to convert it — the
button stays in the "on" state so subsequent new geometry is
also created as construction.
If no line is hovered the click is the original pure
construct-mode toggle for new geometry.
"""
# 1) Try to convert the hovered line first. This mirrors the
# existing C-key shortcut (``_toggle_hovered_line_construction``
# on the sketch widget) but is exposed publicly so the
# toolbar button can drive the same action.
converted = self._sketch_widget.convert_hovered_line_to_construction()
if converted:
# A line was promoted — ensure the button reflects the
# "construction mode is on" state regardless of what the
# user just clicked. Without this, clicking the button
# to turn it OFF while a line was hovered would silently
# re-promote that line AND drop construct mode for new
# geometry, which is confusing. Forcing the button on
# makes the action unambiguous: "make this construction".
self._btn_construct.setChecked(True)
self._sketch_widget.set_construct_mode(True)
return
# 2) No line hovered — fall back to the original behaviour:
# toggle construct mode for *new* geometry.
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,
self._btn_con_diameter,
]:
btn.setChecked(False)
self._sketch_widget.set_mode(None)
def _on_solver_warning(self, message: str) -> None:
"""Show solver-failure messages in the status bar.
Connected to ``Sketch2DWidget.solver_warning``, which fires when
the SolveSpace solver returns a non-OKAY result (INCONSISTENT,
DIDNT_CONVERGE, …). Without this hook the geometry would just
silently stay put and the user would think the constraint had
no effect; with the status-bar message the failure is obvious
and includes a one-line hint (e.g. "the new constraint
conflicts with existing constraints").
The message stays visible for 8 seconds — long enough to read
but short enough not to be annoying if the user fixes the
issue and continues editing.
"""
self.statusBar().showMessage(f"⚠ {message}", 8000)
def _on_sketch_updated(self):
"""Mark bodies as needing update when their source sketch changes.
A body is marked when ANY feature in its history references the
edited sketch (the flat ``source_sketch`` only mirrors the last
feature). The marking then propagates transitively: sketches
hosted on faces of a marked body follow its geometry, so bodies
built from THOSE sketches are marked too, and so on down the
dependency chain.
"""
if not self._current_component or not self._current_sketch:
return
sketch = self._current_sketch
def _body_sketches(b: Body) -> List[Sketch]:
out = [f.sketch for f in b.features if f.sketch is not None]
if b.source_sketch is not None:
out.append(b.source_sketch)
return out
affected: set = set()
for body in self._current_component.bodies.values():
if sketch in _body_sketches(body):
affected.add(body.id)
# Transitive closure: a sketch sitting on a face of an affected
# body moves with it → bodies using that sketch are affected too.
changed = True
while changed:
changed = False
for body in self._current_component.bodies.values():
if body.id in affected:
continue
for s in _body_sketches(body):
if getattr(s, "_source_body_id", None) in affected:
affected.add(body.id)
changed = True
break
for body in self._current_component.bodies.values():
if body.id in affected:
body.needs_update = True
self._refresh_lists()
# Update undo/redo menu actions
self._update_undo_redo_actions()
def _get_active_component_index(self) -> int:
for i, btn in enumerate(self._component_buttons):
if btn.isChecked():
return i
return 0
def _update_component_thumbnail(self, component_index: int) -> None:
"""Update the thumbnail icon on a component button."""
if not (0 <= component_index < len(self._component_buttons)):
return
comp_ids = list(self._project.components.keys())
if component_index >= len(comp_ids):
return
comp = self._project.components[comp_ids[component_index]]
# Check if component has any bodies with geometry
has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
if not has_geometry:
return
btn = self._component_buttons[component_index]
pixmap = _make_component_thumbnail(comp, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
btn.setText("")
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_num = len(self._project.components)
btn = QPushButton(str(btn_num))
btn.setCheckable(True)
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(comp.name)
btn.clicked.connect(self._on_component_button_clicked)
btn.setChecked(True)
_set_button_style(btn)
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)
# Scroll to the new button.
_scroll_to_button(btn, self._component_scroll)
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("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()
# Scroll to the selected button.
if 0 <= idx < len(self._component_buttons):
_scroll_to_button(self._component_buttons[idx], self._component_scroll)
def _refresh_lists(self):
self._sketch_list.clear()
self._body_list.clear()
self._refresh_connection_list()
if self._current_component:
for sketch_id, sketch in self._current_component.sketches.items():
self._sketch_list.addItem(sketch.name)
for body_id, body in self._current_component.bodies.items():
# QListWidgetItem with a data role so the toggle handler can
# look up the right body without relying on display text.
display_name = body.name
if body.needs_update:
display_name = f"⚠ {body.name}"
item = QListWidgetItem(display_name)
item.setData(Qt.UserRole, body_id)
# Greying out a hidden body's name is a nice UX touch.
if not body.visible:
item.setForeground(QColor("#6c7086"))
elif body.needs_update:
item.setForeground(QColor("#f5a623")) # Orange warning
self._body_list.addItem(item)
def _update_and_redraw(self):
"""Full pipeline: rebuild bodies, redraw, propagate to assembly.
Connected to the 'Update Body' button. Bodies are rebuilt by
replaying their feature history; sketches hosted on body faces
are then re-projected and solved. Because a face-sketch's
geometry depends on the body it sits on, the bodies→sketches
cycle repeats until nothing moves anymore (bounded), so changes
to sketches early in the design propagate all the way down the
dependent chain. Then the view, assembly instances and
connectors are refreshed.
"""
MAX_PASSES = 5
for _ in range(MAX_PASSES):
self._update_bodies_from_sketch()
if self._update_sketches_from_bodies() == 0:
break
else:
# Still converging after the cap — one last body pass so the
# final geometry is built from the freshest sketch state.
self._update_bodies_from_sketch()
self._redraw_bodies()
self._propagate_to_assembly()
self._recalculate_connectors()
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
def _update_bodies_from_sketch(self):
"""Rebuild bodies by replaying their parametric feature history.
Every body with a feature list is rebuilt FROM SCRATCH: the base
feature (extrude / revolve / snapshot) is recomputed from its
source sketch, then each cut / union re-applies in order against
the freshly built geometry. Because the base is rebuilt clean,
a moved sketch entity REPLACES its previous effect instead of
piling on top of it (e.g. a moved circle re-cuts one hole at the
new position — the old hole is gone).
Legacy bodies without a feature list are migrated lazily (see
:meth:`_ensure_feature_history`).
"""
if not self._current_component:
return
updated = 0
for body_id, body in list(self._current_component.bodies.items()):
features = _ensure_feature_history(body)
if not features:
continue # imported / baked body — nothing parametric
try:
new_geom = _replay_body_features(
self._kernel, body, features, self._through_all_length_for_geometry
)
except Exception as exc:
logger.exception(f"Body '{body.name}': feature replay failed: {exc}")
continue
if new_geom is None:
# Replay aborted — keep the previous geometry and the
# needs_update flag so the ⚠ marker stays visible.
continue
body.geometry = new_geom
body.needs_update = False
body.modified_at = datetime.now()
updated += 1
logger.info(f"Rebuilt body from features: {body.name}")
if updated > 0:
logger.info(f"Updated {updated} body(ies) from sketch")
def _update_sketches_from_bodies(self) -> int:
"""Re-project underlay construction lines from updated 3D bodies.
For every sketch in the current component that carries a
``_source_face`` (a face-projected underlay) and a
``_source_body_id``, find the corresponding face on the updated
body geometry, re-project its edges to UV, and update the sketch's
external entities *in place* (preserving entity ids so existing
constraints survive). The solver is re-run so any user geometry
anchored to the underlay follows the body.
Returns the number of sketches whose underlay actually moved —
the caller (``_update_and_redraw``) uses this to decide whether
another body-rebuild pass is needed.
"""
if not self._current_component:
return 0
from fluency.geometry_occ.kernel import OCGeometryKernel
kernel = OCGeometryKernel()
updated = 0
for sketch in self._current_component.sketches.values():
src_body_id = getattr(sketch, "_source_body_id", None)
src_face = getattr(sketch, "_source_face", None)
if src_body_id is None or src_face is None:
continue
if sketch.occ_sketch is None:
continue
body = self._current_component.bodies.get(src_body_id)
if body is None or body.geometry is None:
continue
body_shape = kernel._get_shape(body.geometry)
if body_shape is None:
continue
# Find the face on the updated body that matches the original
# face's plane (normal parallel, origin coplanar).
wp = sketch.occ_sketch.get_workplane()
origin, normal = wp[0], wp[1]
ref_center = getattr(sketch, "_source_face_center", None)
match = OCGeometryKernel.find_coplanar_face(
body_shape,
origin,
normal,
ref_center=ref_center,
)
if match is None:
logger.debug(
"Sketch '%s': no matching face on body '%s', skipping",
sketch.name,
body.name,
)
continue
new_face, new_center = match
sketch._source_face = new_face
sketch._source_face_center = new_center
# Re-project the new face's edges into UV.
from fluency.ui.sketch_widget import _project_face_to_uv
try:
polys = _project_face_to_uv(new_face, wp)
except Exception as exc:
logger.debug("re-projection failed for sketch '%s': %s", sketch.name, exc)
continue
if not polys:
continue
# Update external entities in-place (preserves ids + constraints).
ok = sketch.occ_sketch.update_external_entities(polys)
if ok:
updated += 1
logger.info(
"Re-projected underlay for sketch '%s' from body '%s'",
sketch.name,
body.name,
)
# If the sketch is currently loaded in the widget, refresh
# the underlay data so the view reflects the new projection
# WITHOUT re-importing (which would break constraints).
if sketch.occ_sketch is self._sketch_widget._sketch:
self._sketch_widget._source_face = new_face
self._sketch_widget._source_underlay_uv = polys
self._sketch_widget._rebuild_from_sketch()
self._sketch_widget.update()
if updated > 0:
logger.info("Re-projected underlays for %d sketch(es)", updated)
return updated
def _propagate_to_assembly(self):
"""Refresh all assembly instances that reference the current component.
Called after ``_update_bodies_from_sketch`` so the assembly view
shows the updated bodies without a full scene rebuild.
"""
if not self._current_component:
return
comp_id = self._current_component.id
for assembly in self._project.assemblies.values():
for ac_id, ac in assembly.components.items():
if ac.component_id == comp_id:
self._update_assembly_component_in_viewer(ac_id)
def _recalculate_connectors(self):
"""Smart recalculation of connector positions after body update.
For each assembly instance of the current component, probe the
updated geometry near each connector's world position. If a
matching snap candidate is found within tolerance, update the
connector's local position and normal. Otherwise mark it invalid.
"""
import numpy as np
if not self._current_component:
return
comp_id = self._current_component.id
TOLERANCE = 5.0 # mm — max distance to consider a match
updated = 0
invalidated = 0
for assembly in self._project.assemblies.values():
for ac_id, ac in assembly.components.items():
if ac.component_id != comp_id:
continue
for conn_id, conn in list(ac.connectors.items()):
# Compute the connector's current world position.
local_pos = np.array(conn.position, dtype=float)
world_pos = ac.position + ac.rotation @ local_pos
# Project to screen and probe nearby geometry.
screen = self._viewer_3d._renderer._project_to_screen(tuple(world_pos))
if screen is None:
continue
try:
candidates = self._viewer_3d._renderer.probe_snap_candidates(
screen[0],
screen[1],
radius=20,
)
except Exception:
continue
if not candidates:
# No geometry nearby — connector may be orphaned.
if not conn.is_invalid:
conn.is_invalid = True
invalidated += 1
logger.info(
f"Connector '{conn.name}' on {ac.name}: "
f"no geometry nearby, marked invalid"
)
continue
# Find the nearest candidate of matching type.
best = None
best_dist = float("inf")
for cand in candidates:
cand_pos = np.array(cand["position"], dtype=float)
dist = float(np.linalg.norm(cand_pos - world_pos))
if dist < best_dist:
best_dist = dist
best = cand
if best is not None and best_dist <= TOLERANCE:
# Update connector to new local coords.
new_world = np.array(best["position"], dtype=float)
new_local = ac.rotation.T @ (new_world - ac.position)
conn.position = tuple(float(v) for v in new_local)
if best.get("normal") is not None:
new_normal = ac.rotation.T @ np.array(best["normal"], dtype=float)
conn.normal = tuple(float(v) for v in new_normal)
if best.get("x_dir") is not None:
new_xdir = ac.rotation.T @ np.array(best["x_dir"], dtype=float)
conn.x_dir = tuple(float(v) for v in new_xdir)
conn.is_invalid = False
conn.modified_at = datetime.now()
updated += 1
logger.info(
f"Connector '{conn.name}' on {ac.name}: "
f"updated ({best_dist:.1f}mm shift)"
)
elif not conn.is_invalid:
conn.is_invalid = True
invalidated += 1
logger.info(
f"Connector '{conn.name}' on {ac.name}: "
f"no close match ({best_dist:.1f}mm), marked invalid"
)
if updated or invalidated:
logger.info(f"Connector recalc: {updated} updated, {invalidated} invalidated")
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(100, 100))
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)
_set_button_style(btn)
# Thumbnail from all bodies in the component.
src_comp = self._current_component
if src_comp:
has_geometry = any(b.visible and b.geometry for b in src_comp.bodies.values())
if has_geometry:
pixmap = _make_component_thumbnail(src_comp, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
# 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)
# Scroll to the selected button.
for btn in self._assembly_component_buttons:
if getattr(btn, "_assembly_component_id", None) == active_id:
_scroll_to_button(btn, self._assembly_scroll)
break
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
conn_color = (1.0, 0.1, 0.1) if conn.is_invalid else (1.0, 0.3, 0.0)
sphere_shape = self._make_connector_marker(tuple(world_pos), color=conn_color)
if sphere_shape is not None:
self._viewer_3d.show_shape(
sphere_shape,
color=conn_color,
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
conn_color = (1.0, 0.1, 0.1) if conn.is_invalid else (1.0, 0.3, 0.0)
sphere_shape = self._make_connector_marker(tuple(world_pos), color=conn_color)
if sphere_shape is not None:
self._viewer_3d.show_shape(
sphere_shape,
color=conn_color,
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._refresh_connection_list()
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 QHBoxLayout, QPushButton
if second_ac is None:
return (None, None, False)
# The component to preview adjustments on — defaults to second_ac
# but can be overridden via mover_ac (for anchor-aware solving).
preview_target = mover_ac if mover_ac is not None else second_ac
dialog = QDialog(self)
dialog.setWindowTitle("Connector — Connection Properties")
dialog.setMinimumWidth(340)
layout = QVBoxLayout(dialog)
entity_names = {
"planar_face": "Face",
"cylindrical_face": "Hole",
"edge": "Edge",
"vertex": "Vertex",
}
t1 = entity_names.get(first_pick.get("entity_type", ""), "Entity")
t2 = entity_names.get(second_pick.get("entity_type", ""), "Entity")
layout.addWidget(
QLabel(f"{t1} on {first_ac.name} → {t2} on {second_ac.name}")
)
layout.addWidget(QLabel("Adjust the connection:"))
# Rotation around normal axis.
rot_layout = QHBoxLayout()
rot_layout.addWidget(QLabel("Rotation around axis (°):"))
rotation_spin = QDoubleSpinBox()
rotation_spin.setDecimals(1)
rotation_spin.setRange(-360, 360)
rotation_spin.setValue(0.0)
rotation_spin.setSuffix("°")
rot_layout.addWidget(rotation_spin)
layout.addLayout(rot_layout)
# Offset along normal.
off_layout = QHBoxLayout()
off_layout.addWidget(QLabel("Offset along normal (mm):"))
offset_spin = QDoubleSpinBox()
offset_spin.setDecimals(2)
offset_spin.setRange(-10000, 10000)
offset_spin.setValue(0.0)
off_layout.addWidget(offset_spin)
layout.addLayout(off_layout)
# Flip alignment direction.
flip_check = QCheckBox("Flip connection direction (normals opposed)")
flip_check.setChecked(False)
layout.addWidget(flip_check)
line = QFrame()
line.setFrameShape(QFrame.HLine)
layout.addWidget(line)
btn_layout = QHBoxLayout()
ok_btn = QPushButton("Connect")
cancel_btn = QPushButton("Cancel")
btn_layout.addWidget(ok_btn)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
import numpy as np
target_normal = np.array(first_pick["normal_world"], dtype=float)
target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
# ── Live preview callback ──
def _update_preview(*args):
rot_deg = rotation_spin.value()
off = offset_spin.value()
flip = flip_check.isChecked()
# Start from solved transform.
base_pos = np.array(solved["position"], dtype=float)
base_rot = np.array(solved["rotation"], dtype=float)
# Apply axis rotation around target_normal.
angle_rad = np.radians(rot_deg)
k = target_normal
K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
preview_rot = R_axis @ base_rot
# Apply offset (with flip).
flip_sign = -1.0 if flip else 1.0
preview_pos = base_pos + flip_sign * target_normal * off
preview_target.position = preview_pos
preview_target.rotation = preview_rot
self._show_assembly_in_viewer() # no fit — keep camera steady
rotation_spin.valueChanged.connect(_update_preview)
offset_spin.valueChanged.connect(_update_preview)
flip_check.toggled.connect(_update_preview)
# Initial preview.
_update_preview()
ok_btn.clicked.connect(dialog.accept)
cancel_btn.clicked.connect(dialog.reject)
if dialog.exec():
return (rotation_spin.value(), offset_spin.value(), flip_check.isChecked())
return (None, None, False)
def _on_delete_connector(self):
"""Delete the connector nearest to the selected assembly component."""
active_id = self._get_active_assembly_component_id()
if active_id is None:
QMessageBox.warning(self, "No Selection", "Select an assembly component first")
return
assembly = self._get_assembly()
ac = assembly.components.get(active_id)
if ac is None or not ac.connectors:
QMessageBox.information(
self, "No Connectors", "This component has no connectors to remove."
)
return
# List connectors in a simple choice dialog.
conn_names = list(ac.connectors.keys())
conn_labels = [f"{c.name} at {c.position}" for c in ac.connectors.values()]
from PySide6.QtWidgets import QInputDialog
label, ok = QInputDialog.getItem(
self, "Remove Connector", "Select connector:", conn_labels, 0, False
)
if ok and label:
idx = conn_labels.index(label)
conn_id = conn_names[idx]
conn = ac.connectors.get(conn_id)
# Un-partner the mate and drop the rigid-group edge so stale
# connections don't linger in the BFS graph.
if conn is not None:
partner_ac_id = conn.partner_ac_id
partner_conn_id = conn.partner_connector_id
if partner_ac_id is not None and partner_conn_id is not None:
partner_ac = assembly.components.get(partner_ac_id)
if partner_ac is not None and partner_conn_id in partner_ac.connectors:
pc = partner_ac.connectors[partner_conn_id]
pc.partner_ac_id = None
pc.partner_connector_id = None
pc.is_grounded = False
# Remove the connection edge either side references this pair.
assembly.connections = (
[
c
for c in assembly.connections
if not (
(c.first_ac_id == active_id and c.second_ac_id == partner_ac_id)
or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id)
)
]
if partner_ac_id is not None
else assembly.connections
)
ac.remove_connector(conn_id)
logger.info(f"Removed connector {conn_id}")
self._show_assembly_in_viewer(fit=True)
self._refresh_connection_list()
def _refresh_connection_list(self):
"""Refresh the connection list widget with all assembly connections.
Each entry shows the connected component names and connector info.
The list is populated from the active assembly's connections.
"""
self._connection_list.clear()
assembly = self._get_assembly()
if assembly is None or not assembly.connections:
return
for conn in assembly.connections:
first_ac = assembly.components.get(conn.first_ac_id)
second_ac = assembly.components.get(conn.second_ac_id)
if first_ac is None or second_ac is None:
continue
# Get component names from the project.
first_comp = self._project.get_component_by_id(first_ac.component_id)
second_comp = self._project.get_component_by_id(second_ac.component_id)
first_name = first_comp.name if first_comp else first_ac.name
second_name = second_comp.name if second_comp else second_ac.name
# Build display text.
display = f"{first_name} ↔ {second_name}"
item = QListWidgetItem(display)
item.setData(Qt.UserRole, conn.id)
self._connection_list.addItem(item)
def _on_delete_connection_from_list(self):
"""Delete the selected connection from the connection list.
Also removes the mated connectors from both assembly components
and cleans up the connection graph.
"""
selected = self._connection_list.currentItem()
if selected is None:
QMessageBox.information(self, "No Selection", "Select a connection to delete")
return
conn_id = selected.data(Qt.UserRole)
assembly = self._get_assembly()
if assembly is None:
return
# Find the connection.
target_conn = None
for conn in assembly.connections:
if conn.id == conn_id:
target_conn = conn
break
if target_conn is None:
return
# Get the assembly components involved.
first_ac = assembly.components.get(target_conn.first_ac_id)
second_ac = assembly.components.get(target_conn.second_ac_id)
# Remove the mated connectors from both sides.
if first_ac is not None:
for conn in list(first_ac.connectors.values()):
if conn.partner_ac_id == target_conn.second_ac_id:
first_ac.remove_connector(conn.id)
break
if second_ac is not None:
for conn in list(second_ac.connectors.values()):
if conn.partner_ac_id == target_conn.first_ac_id:
second_ac.remove_connector(conn.id)
break
# Remove the connection from the assembly.
assembly.connections = [c for c in assembly.connections if c.id != conn_id]
self._mark_dirty()
self._refresh_connection_list()
self._show_assembly_in_viewer(fit=True)
logger.info(f"Deleted connection {conn_id}")
def _new_workplane(self):
"""Open the orientation dialog and create a new independent workplane.
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.
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
# Store the face centroid for re-matching when the body updates.
try:
from OCP.BRepGProp import BRepGProp
from OCP.GProp import GProp_GProps
props = GProp_GProps()
BRepGProp.VolumeProperties_s(face_shape, props)
c = props.CentreOfMass()
sketch._source_face_center = (float(c.X()), float(c.Y()), float(c.Z()))
except Exception:
sketch._source_face_center = tuple(float(v) for v in origin)
# 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("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 clicks pb_body_hide.
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 = not body.visible # toggle
if body.visible == new_visible:
return # no change
body.visible = new_visible
item.setForeground(QColor("#1e1e2e") if new_visible else QColor("#6c7086"))
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}")
def _on_body_hide_button_clicked(self) -> None:
"""Handle click on pb_body_hide button - toggle visibility of selected body."""
current_item = self._body_list.currentItem()
if current_item is not None:
self._on_body_visibility_changed(current_item)
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
def _record_feature(
self,
body: Body,
operation: str,
sketch: Optional[Sketch],
length: Optional[float] = None,
symmetric: bool = False,
invert: bool = False,
through_all: bool = False,
cut_all_bodies: bool = False,
face_index: Optional[int] = None,
angle: float = 360.0,
) -> None:
"""Append *operation* to *body*'s parametric feature history.
If the body has no features yet but already holds geometry (a
legacy body created before feature history existed), a frozen
"base" snapshot of the CURRENT geometry is inserted first so
replays start from a known state. Call this BEFORE assigning
the new geometry onto ``body.geometry`` — the snapshot must
capture the pre-operation state.
"""
if not body.features and body.geometry is not None and operation in ("cut", "union"):
body.features.append(Feature(operation="base", geometry=body.geometry))
logger.info(f"Body '{body.name}': snapshotted legacy geometry as feature base")
body.features.append(
Feature(
operation=operation,
sketch=sketch,
length=length,
symmetric=symmetric,
invert=invert,
through_all=through_all,
cut_all_bodies=cut_all_bodies,
face_index=face_index,
angle=angle,
)
)
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 _find_extrude_body_for_sketch(self, sketch: Sketch) -> Optional[Body]:
"""Return the existing body that was created from *sketch* via a
plain extrude, or *None* if no such body exists.
Used by the plain-extrude path of :meth:`_extrude_sketch` to decide
between "update the existing body in place" and "create a new
body". Without this, clicking Extrude a second time on the same
sketch stacks a fresh 44mm solid on top of the original 44mm
solid and the user perceives the apparent depth as 88mm.
"""
if self._current_component is None or sketch is None:
return None
for body in self._current_component.bodies.values():
if body.source_sketch is sketch and body.source_operation == "extrude":
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. See :meth:`_through_all_length_for_geometry`.
"""
return self._through_all_length_for_geometry(target.geometry, sketch)
def _through_all_length_for_geometry(self, geometry: Any, sketch: Sketch) -> float:
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
*through* a body geometry.
Computes the geometry's bounding-box extent along the sketch's
workplane normal direction ("extent" = how far the solid 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 solid so the
boolean reliably removes the through volume.
"""
import numpy as _np
try:
p_min, p_max = self._kernel.get_bounding_box(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,
cut_all_bodies: bool = False,
) -> 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)
- "all_targets": list of all bodies affected when cut_all_bodies
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
# When cut_all_bodies, collect all bodies in the component as targets.
all_targets: list = []
if cut_all_bodies and cut and self._current_component is not None:
all_targets = [
b for b in self._current_component.bodies.values() if b.geometry is not None
]
# Use the first non-tool body as the primary target for preview.
if target is None and all_targets:
target = all_targets[0]
# 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,
"all_targets": all_targets,
}
# 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,
"all_targets": [],
}
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(targets):
"""Dim one or more bodies for preview clarity."""
# Undo any prior dim.
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()
# Normalize to a list.
if targets is None:
targets = []
elif isinstance(targets, Body):
targets = [targets]
for t in targets:
if t is not None and t.render_object is not None:
ok = self._viewer_3d.set_transparency(t.render_object, 0.6)
if ok:
state["dimmed"].append((t.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, cut_all_bodies, _rounded = values
result = self._compute_extrude_result(
sketch,
face_geom,
length,
symmetric,
invert,
bool(cut),
bool(union),
bool(through_all),
cut_all_bodies=bool(cut_all_bodies),
)
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"])
# Dim all affected bodies when cut_all_bodies is active.
all_targets = result.get("all_targets", [])
if all_targets:
_apply_dim(all_targets)
else:
_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
if sketch is None:
sketch = Sketch()
self._current_sketch = sketch
self._current_component.add_sketch(sketch)
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, cut_all_bodies, rounded = (
dialog.get_values()
)
logger.info(
f"Extrude params: length={length}, symmetric={symmetric}, "
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}, "
f"cut_all_bodies={cut_all_bodies}"
)
# Capture the face index before the dialog closes (the selected
# face may be cleared during preview cleanup).
face_index = self._sketch_widget.get_selected_face_index()
try:
result = self._compute_extrude_result(
sketch,
face_geom,
length,
symmetric,
invert,
bool(cut),
bool(union),
bool(through_all),
cut_all_bodies=bool(cut_all_bodies),
)
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"]
all_targets = result.get("all_targets", [])
if target is not None and bool(cut) and all_targets:
# Cut all bodies: apply the boolean difference to every body
# in the component that has geometry.
tool_geom = result["tool_geom"]
updated_count = 0
for body in all_targets:
try:
new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
except Exception as exc:
logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
continue
# Record the feature BEFORE committing the geometry so
# a legacy body snapshots its pre-cut state as base.
self._record_feature(
body,
"cut",
sketch,
length=length,
symmetric=symmetric,
invert=invert,
through_all=bool(through_all),
cut_all_bodies=True,
face_index=face_index,
)
body.geometry = new_geom
body.extrude_length = length
body.extrude_symmetric = symmetric
body.extrude_invert = invert
body.extrude_cut = True
body.extrude_union = False
body.extrude_through_all = bool(through_all)
body.extrude_cut_all_bodies = True
body.extrude_face_index = face_index
body.source_sketch = sketch
body.source_operation = "cut"
body.extrude_target_body_id = body.id
if body.render_object is not None:
self._viewer_3d.remove_mesh(body.render_object)
shape = self._kernel._get_shape(body.geometry)
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
updated_count += 1
logger.info(f"Cut-all applied to {updated_count} body(ies)")
body_name = f"{updated_count} body(ies)"
elif target is not None:
# Single-body cut / union: commit the result onto the *target*
# body in place. Record the feature BEFORE committing so a
# legacy body snapshots its pre-op geometry as base.
self._record_feature(
target,
"cut" if cut else "union",
sketch,
length=length,
symmetric=symmetric,
invert=invert,
through_all=bool(through_all),
cut_all_bodies=False,
face_index=face_index,
)
target.geometry = result["result_geom"]
# Store extrude params so the body can be rebuilt later.
target.extrude_length = length
target.extrude_symmetric = symmetric
target.extrude_invert = invert
target.extrude_cut = bool(cut)
target.extrude_union = bool(union)
target.extrude_through_all = bool(through_all)
target.extrude_cut_all_bodies = False
target.extrude_face_index = face_index
target.source_sketch = sketch
target.source_operation = "cut" if cut else "union"
target.extrude_target_body_id = target.id
if target.render_object is not None:
self._viewer_3d.remove_mesh(target.render_object)
shape = self._kernel._get_shape(target.geometry)
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. If this sketch already produced an
# existing extrude body, UPDATE that body in place rather
# than stacking a fresh 44mm solid on top of the old one
# — which the user perceives as "44mm looks like 88mm"
# because the two coincident solids visually sum.
existing = self._find_extrude_body_for_sketch(sketch)
if existing is not None:
body = existing
# Record the new feature BEFORE replacing geometry so
# the parametric history reflects the latest op.
self._record_feature(
body,
"extrude",
sketch,
length=length,
symmetric=symmetric,
invert=invert,
through_all=bool(through_all),
face_index=face_index,
)
body.geometry = result["result_geom"]
body.extrude_length = length
body.extrude_symmetric = symmetric
body.extrude_invert = invert
body.extrude_cut = False
body.extrude_union = False
body.extrude_through_all = bool(through_all)
body.extrude_cut_all_bodies = False
body.extrude_face_index = face_index
body.source_sketch = sketch
body.source_operation = "extrude"
body.extrude_target_body_id = None
self._mark_dirty()
logger.info(f"Updated existing body in place: {body.name}")
if body.render_object is not None:
self._viewer_3d.remove_mesh(body.render_object)
shape = self._kernel._get_shape(body.geometry)
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
body_name = body.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",
extrude_length=length,
extrude_symmetric=symmetric,
extrude_invert=invert,
extrude_cut=False,
extrude_union=False,
extrude_through_all=bool(through_all),
extrude_cut_all_bodies=False,
extrude_face_index=face_index,
features=[
Feature(
operation="extrude",
sketch=sketch,
length=length,
symmetric=symmetric,
invert=invert,
through_all=bool(through_all),
face_index=face_index,
)
],
)
)
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._update_component_thumbnail(self._get_active_component_index())
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
if sketch is None:
sketch = Sketch()
self._current_sketch = sketch
self._current_component.add_sketch(sketch)
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",
features=[
Feature(
operation="revolve",
sketch=sketch,
angle=angle,
face_index=self._sketch_widget.get_selected_face_index(),
)
],
)
)
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._update_component_thumbnail(self._get_active_component_index())
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",
extrude_cut=True,
extrude_target_body_id=base_id,
)
)
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._update_component_thumbnail(self._get_active_component_index())
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",
extrude_union=True,
)
)
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._update_component_thumbnail(self._get_active_component_index())
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
self._selected_assembly_component_id = None
self._assembly_view_active = False
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()
# 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, with thumbnails).
for idx, comp in enumerate(self._project.components.values(), start=1):
has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
if has_geometry:
btn = _create_component_button(
idx,
comp.name,
comp,
self._kernel,
self._component_group,
self._component_box_layout,
self._on_component_button_clicked,
self._component_scroll,
)
else:
btn = QPushButton(str(idx))
btn.setCheckable(True)
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(comp.name)
btn.clicked.connect(self._on_component_button_clicked)
_set_button_style(btn)
self._component_group.addButton(btn)
self._component_box_layout.addWidget(btn)
self._component_buttons.append(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(100, 100))
btn.setToolTip(f"{ac.name} (instance {instance_num})")
btn._assembly_component_id = ac.id
btn.clicked.connect(self._on_assembly_component_clicked)
_set_button_style(btn)
# Thumbnail from the source component's all bodies.
src_comp = self._project.components.get(ac.component_id)
if src_comp:
has_geometry = any(
b.visible and b.geometry for b in src_comp.bodies.values()
)
if has_geometry:
pixmap = _make_component_thumbnail(
src_comp, self._kernel, QSize(96, 96)
)
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
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 not filepath:
return
try:
if filepath.lower().endswith((".step", ".stp")):
parts = self._kernel.import_step_components(filepath)
else:
geometry = self._kernel.import_iges(filepath)
parts = [("Imported", geometry)]
num_before = len(self._project.components)
for name, geometry in parts:
comp = self._project.add_component(Component(name=name))
self._current_component = comp
body = comp.add_body(Body(name=name, geometry=geometry, source_operation="import"))
vertices, faces = body.get_mesh(self._kernel)
body.render_object = self._viewer_3d.add_mesh(
vertices, faces, body.color, body.name
)
# Add a numbered button to the component bar
btn_num = len(self._project.components)
btn = _create_component_button(
btn_num,
name,
comp,
self._kernel,
self._component_group,
self._component_box_layout,
self._on_component_button_clicked,
self._component_scroll,
)
self._component_buttons.append(btn)
# Select the first newly imported component
if self._component_buttons and num_before < len(self._component_buttons):
for b in self._component_buttons:
b.setChecked(False)
self._component_buttons[num_before].setChecked(True)
self._current_component = self._project.components[
list(self._project.components.keys())[num_before]
]
self._refresh_lists()
self._viewer_3d.fit_camera()
self._mark_dirty()
logger.info(f"Imported {len(parts)} part(s) from {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 _open_render_window(self):
"""Populate the render tab with the selected body or assembly and switch to it."""
# Collect all visible bodies across all components
assembly_parts = [] # list of (TopoDS_Shape, Optional[str])
single_shape = None
for comp in self._project.components.values():
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
if not assembly_parts:
QMessageBox.information(
self,
"Render",
"Select a body or assembly component to render.",
)
return
# Capture the current 3D viewport camera
try:
renderer = self._viewer_3d.get_renderer()
if hasattr(renderer, "get_render_camera"):
viewport_camera = renderer.get_render_camera()
else:
from fluency.rendering.render_backend import RenderCamera
eye, at_, up = self._viewer_3d.get_camera_position()
fov = self._viewer_3d.get_camera_fov()
viewport_camera = RenderCamera(
origin=tuple(float(v) for v in eye),
target=tuple(float(v) for v in at_),
up=tuple(float(v) for v in up),
fov=fov,
)
except Exception:
viewport_camera = None
# Single body → use set_shape; multiple → use set_assembly
if len(assembly_parts) == 1:
self._render_tab.set_shape(assembly_parts[0][0], camera=viewport_camera)
else:
self._render_tab.set_assembly(assembly_parts, camera=viewport_camera)
self._ui.InputTab.setCurrentWidget(self._render_tab)
def _load_render_tab_shape(self) -> None:
"""Auto-load the selected body or assembly component into the render tab."""
# Collect all visible bodies across all components
assembly_parts = []
for comp in self._project.components.values():
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
if not assembly_parts:
return
# Capture the viewport camera
try:
renderer = self._viewer_3d.get_renderer()
if hasattr(renderer, "get_render_camera"):
viewport_camera = renderer.get_render_camera()
else:
from fluency.rendering.render_backend import RenderCamera
eye, at_, up = self._viewer_3d.get_camera_position()
fov = self._viewer_3d.get_camera_fov()
viewport_camera = RenderCamera(
origin=tuple(float(v) for v in eye),
target=tuple(float(v) for v in at_),
up=tuple(float(v) for v in up),
fov=fov,
)
except Exception:
viewport_camera = None
if len(assembly_parts) == 1:
self._render_tab.set_shape(assembly_parts[0][0], camera=viewport_camera)
else:
self._render_tab.set_assembly(assembly_parts, camera=viewport_camera)
def _on_tab_changed(self, index: int) -> None:
"""When the user switches to the Render tab, auto-load the selected body."""
if self._ui.InputTab.widget(index) is self._render_tab:
self._load_render_tab_shape()
# ─── Sketch Undo/Redo ─────────────────────────────────────────────────
def _get_sketch_widget(self):
"""Return the active sketch widget, or None if not in sketch mode."""
# The sketch widget is in the sketch tab
if hasattr(self, "_sketch_widget"):
return self._sketch_widget
return None
def _undo_sketch(self):
"""Undo the last sketch operation."""
sketch_widget = self._get_sketch_widget()
if sketch_widget and sketch_widget.get_undo_manager():
undo_mgr = sketch_widget.get_undo_manager()
if undo_mgr.can_undo:
undo_mgr.undo()
sketch_widget._rebuild_from_sketch()
sketch_widget._solve_and_sync()
sketch_widget.sketch_updated.emit()
sketch_widget.update()
self._update_undo_redo_actions()
def _redo_sketch(self):
"""Redo the last undone sketch operation."""
sketch_widget = self._get_sketch_widget()
if sketch_widget and sketch_widget.get_undo_manager():
undo_mgr = sketch_widget.get_undo_manager()
if undo_mgr.can_redo:
undo_mgr.redo()
sketch_widget._rebuild_from_sketch()
sketch_widget._solve_and_sync()
sketch_widget.sketch_updated.emit()
sketch_widget.update()
self._update_undo_redo_actions()
def _update_undo_redo_actions(self):
"""Update the enabled state of Undo/Redo menu actions."""
sketch_widget = self._get_sketch_widget()
if sketch_widget and sketch_widget.get_undo_manager():
undo_mgr = sketch_widget.get_undo_manager()
self._action_undo.setEnabled(undo_mgr.can_undo)
self._action_redo.setEnabled(undo_mgr.can_redo)
else:
self._action_undo.setEnabled(False)
self._action_redo.setEnabled(False)
def _show_about(self):
QMessageBox.about(
self,
"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",
)
# ── Camera sync: 3D view → render tab ────────────────────────
def _on_camera_changed(self, eye: tuple, at_: tuple, up: tuple) -> None:
"""Push the viewport camera into the render tab when it's active.
Fires whenever the 3D viewport camera changes (orbit, pan, zoom)
while the Render tab is selected. ``set_camera`` decides whether
to restart a running preview or just schedule a new one.
"""
# Is the Render tab currently visible?
if self._ui.InputTab.currentWidget() is not self._render_tab:
return
# Nothing to render yet.
if self._render_tab._mesh_path is None:
return
try:
renderer = self._viewer_3d.get_renderer()
if hasattr(renderer, "get_render_camera"):
viewport_camera = renderer.get_render_camera()
else:
from fluency.rendering.render_backend import RenderCamera
fov = self._viewer_3d.get_camera_fov()
viewport_camera = RenderCamera(
origin=eye,
target=at_,
up=up,
fov=fov,
)
except Exception:
return
self._render_tab.set_camera(viewport_camera)