4250 lines
181 KiB
Python
4250 lines
181 KiB
Python
from h5py._hl import files
|
||
|
||
"""
|
||
Fluency CAD - Main Application
|
||
|
||
A parametric CAD application built on OpenCASCADE Technology (OCCT)
|
||
with a modern pygfx-based 3D renderer.
|
||
"""
|
||
|
||
import sys
|
||
import math
|
||
import uuid
|
||
import logging
|
||
from typing import Optional, List, Dict, Any, Tuple
|
||
from dataclasses import dataclass, field
|
||
|
||
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(levelname)s - %(message)s")
|
||
logger = logging.getLogger(__name__)
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QMainWindow,
|
||
QWidget,
|
||
QVBoxLayout,
|
||
QHBoxLayout,
|
||
QGridLayout,
|
||
QToolBar,
|
||
QStatusBar,
|
||
QFileDialog,
|
||
QMessageBox,
|
||
QDockWidget,
|
||
QTreeWidget,
|
||
QTreeWidgetItem,
|
||
QLabel,
|
||
QDoubleSpinBox,
|
||
QSpinBox,
|
||
QComboBox,
|
||
QPushButton,
|
||
QGroupBox,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QTabWidget,
|
||
QTextEdit,
|
||
QDialog,
|
||
QCheckBox,
|
||
QButtonGroup,
|
||
QFrame,
|
||
QMenu,
|
||
QMenuBar,
|
||
QSplitter,
|
||
QSizePolicy,
|
||
QInputDialog,
|
||
)
|
||
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
|
||
from PySide6.QtGui import (
|
||
QAction,
|
||
QIcon,
|
||
QKeySequence,
|
||
QPainter,
|
||
QPainterPath,
|
||
QPen,
|
||
QColor,
|
||
QBrush,
|
||
QFont,
|
||
QFontMetrics,
|
||
QCursor,
|
||
QPolygonF,
|
||
)
|
||
|
||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
|
||
from fluency.geometry.base import Point2D, Point3D
|
||
from fluency.rendering.occ_renderer import OCCRenderer
|
||
from fluency.models.data_model import Project, Component, Sketch, Body
|
||
|
||
|
||
def _project_face_to_uv(
|
||
face: Any,
|
||
workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]],
|
||
) -> List[List[Tuple[float, float]]]:
|
||
"""Project a planar ``TopoDS_Face``'s boundary edges into the UV frame.
|
||
|
||
*workplane* is (origin, normal, x_dir). Returns a list of polylines,
|
||
each a list of (u, v) points, one per boundary edge (lines → endpoints,
|
||
curves → sampled). Used by the 2D sketch widget to draw the face as an
|
||
underlay when sketching on a surface.
|
||
"""
|
||
import numpy as np
|
||
from OCP.TopExp import TopExp_Explorer
|
||
from OCP.TopAbs import TopAbs_EDGE, 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) # (x,y,z)
|
||
normal = np.asarray(workplane[1], dtype=float) # plane normal
|
||
x_dir = np.asarray(workplane[2], dtype=float) # in-plane x axis
|
||
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 wires of the face (outer + inner = holes), then edges.
|
||
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 32 segments across the parameter range.
|
||
pts = [crv.Value(f + (l - f) * i / 32.0) for i in range(33)]
|
||
poly = [world_to_uv(p) for p in pts]
|
||
polylines.append(poly)
|
||
except Exception:
|
||
pass
|
||
edge_expl.Next()
|
||
wire_expl.Next()
|
||
|
||
return polylines
|
||
|
||
|
||
class ExtrudeDialog(QDialog):
|
||
"""Dialog for extrude options.
|
||
|
||
Carries an optional ``preview_callback`` that is invoked whenever the
|
||
user changes any option; the host uses it to render a live transparent
|
||
preview of the operation result in the 3D view. Passing *False* (or
|
||
*None*) to the callback tells the host to clear the preview.
|
||
"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Extrude Options")
|
||
self.setMinimumWidth(320)
|
||
|
||
self._preview_callback = None
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
length_layout = QHBoxLayout()
|
||
length_layout.addWidget(QLabel("Extrude Length (mm):"))
|
||
self.length_input = QDoubleSpinBox()
|
||
self.length_input.setDecimals(2)
|
||
self.length_input.setRange(-10000, 10000)
|
||
self.length_input.setValue(10)
|
||
length_layout.addWidget(self.length_input)
|
||
layout.addLayout(length_layout)
|
||
|
||
self.symmetric_checkbox = QCheckBox("Symmetric Extrude")
|
||
layout.addWidget(self.symmetric_checkbox)
|
||
|
||
self.invert_checkbox = QCheckBox("Invert Extrusion")
|
||
layout.addWidget(self.invert_checkbox)
|
||
|
||
self.cut_checkbox = QCheckBox("Perform Cut")
|
||
layout.addWidget(self.cut_checkbox)
|
||
|
||
self.union_checkbox = QCheckBox("Combine (Union)")
|
||
layout.addWidget(self.union_checkbox)
|
||
|
||
self.through_all_checkbox = QCheckBox("Through All (cut/union target)")
|
||
self.through_all_checkbox.setToolTip(
|
||
"Ignore the typed length and extrude far enough to fully pass "
|
||
"through the cut/union target body. Applies when Perform Cut or "
|
||
"Combine (Union) is checked."
|
||
)
|
||
layout.addWidget(self.through_all_checkbox)
|
||
|
||
self.rounded_checkbox = QCheckBox("Round Edges")
|
||
layout.addWidget(self.rounded_checkbox)
|
||
|
||
line = QFrame()
|
||
line.setFrameShape(QFrame.HLine)
|
||
line.setFrameShadow(QFrame.Sunken)
|
||
layout.addWidget(line)
|
||
|
||
button_layout = QHBoxLayout()
|
||
ok_button = QPushButton("OK")
|
||
ok_button.clicked.connect(self.accept)
|
||
cancel_button = QPushButton("Cancel")
|
||
cancel_button.clicked.connect(self.reject)
|
||
button_layout.addWidget(ok_button)
|
||
button_layout.addWidget(cancel_button)
|
||
layout.addLayout(button_layout)
|
||
|
||
# Live preview: recompute on every option change. Use a light-
|
||
# weight guard so we don't emit before the host has wired up the
|
||
# callback.
|
||
for w in (
|
||
self.length_input,
|
||
self.symmetric_checkbox,
|
||
self.invert_checkbox,
|
||
self.cut_checkbox,
|
||
self.union_checkbox,
|
||
self.through_all_checkbox,
|
||
self.rounded_checkbox,
|
||
):
|
||
# The spinbox has valueChanged; the checkboxes have stateChanged.
|
||
# Each must be wired in its own try/except so that a missing
|
||
# signal on one widget type doesn't skip the OTHER signal's
|
||
# connection (the prior single-try version accidentally
|
||
# left checkboxes un-connected when valueChanged raised first).
|
||
try:
|
||
w.valueChanged.connect(self._emit_preview)
|
||
except AttributeError:
|
||
pass
|
||
try:
|
||
w.stateChanged.connect(self._emit_preview)
|
||
except AttributeError:
|
||
pass
|
||
|
||
def set_preview_callback(self, callback) -> None:
|
||
"""Install the live-preview callback (or *None* to disable)."""
|
||
self._preview_callback = callback
|
||
# Emit once so the initial state shows a preview right away.
|
||
self._emit_preview()
|
||
|
||
def _emit_preview(self, *args) -> None:
|
||
if self._preview_callback is None:
|
||
return
|
||
try:
|
||
self._preview_callback(self.get_values())
|
||
except Exception as exc: # preview must never break the dialog
|
||
logger.debug("extrude preview callback raised: %s", exc)
|
||
|
||
def hideEvent(self, event):
|
||
# Tell the host to clear the preview when the dialog goes away
|
||
# (accept, reject, or close). The host is responsible for the
|
||
# actual viewer cleanup.
|
||
if self._preview_callback is not None:
|
||
try:
|
||
self._preview_callback(None)
|
||
except Exception:
|
||
pass
|
||
super().hideEvent(event)
|
||
|
||
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
|
||
return (
|
||
self.length_input.value(),
|
||
self.symmetric_checkbox.isChecked(),
|
||
self.invert_checkbox.isChecked(),
|
||
self.cut_checkbox.isChecked(),
|
||
self.union_checkbox.isChecked(),
|
||
self.through_all_checkbox.isChecked(),
|
||
self.rounded_checkbox.isChecked(),
|
||
)
|
||
|
||
|
||
class RevolveDialog(QDialog):
|
||
"""Dialog for revolve options."""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Revolve Options")
|
||
self.setMinimumWidth(300)
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
angle_layout = QHBoxLayout()
|
||
angle_layout.addWidget(QLabel("Revolve Angle (°):"))
|
||
self.angle_input = QDoubleSpinBox()
|
||
self.angle_input.setDecimals(1)
|
||
self.angle_input.setRange(1, 360)
|
||
self.angle_input.setValue(360)
|
||
self.angle_input.setSuffix("°")
|
||
angle_layout.addWidget(self.angle_input)
|
||
layout.addLayout(angle_layout)
|
||
|
||
line = QFrame()
|
||
line.setFrameShape(QFrame.HLine)
|
||
line.setFrameShadow(QFrame.Sunken)
|
||
layout.addWidget(line)
|
||
|
||
button_layout = QHBoxLayout()
|
||
ok_button = QPushButton("OK")
|
||
ok_button.clicked.connect(self.accept)
|
||
cancel_button = QPushButton("Cancel")
|
||
cancel_button.clicked.connect(self.reject)
|
||
button_layout.addWidget(ok_button)
|
||
button_layout.addWidget(cancel_button)
|
||
layout.addLayout(button_layout)
|
||
|
||
|
||
class Sketch2DWidget(QWidget):
|
||
"""2D sketching widget with SolveSpace constraint solving and drawing tools."""
|
||
|
||
constrain_done = Signal()
|
||
sketch_updated = Signal()
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setMinimumSize(400, 300)
|
||
self.setMouseTracking(True)
|
||
|
||
self._sketch: Optional[OCCSketch] = None
|
||
self._mode: Optional[str] = None
|
||
self._is_construct: bool = False
|
||
|
||
self._points: List[OCCSketchEntity] = []
|
||
self._lines: List[Tuple[OCCSketchEntity, OCCSketchEntity]] = []
|
||
self._circles: List[Tuple[OCCSketchEntity, float]] = []
|
||
|
||
self._draw_buffer: List[QPoint] = []
|
||
self._hovered_point: Optional[QPoint] = None
|
||
self._hovered_point_entity: Optional[OCCSketchEntity] = None # point entity under cursor (for Delete)
|
||
self._hovered_line: Optional[Tuple[QPoint, QPoint]] = None
|
||
self._hovered_line_entity: Optional[OCCSketchEntity] = None # line entity under cursor (for Delete)
|
||
self._hovered_constraint_idx: int = -1 # constraint-log index hovered over its tag (for Delete)
|
||
self._constraint_tags: List[Dict[str, Any]] = [] # cached tag rects for paint + hit-test
|
||
self._hovered_face: Any = None # detected face dict (see OCCSketch.detect_faces) or None
|
||
self._selected_face: Any = None
|
||
self._selected_entities: List[OCCSketchEntity] = []
|
||
|
||
# Source face for sketch-on-surface: the planar face the user picked
|
||
# in the 3D viewer, plus its workplane (origin/normal/x_dir). Phase 3
|
||
# projects this face's edges into UV and draws them as an underlay.
|
||
self._source_face: Any = None
|
||
self._source_workplane: Optional[Tuple[Tuple[float, float, float], ...]] = None
|
||
self._source_underlay_uv: List[Any] = [] # cached UV polylines for paintEvent
|
||
# Underlay visibility: the dashed construction lines projected from
|
||
# the source face can be hidden/shown without losing the source
|
||
# face reference (useful when the underlay is too busy). Toggled
|
||
# from the "Underlay" button in the MainWindow UI.
|
||
self._underlay_visible: bool = True
|
||
|
||
self._snap_mode: Dict[str, bool] = {
|
||
"point": True,
|
||
"mpoint": False,
|
||
"horiz": False,
|
||
"vert": False,
|
||
"grid": False,
|
||
"angle": False,
|
||
}
|
||
self._snap_distance: int = 10
|
||
self._angle_steps: int = 15
|
||
|
||
self._zoom: float = 1.0
|
||
self._offset: QPoint = QPoint(0, 0)
|
||
self._panning: bool = False
|
||
self._pan_start: Optional[QPoint] = None
|
||
|
||
self._dynamic_line_end: Optional[QPoint] = None
|
||
self._temp_entities: List[Any] = []
|
||
|
||
self._constraint_distance_value: float = 10.0
|
||
# Pending distance constraint input
|
||
self._pending_distance_val: Optional[float] = None
|
||
|
||
# Element move state (move tool / select mode)
|
||
self._moving_points: List[OCCSketchEntity] = []
|
||
self._move_anchor: Optional[OCCSketchEntity] = None
|
||
self._move_anchor_orig: Optional[QPoint] = None
|
||
self._move_orig_positions: Dict[int, Tuple[float, float]] = {}
|
||
self._move_active: bool = False
|
||
|
||
self.setFocusPolicy(Qt.StrongFocus)
|
||
self._setup_ui()
|
||
|
||
def _setup_ui(self):
|
||
self.setStyleSheet("background-color: #1e1e2e;")
|
||
|
||
def set_sketch(self, sketch: Optional[OCCSketch]):
|
||
self._sketch = sketch
|
||
self._rebuild_from_sketch()
|
||
self._draw_buffer = []
|
||
self._clear_face_state()
|
||
# If the new sketch carries a workplane, refresh the source underlay.
|
||
self._refresh_source_underlay()
|
||
# A brand new sketch has no external entities — strip the old
|
||
# underlay from the previous sketch so the construction lines
|
||
# don't bleed into the new sketch. (set_source_face will reimport
|
||
# them if the new sketch is on a face too.)
|
||
if self._sketch is not None and self._sketch is not sketch:
|
||
self._sketch.remove_external_entities()
|
||
self.update()
|
||
|
||
def clear_source_face(self) -> None:
|
||
"""Forget the picked source face and remove the underlay entities.
|
||
|
||
Use this when the user wants to drop the face reference (e.g. they
|
||
want to draw a free-standing sketch without the body's outline
|
||
showing through). Removes the underlay entities from the solver,
|
||
clears the cached polyline data, and resets the view to whatever
|
||
zoom the user had before the face was set.
|
||
"""
|
||
if self._sketch is not None:
|
||
self._sketch.remove_external_entities()
|
||
self._source_face = None
|
||
self._source_workplane = None
|
||
self._source_underlay_uv = []
|
||
self._rebuild_from_sketch()
|
||
self._hovered_point = None
|
||
self._hovered_point_entity = None
|
||
self._hovered_line = None
|
||
self._hovered_line_entity = None
|
||
self._selected_entities = []
|
||
self.update()
|
||
|
||
def set_source_face(
|
||
self,
|
||
face: Any,
|
||
origin: Tuple[float, float, float],
|
||
normal: Tuple[float, float, float],
|
||
x_dir: Tuple[float, float, float],
|
||
) -> None:
|
||
"""Store the picked 3D face and reorient the 2D view to its plane.
|
||
|
||
Called by MainWindow after a face pick. Projects the face's boundary
|
||
edges into the sketch's UV frame, caches them for the underlay fill,
|
||
*and* imports them as construction-line entities in the underlying
|
||
OCCSketch. Those entities are fixed in the solver, so the user can
|
||
snap to them and add distance / horizontal / vertical / parallel /
|
||
perpendicular / midpoint / coincident constraints against them —
|
||
e.g. place a hole "50 mm from the body's top edge" by clicking the
|
||
underay corner, the hole centre, and entering 50.
|
||
|
||
Also re-centres/scales the 2D view to look down the plane.
|
||
"""
|
||
self._source_face = face
|
||
self._source_workplane = (tuple(origin), tuple(normal), tuple(x_dir))
|
||
# Ensure the OCCSketch shares the same workplane so UV↔world agrees.
|
||
if self._sketch is not None:
|
||
self._sketch.set_workplane(origin, normal, x_dir)
|
||
self._refresh_source_underlay()
|
||
self._import_underlay_as_construction_lines()
|
||
self._orient_view_to_plane()
|
||
self.update()
|
||
|
||
def _refresh_source_underlay(self) -> None:
|
||
"""Project the source face's boundary edges into UV for the underlay."""
|
||
self._source_underlay_uv = []
|
||
if self._source_face is None or self._source_workplane is None:
|
||
return
|
||
if self._sketch is None:
|
||
return
|
||
try:
|
||
self._source_underlay_uv = _project_face_to_uv(
|
||
self._source_face, self._source_workplane
|
||
)
|
||
except Exception:
|
||
logger.debug("source underlay projection failed", exc_info=True)
|
||
|
||
def set_underlay_visible(self, visible: bool) -> None:
|
||
"""Show or hide the underlay (face-projected construction lines).
|
||
|
||
When hidden, the external entities stay in the OCCSketch solver
|
||
(constraints referencing them keep working) but they're not
|
||
rendered, snapped to, or hit-tested in the 2D view.
|
||
"""
|
||
self._underlay_visible = bool(visible)
|
||
# Re-render and drop any in-flight hover that pointed at an underlay
|
||
# entity (otherwise the cursor would freeze on a no-longer-drawn
|
||
# underlay element after the user hides it).
|
||
if not self._underlay_visible:
|
||
self._hovered_point = None
|
||
self._hovered_point_entity = None
|
||
self._hovered_line = None
|
||
self._hovered_line_entity = None
|
||
self.update()
|
||
|
||
def _import_underlay_as_construction_lines(self) -> None:
|
||
"""Convert the projected face edges into real construction-line entities.
|
||
|
||
Each polyline in ``_source_underlay_uv`` becomes a chain of external
|
||
point entities and external line segment entities in the underlying
|
||
OCCSketch. External points/lines are tagged ``is_external`` and
|
||
``is_construction`` so the paintEvent renders them as dashed
|
||
construction lines, and so the sketch profile path (detect_faces /
|
||
get_geometry) skips them. The solver marks every external point
|
||
fixed via ``dragged``, so a user drag of a related entity never moves
|
||
the underlay.
|
||
|
||
If a previous underlay was already imported it is cleared first so
|
||
we don't accumulate duplicates on a re-pick of the same face.
|
||
"""
|
||
if self._sketch is None or not self._source_underlay_uv:
|
||
return
|
||
# Clear any prior external entities before importing fresh ones so a
|
||
# repeated face pick doesn't pile up duplicate construction lines.
|
||
self._sketch.remove_external_entities()
|
||
imported = 0
|
||
for poly in self._source_underlay_uv:
|
||
if len(poly) < 2:
|
||
continue
|
||
try:
|
||
_, lines = self._sketch.add_external_polyline(
|
||
[(float(u), float(v)) for (u, v) in poly]
|
||
)
|
||
imported += len(lines)
|
||
except Exception as exc:
|
||
logger.debug("underlay polyline import failed: %s", exc)
|
||
logger.info(
|
||
"Imported %d construction-line segments from source face", imported
|
||
)
|
||
# Pull the new external entities into the UI lists so they're
|
||
# snap/hover/paint targets.
|
||
self._rebuild_from_sketch()
|
||
|
||
def _orient_view_to_plane(self) -> None:
|
||
"""Centre & scale the 2D view to fit the source face's UV bounds."""
|
||
if not self._source_underlay_uv:
|
||
return
|
||
# Collect all UV points across all cached polylines.
|
||
all_pts = [pt for poly in self._source_underlay_uv for pt in poly]
|
||
if not all_pts:
|
||
return
|
||
us = [p[0] for p in all_pts]
|
||
vs = [p[1] for p in all_pts]
|
||
umin, umax = min(us), max(us)
|
||
vmin, vmax = min(vs), max(vs)
|
||
cu, cv = (umin + umax) / 2.0, (vmin + vmax) / 2.0
|
||
du, dv = max(umax - umin, 1e-6), max(vmax - vmin, 1e-6)
|
||
# Zoom so the face fits with ~20% margin; offset so the face centre
|
||
# maps to the widget centre (world (cu,cv) → screen centre).
|
||
w, h = max(self.width(), 100), max(self.height(), 100)
|
||
self._zoom = min(w / (du * 1.2), h / (dv * 1.2))
|
||
# _world_to_screen: screen = world*zoom + centre + offset.
|
||
# We want world (cu,cv) → screen (w/2, h/2). Solve for offset.
|
||
# screen_x = cu*zoom + w/2 + offset_x → offset_x = -cu*zoom
|
||
# screen_y = h/2 - cv*zoom + offset_y → offset_y = cv*zoom
|
||
self._offset = QPoint(int(-cu * self._zoom), int(cv * self._zoom))
|
||
self.update()
|
||
|
||
def _clear_face_state(self):
|
||
self._hovered_face = None
|
||
self._selected_face = None
|
||
|
||
def get_selected_face_geometry(self) -> Any:
|
||
"""Return the OCCGeometryObject for the currently selected face, or None."""
|
||
if self._selected_face is not None and self._sketch is not None:
|
||
return self._sketch.build_face_geometry(self._selected_face)
|
||
return None
|
||
|
||
def clear_selected_face(self):
|
||
self._selected_face = None
|
||
self._hovered_face = None
|
||
|
||
@staticmethod
|
||
def _faces_match(a: Any, b: Any) -> bool:
|
||
"""Content-based face comparison (dicts may be from different calls to detect_faces)."""
|
||
if a is None or b is None:
|
||
return False
|
||
return Sketch2DWidget._loop_match(a.get("outer"), b.get("outer"))
|
||
|
||
@staticmethod
|
||
def _loop_match(a: Any, b: Any) -> bool:
|
||
if a is None or b is None or a["type"] != b["type"]:
|
||
return False
|
||
if a["type"] == "polygon":
|
||
return a["points"][0] == b["points"][0] and len(a["points"]) == len(b["points"])
|
||
else: # circle
|
||
return a["center"] == b["center"] and a["radius"] == b["radius"]
|
||
|
||
def _rebuild_from_sketch(self):
|
||
"""Rebuild UI point/line lists from the OCCSketch entity data.
|
||
|
||
External (underlay) entities are included in the UI lists so they
|
||
are valid pick targets for constraints and snap. The hit-test /
|
||
move / delete paths all check ``is_external`` and either skip them
|
||
(delete) or refuse to start a drag on them (move).
|
||
"""
|
||
self._points = []
|
||
self._lines = []
|
||
self._circles = []
|
||
self._selected_entities = []
|
||
if self._sketch:
|
||
# Collect points in creation order (user + external).
|
||
for eid, entity in self._sketch._entities.items():
|
||
if entity.entity_type == "point":
|
||
self._points.append(entity)
|
||
for eid, entity in self._sketch._entities.items():
|
||
if entity.entity_type == "line" and eid in self._sketch._lines:
|
||
sid, eid2 = self._sketch._lines[eid]
|
||
s_ent = self._sketch._entities.get(sid)
|
||
e_ent = self._sketch._entities.get(eid2)
|
||
if s_ent and e_ent:
|
||
self._lines.append((s_ent, e_ent))
|
||
for eid, (cid, r) in self._sketch._circles.items():
|
||
c_ent = self._sketch._entities.get(cid)
|
||
if c_ent:
|
||
self._circles.append((c_ent, r))
|
||
|
||
@staticmethod
|
||
def _is_external(entity: Any) -> bool:
|
||
"""True if an entity is a face-projected underlay / reference entity.
|
||
|
||
External entities live in the solver so constraints can reference
|
||
them, but they're protected from user deletion, dragging, and
|
||
profile extrusion.
|
||
"""
|
||
return bool(entity is not None and getattr(entity, "is_external", False))
|
||
|
||
def get_sketch(self) -> Optional[OCCSketch]:
|
||
return self._sketch
|
||
|
||
def create_sketch(self) -> OCCSketch:
|
||
self._sketch = OCCSketch()
|
||
self._points = []
|
||
self._lines = []
|
||
self._circles = []
|
||
self.update()
|
||
return self._sketch
|
||
|
||
def reset_buffers(self):
|
||
self._draw_buffer = []
|
||
self._dynamic_line_end = None
|
||
self._mode = None
|
||
self._clear_move_state()
|
||
self.update()
|
||
|
||
def _clear_move_state(self):
|
||
"""Reset all element-move drag state."""
|
||
self._moving_points = []
|
||
self._move_anchor = None
|
||
self._move_anchor_orig = None
|
||
self._move_orig_positions = {}
|
||
self._move_active = False
|
||
self._hovered_face = None
|
||
|
||
def set_mode(self, mode: Optional[str]):
|
||
self._mode = mode
|
||
self._draw_buffer = []
|
||
self._dynamic_line_end = None
|
||
self._selected_entities = []
|
||
self._hovered_constraint_idx = -1
|
||
self._hovered_face = None
|
||
# Cancel an ongoing move when switching modes
|
||
if self._move_active:
|
||
self._clear_move_state()
|
||
self.setCursor(Qt.ArrowCursor)
|
||
self.update()
|
||
|
||
def set_construct_mode(self, enabled: bool):
|
||
self._is_construct = enabled
|
||
|
||
def set_snap_mode(self, snap_type: str, enabled: bool):
|
||
self._snap_mode[snap_type] = enabled
|
||
|
||
def set_snap_distance(self, distance: int):
|
||
self._snap_distance = distance
|
||
|
||
def set_angle_steps(self, steps: int):
|
||
self._angle_steps = steps
|
||
|
||
def set_constraint_distance(self, distance: float):
|
||
self._constraint_distance_value = distance
|
||
|
||
# ─── Coordinate transforms ────────────────────────────────────────────
|
||
|
||
def _screen_to_world(self, pos: QPoint) -> QPoint:
|
||
return QPoint(
|
||
int((pos.x() - self.width() / 2 - self._offset.x()) / self._zoom),
|
||
int((self.height() / 2 - pos.y() + self._offset.y()) / self._zoom),
|
||
)
|
||
|
||
def _world_to_screen(self, pos: QPoint) -> QPoint:
|
||
return QPoint(
|
||
int(pos.x() * self._zoom + self.width() / 2 + self._offset.x()),
|
||
int(self.height() / 2 - pos.y() * self._zoom + self._offset.y()),
|
||
)
|
||
|
||
# ─── Snapping ─────────────────────────────────────────────────────────
|
||
|
||
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
|
||
if not self._snap_mode.get("point", False):
|
||
return None
|
||
nearest = None
|
||
min_dist = max_distance
|
||
for entity in self._points:
|
||
if entity.geometry:
|
||
x, y = entity.geometry
|
||
point = QPoint(int(round(x)), int(round(y)))
|
||
screen_point = self._world_to_screen(point)
|
||
dist = math.sqrt(
|
||
(pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
|
||
)
|
||
if dist < min_dist:
|
||
min_dist = dist
|
||
nearest = point
|
||
return nearest
|
||
|
||
def _find_nearest_point_entity(self, pos: QPoint, max_distance: int = 15) -> Optional[OCCSketchEntity]:
|
||
"""Find the nearest point entity to a screen position.
|
||
|
||
External (underlay) points are pickable when the underlay is visible
|
||
so the user can use them as constraint anchors; they are skipped
|
||
otherwise. The function still respects the ``point`` snap mode toggle
|
||
so the user can disable snapping entirely.
|
||
"""
|
||
if not self._snap_mode.get("point", False):
|
||
return None
|
||
nearest = None
|
||
min_dist = max_distance
|
||
for entity in self._points:
|
||
if self._is_external(entity) and not self._underlay_visible:
|
||
continue
|
||
if entity.geometry:
|
||
x, y = entity.geometry
|
||
point = QPoint(int(round(x)), int(round(y)))
|
||
screen_point = self._world_to_screen(point)
|
||
dist = math.sqrt(
|
||
(pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
|
||
)
|
||
if dist < min_dist:
|
||
min_dist = dist
|
||
nearest = entity
|
||
return nearest
|
||
|
||
def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
|
||
if not self._snap_mode.get("mpoint", False):
|
||
return None
|
||
for p1, p2 in self._lines:
|
||
if p1.geometry and p2.geometry:
|
||
x1, y1 = p1.geometry
|
||
x2, y2 = p2.geometry
|
||
mid = QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
|
||
screen_mid = self._world_to_screen(mid)
|
||
dist = math.sqrt((pos.x() - screen_mid.x()) ** 2 + (pos.y() - screen_mid.y()) ** 2)
|
||
if dist < max_distance:
|
||
return mid
|
||
return None
|
||
|
||
def _apply_angle_snap(self, start: QPoint, end: QPoint) -> QPoint:
|
||
if not self._snap_mode.get("angle", False):
|
||
return end
|
||
dx = end.x() - start.x()
|
||
dy = end.y() - start.y()
|
||
angle = math.degrees(math.atan2(dy, dx))
|
||
length = math.sqrt(dx**2 + dy**2)
|
||
snapped_angle = round(angle / self._angle_steps) * self._angle_steps
|
||
snapped_rad = math.radians(snapped_angle)
|
||
return QPoint(
|
||
int(start.x() + length * math.cos(snapped_rad)),
|
||
int(start.y() + length * math.sin(snapped_rad)),
|
||
)
|
||
|
||
def _apply_horizontal_snap(self, start: QPoint, end: QPoint) -> QPoint:
|
||
if not self._snap_mode.get("horiz", False):
|
||
return end
|
||
return QPoint(end.x(), start.y())
|
||
|
||
def _apply_vertical_snap(self, start: QPoint, end: QPoint) -> QPoint:
|
||
if not self._snap_mode.get("vert", False):
|
||
return end
|
||
return QPoint(start.x(), end.y())
|
||
|
||
def _apply_all_snaps(self, pos: QPoint, start: Optional[QPoint] = None) -> QPoint:
|
||
result = pos
|
||
point_snap = self._find_nearest_point(pos)
|
||
if point_snap:
|
||
return self._world_to_screen(point_snap)
|
||
if self._snap_mode.get("mpoint", False):
|
||
mid_snap = self._find_midpoint_snap(pos)
|
||
if mid_snap:
|
||
return self._world_to_screen(mid_snap)
|
||
if start:
|
||
if self._snap_mode.get("horiz", False):
|
||
horiz = self._apply_horizontal_snap(start, result)
|
||
if abs(result.y() - start.y()) < 10:
|
||
result = horiz
|
||
if self._snap_mode.get("vert", False):
|
||
vert = self._apply_vertical_snap(start, result)
|
||
if abs(result.x() - start.x()) < 10:
|
||
result = vert
|
||
if self._snap_mode.get("angle", False):
|
||
result = self._apply_angle_snap(start, result)
|
||
# Grid snap as a final fallback (only when nothing else applied)
|
||
if result == pos and self._snap_mode.get("grid", False):
|
||
grid_snap = self._find_grid_snap(pos)
|
||
if grid_snap is not None:
|
||
result = self._world_to_screen(grid_snap)
|
||
return result
|
||
|
||
def _find_grid_snap(self, pos: QPoint) -> Optional[QPoint]:
|
||
"""Return the nearest world-space grid intersection for a screen position."""
|
||
if not self._snap_mode.get("grid", False):
|
||
return None
|
||
world = self._screen_to_world(pos)
|
||
grid_step = 50 # world units per grid cell (matches paintEvent grid)
|
||
gx = round(world.x() / grid_step) * grid_step
|
||
gy = round(world.y() / grid_step) * grid_step
|
||
return QPoint(gx, gy)
|
||
|
||
def _apply_move_snaps(
|
||
self,
|
||
mouse_screen: QPoint,
|
||
anchor_orig_screen: QPoint,
|
||
exclude_ids: set,
|
||
) -> QPoint:
|
||
"""Snapping used while moving an element.
|
||
|
||
The moved element's own points/lines are excluded from being snap
|
||
candidates so the element never snaps to itself. ``anchor_orig_screen``
|
||
is the screen position of the grabbed anchor BEFORE the move started; it
|
||
is used as the reference origin for horizontal/vertical/angle snapping.
|
||
"""
|
||
pos = mouse_screen
|
||
|
||
# Point snap (excluding moved points)
|
||
if self._snap_mode.get("point", False):
|
||
nearest = None
|
||
min_dist = self._snap_distance
|
||
for entity in self._points:
|
||
if entity.id in exclude_ids or not entity.geometry:
|
||
continue
|
||
x, y = entity.geometry
|
||
sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||
d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2)
|
||
if d < min_dist:
|
||
min_dist = d
|
||
nearest = sp
|
||
if nearest is not None:
|
||
return nearest
|
||
|
||
# Midpoint snap (excluding lines whose both endpoints are being moved)
|
||
if self._snap_mode.get("mpoint", False):
|
||
nearest = None
|
||
min_dist = self._snap_distance
|
||
for p1, p2 in self._lines:
|
||
if p1.id in exclude_ids and p2.id in exclude_ids:
|
||
continue
|
||
if p1.geometry and p2.geometry:
|
||
x1, y1 = p1.geometry
|
||
x2, y2 = p2.geometry
|
||
mid = QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
|
||
sm = self._world_to_screen(mid)
|
||
d = math.sqrt((pos.x() - sm.x()) ** 2 + (pos.y() - sm.y()) ** 2)
|
||
if d < min_dist:
|
||
min_dist = d
|
||
nearest = sm
|
||
if nearest is not None:
|
||
return nearest
|
||
|
||
# Horizontal / vertical / angle snaps are relative to the original anchor
|
||
result = pos
|
||
if self._snap_mode.get("horiz", False):
|
||
if abs(result.y() - anchor_orig_screen.y()) < 10:
|
||
result = QPoint(result.x(), anchor_orig_screen.y())
|
||
if self._snap_mode.get("vert", False):
|
||
if abs(result.x() - anchor_orig_screen.x()) < 10:
|
||
result = QPoint(anchor_orig_screen.x(), result.y())
|
||
if self._snap_mode.get("angle", False):
|
||
result = self._apply_angle_snap(anchor_orig_screen, result)
|
||
|
||
# Grid snap as final fallback (only when nothing else changed)
|
||
if result == pos and self._snap_mode.get("grid", False):
|
||
gs = self._find_grid_snap(pos)
|
||
if gs is not None:
|
||
result = self._world_to_screen(gs)
|
||
return result
|
||
|
||
# ─── Solver helpers ───────────────────────────────────────────────────
|
||
|
||
def _get_point_entity_at(self, world_pos: QPoint) -> Optional[OCCSketchEntity]:
|
||
"""Find nearest point entity to world position.
|
||
|
||
External (underlay) points are pickable when the underlay is visible
|
||
so the user can use them as constraint anchors (e.g. the corner of a
|
||
projected face); they're skipped when the underlay is hidden.
|
||
"""
|
||
for entity in self._points:
|
||
if self._is_external(entity) and not self._underlay_visible:
|
||
continue
|
||
if entity.geometry:
|
||
x, y = entity.geometry
|
||
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
|
||
if dist < 5: # tolerance in world coords
|
||
return entity
|
||
return None
|
||
|
||
def _get_line_entity_at(self, world_pos: QPoint) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
|
||
"""Find a line near the given world position.
|
||
|
||
External (underlay) lines are pickable when the underlay is visible
|
||
(so the user can add a distance / horizontal / vertical / parallel /
|
||
perpendicular / midpoint constraint against them) and skipped when
|
||
the underlay is hidden.
|
||
"""
|
||
for p1_ent, p2_ent in self._lines:
|
||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
is_ext = bool(line_ent is not None and self._is_external(line_ent))
|
||
if is_ext and not self._underlay_visible:
|
||
continue
|
||
if p1_ent.geometry and p2_ent.geometry:
|
||
x1, y1 = p1_ent.geometry
|
||
x2, y2 = p2_ent.geometry
|
||
# Point-to-line-segment distance check
|
||
dx = x2 - x1
|
||
dy = y2 - y1
|
||
if dx == 0 and dy == 0:
|
||
continue
|
||
t = ((world_pos.x() - x1) * dx + (world_pos.y() - y1) * dy) / (dx*dx + dy*dy)
|
||
t = max(0, min(1, t))
|
||
proj_x = x1 + t * dx
|
||
proj_y = y1 + t * dy
|
||
dist = math.sqrt((world_pos.x() - proj_x)**2 + (world_pos.y() - proj_y)**2)
|
||
if dist < 10: # tolerance
|
||
return (p1_ent, p2_ent)
|
||
return None
|
||
|
||
def _find_line_sketch_entity(
|
||
self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
|
||
) -> Optional[OCCSketchEntity]:
|
||
"""Return the LINE sketch entity whose endpoints match the given point entities.
|
||
|
||
``OCCSketch._lines`` maps ``line_id -> (start_point_id, end_point_id)``;
|
||
the line *entity* itself lives in ``_entities[line_id]`` and carries the
|
||
solver line handle. The old handlers mistakenly returned the start
|
||
*point* entity, whose solver handle is a point — passing that to
|
||
``horizontal``/``vertical``/``parallel``/``perpendicular``/``midpoint``/
|
||
point-on-line/``symmetric`` raises ``TypeError: unsupported entities``.
|
||
"""
|
||
if not self._sketch:
|
||
return None
|
||
for line_id, (sid, eid2) in self._sketch._lines.items():
|
||
s_ent = self._sketch._entities.get(sid)
|
||
e_ent = self._sketch._entities.get(eid2)
|
||
if (s_ent == p1_ent and e_ent == p2_ent) or (s_ent == p2_ent and e_ent == p1_ent):
|
||
return self._sketch._entities.get(line_id) # the line entity
|
||
return None
|
||
|
||
def _get_line_endpoints(
|
||
self, line_ent: OCCSketchEntity
|
||
) -> Tuple[Optional[OCCSketchEntity], Optional[OCCSketchEntity]]:
|
||
"""Return ``(start_point_entity, end_point_entity)`` for a given line entity."""
|
||
if not self._sketch:
|
||
return None, None
|
||
for line_id, (sid, eid2) in self._sketch._lines.items():
|
||
if self._sketch._entities.get(line_id) is line_ent:
|
||
return self._sketch._entities.get(sid), self._sketch._entities.get(eid2)
|
||
return None, None
|
||
|
||
def _find_line_entity_for_line_xy(self, p1_xy: Tuple[float, float], p2_xy: Tuple[float, float]) -> Optional[OCCSketchEntity]:
|
||
"""Find the OCCSketchEntity for a line defined by its endpoint tuples."""
|
||
for eid, start_end in self._sketch._lines.items():
|
||
sid, eid2 = start_end
|
||
s_ent = self._sketch._entities.get(sid)
|
||
e_ent = self._sketch._entities.get(eid2)
|
||
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
|
||
sx, sy = s_ent.geometry
|
||
ex, ey = e_ent.geometry
|
||
if (abs(sx - p1_xy[0]) < 0.1 and abs(sy - p1_xy[1]) < 0.1 and
|
||
abs(ex - p2_xy[0]) < 0.1 and abs(ey - p2_xy[1]) < 0.1):
|
||
return s_ent # Return the line entity reference
|
||
if (abs(sx - p2_xy[0]) < 0.1 and abs(sy - p2_xy[1]) < 0.1 and
|
||
abs(ex - p1_xy[0]) < 0.1 and abs(ey - p1_xy[1]) < 0.1):
|
||
return s_ent
|
||
return None
|
||
|
||
def _get_constraints_for_line(self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity) -> List[str]:
|
||
"""Get constraint labels from both endpoint entities."""
|
||
return list(set(p1_ent.constraints + p2_ent.constraints))
|
||
|
||
# ─── Constraint tags (display + delete) ───────────────────────────────
|
||
|
||
def _line_world_mid(self, line_id: int) -> Optional[QPoint]:
|
||
"""World-space midpoint of the line with the given entity id."""
|
||
if not self._sketch or line_id not in self._sketch._lines:
|
||
return None
|
||
sid, eid2 = self._sketch._lines[line_id]
|
||
s_ent = self._sketch._entities.get(sid)
|
||
e_ent = self._sketch._entities.get(eid2)
|
||
if not s_ent or not e_ent or not s_ent.geometry or not e_ent.geometry:
|
||
return None
|
||
x1, y1 = s_ent.geometry
|
||
x2, y2 = e_ent.geometry
|
||
return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
|
||
|
||
def _point_world(self, pid: int) -> Optional[QPoint]:
|
||
"""World-space position of the point entity with the given id.
|
||
|
||
Defensive: returns *None* if the entity is missing, has no
|
||
geometry, or its geometry is not a 2-tuple of numbers. The last
|
||
check matters because the solver log also contains line ids
|
||
(e.g. for point-on-line coincident or distance to a line), and a
|
||
line's geometry is ``((x1,y1), (x2,y2))`` — naively unpacking
|
||
that as ``(x, y)`` and calling ``round()`` on the inner tuples
|
||
raises ``TypeError: type tuple doesn't define __round__ method``.
|
||
|
||
The final ``try/except`` is a last-resort safety net for exotic
|
||
cases (numpy scalars, complex numbers, badly-typed solver
|
||
output) that the explicit type checks above might miss. Better
|
||
to drop a constraint tag than to take down the entire paint
|
||
event.
|
||
"""
|
||
if not self._sketch:
|
||
return None
|
||
ent = self._sketch._entities.get(pid)
|
||
if not ent or not ent.geometry:
|
||
return None
|
||
geom = ent.geometry
|
||
# A point's geometry is a flat 2-tuple of numbers; a line's is
|
||
# ((x1, y1), (x2, y2)). Reject anything that doesn't look like
|
||
# a point so callers don't crash on line/circle/arc ids.
|
||
if not isinstance(geom, tuple) or len(geom) != 2:
|
||
return None
|
||
x, y = geom
|
||
if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
|
||
return None
|
||
try:
|
||
return QPoint(int(round(x)), int(round(y)))
|
||
except (TypeError, ValueError):
|
||
# Last-resort guard: exotic x/y types (numpy scalars, NaN,
|
||
# etc.) can still slip through. Returning None means the
|
||
# tag is dropped rather than the paint event crashing.
|
||
logger.debug(
|
||
"_point_world: could not round geometry for entity %s (%r, %r)",
|
||
pid, x, y,
|
||
)
|
||
return None
|
||
|
||
def _entity_anchor(self, eid: int) -> Optional[QPoint]:
|
||
"""Return a sensible world-space anchor for *any* entity id.
|
||
|
||
Points → their position. Lines → the midpoint. Anything else
|
||
(missing / unrecognised) → None. Used by constraint tag rendering
|
||
so a coincident or distance constraint that involves a line (e.g.
|
||
point-on-line, distance to a line) can be labelled without
|
||
crashing the paint event.
|
||
"""
|
||
if not self._sketch:
|
||
return None
|
||
ent = self._sketch._entities.get(eid)
|
||
if ent is None:
|
||
return None
|
||
if ent.entity_type == "line":
|
||
return self._line_world_mid(eid)
|
||
return self._point_world(eid)
|
||
|
||
def _compute_constraint_tags(self) -> List[Dict[str, Any]]:
|
||
"""Build the list of constraint-tag descriptors from the constraint log.
|
||
|
||
Each tag maps a displayed label + on-screen rect to the index of its
|
||
entry in ``OCCSketch._constraint_log`` so the user can hover a tag and
|
||
press Delete to remove exactly that constraint. Anchors are chosen per
|
||
constraint type: line-attached tags sit at the line midpoint, point tags
|
||
at the relevant point, point-pair tags between the two points.
|
||
"""
|
||
tags: List[Dict[str, Any]] = []
|
||
if not self._sketch:
|
||
return tags
|
||
fm = QFontMetrics(QFont("Monospace", 9))
|
||
# Track how many tags already share an anchor so we stack them vertically.
|
||
stack_count: Dict[Tuple[int, int], int] = {}
|
||
|
||
for idx, entry in enumerate(self._sketch._constraint_log):
|
||
# One bad log entry (e.g. a dangling id after a delete, an
|
||
# exotic geometry type, a numpy scalar that didn't round) must
|
||
# not take down the entire paint event. Catch any failure
|
||
# here, log it at debug, skip the tag, and keep painting.
|
||
try:
|
||
ctype = entry["type"]
|
||
ids = entry["ids"]
|
||
params = entry["params"]
|
||
anchor: Optional[QPoint] = None
|
||
label = ""
|
||
|
||
if ctype == "horizontal":
|
||
anchor = self._line_world_mid(ids[0]); label = "hrz"
|
||
elif ctype == "vertical":
|
||
anchor = self._line_world_mid(ids[0]); label = "vrt"
|
||
elif ctype == "midpoint":
|
||
anchor = self._line_world_mid(ids[1]); label = "mid"
|
||
elif ctype == "distance":
|
||
# Distance may be point-to-point OR point-to-line (e.g.
|
||
# point-on-line coincident surfaces as a coincident entry;
|
||
# a future point-to-line distance would do the same).
|
||
# Use _entity_anchor so a line id routes to the line
|
||
# midpoint instead of crashing on round(<tuple>).
|
||
a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1])
|
||
# NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
|
||
if a is not None and b is not None:
|
||
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
|
||
label = f"dst {params[0]:.1f}" if params else "dst"
|
||
elif ctype == "parallel":
|
||
anchor = self._line_world_mid(ids[0]); label = "par"
|
||
elif ctype == "perpendicular":
|
||
anchor = self._line_world_mid(ids[0]); label = "perp"
|
||
elif ctype == "angle":
|
||
anchor = self._line_world_mid(ids[0])
|
||
label = f"ang {params[0]:.0f}" if params else "ang"
|
||
elif ctype == "equal":
|
||
anchor = self._line_world_mid(ids[0]); label = "eql"
|
||
elif ctype == "coincident":
|
||
# Coincident can be point-to-point OR point-on-line (when
|
||
# a line is one of the targets). Use _entity_anchor so
|
||
# the line's midpoint is used as a fallback anchor when
|
||
# one of the ids is a line.
|
||
a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1])
|
||
if a is not None and b is not None:
|
||
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
|
||
label = "coin"
|
||
elif ctype == "symmetric":
|
||
anchor = self._line_world_mid(ids[2]); label = "sym"
|
||
elif ctype == "fixed":
|
||
anchor = self._point_world(ids[0]); label = "fix"
|
||
elif ctype == "equal_radius":
|
||
anchor = self._point_world(ids[0]); label = "eqr"
|
||
else:
|
||
continue
|
||
|
||
if anchor is None:
|
||
continue
|
||
sc = self._world_to_screen(anchor)
|
||
key = (sc.x(), sc.y())
|
||
slot = stack_count.get(key, 0)
|
||
stack_count[key] = slot + 1
|
||
|
||
text = f"> {label} <"
|
||
w = fm.horizontalAdvance(text) + 10
|
||
h = 16
|
||
# Stack successive tags above the anchor so they don't overlap.
|
||
cx = sc.x()
|
||
cy = sc.y() - 14 - slot * (h + 2)
|
||
rect = QRect(cx - w // 2, cy - h // 2, w, h)
|
||
tags.append({"idx": idx, "label": text, "rect": rect, "center": QPoint(cx, cy)})
|
||
except Exception as exc:
|
||
# Catch any failure while building this one tag (bad
|
||
# geometry, missing entity, numpy round weirdness, etc.)
|
||
# so a single bad entry can't take down the whole paint
|
||
# event. Drop the tag and move on; the user sees the
|
||
# other tags as normal.
|
||
logger.debug(
|
||
"Skipped constraint tag #%s (%s) due to %s: %s",
|
||
idx, entry.get("type"), type(exc).__name__, exc,
|
||
)
|
||
continue
|
||
return tags
|
||
|
||
# ─── Element move helpers ─────────────────────────────────────────────
|
||
|
||
def _collect_connected_points(self, anchor: OCCSketchEntity) -> List[OCCSketchEntity]:
|
||
"""Return anchor plus all point entities connected through lines.
|
||
|
||
``OCCSketch._lines`` maps line id -> (start_point_id, end_point_id); we
|
||
BFS over that graph to gather the whole element (e.g. all 4 corners of a
|
||
rectangle, or both endpoints of a single line).
|
||
"""
|
||
if not self._sketch:
|
||
return [anchor]
|
||
adjacency: Dict[int, List[int]] = {}
|
||
for _line_id, (sid, eid2) in self._sketch._lines.items():
|
||
adjacency.setdefault(sid, []).append(eid2)
|
||
adjacency.setdefault(eid2, []).append(sid)
|
||
seen = {anchor.id}
|
||
result: List[OCCSketchEntity] = [anchor]
|
||
queue = [anchor.id]
|
||
while queue:
|
||
cur = queue.pop()
|
||
for nbr in adjacency.get(cur, []):
|
||
if nbr in seen:
|
||
continue
|
||
seen.add(nbr)
|
||
ent = self._sketch._entities.get(nbr)
|
||
if ent is not None:
|
||
result.append(ent)
|
||
queue.append(nbr)
|
||
return result
|
||
|
||
def _find_move_target(self, pos: QPoint) -> Optional[Tuple[OCCSketchEntity, QPoint]]:
|
||
"""Find the element under ``pos`` and return ``(anchor_entity, anchor_world)``.
|
||
|
||
Click priority: nearest point > nearest line (anchor = closer endpoint) >
|
||
circle circumference (anchor = center). The anchor's current world
|
||
position is returned so snapping can use it as the reference origin.
|
||
"""
|
||
if not self._sketch:
|
||
return None
|
||
ent = self._find_nearest_point_entity(pos, max_distance=self._snap_distance)
|
||
if ent is not None and ent.geometry:
|
||
x, y = ent.geometry
|
||
return ent, QPoint(int(round(x)), int(round(y)))
|
||
world = self._screen_to_world(pos)
|
||
line_hit = self._get_line_entity_at(world)
|
||
if line_hit:
|
||
p1_ent, p2_ent = line_hit
|
||
if p1_ent.geometry and p2_ent.geometry:
|
||
x1, y1 = p1_ent.geometry
|
||
x2, y2 = p2_ent.geometry
|
||
d1 = (world.x() - x1) ** 2 + (world.y() - y1) ** 2
|
||
d2 = (world.x() - x2) ** 2 + (world.y() - y2) ** 2
|
||
anchor = p1_ent if d1 <= d2 else p2_ent
|
||
ax, ay = anchor.geometry
|
||
return anchor, QPoint(int(round(ax)), int(round(ay)))
|
||
for c_ent, r in self._circles:
|
||
if c_ent.geometry and r > 0:
|
||
cx, cy = c_ent.geometry
|
||
d = math.sqrt((world.x() - cx) ** 2 + (world.y() - cy) ** 2)
|
||
if abs(d - r) < 8:
|
||
return c_ent, QPoint(int(round(cx)), int(round(cy)))
|
||
return None
|
||
|
||
def _sync_solved_positions(self):
|
||
"""Sync solver positions back to UI points and lines."""
|
||
if not self._sketch:
|
||
return
|
||
for entity in self._points:
|
||
if entity.handle is not None:
|
||
try:
|
||
x, y = self._sketch.solver.params(entity.handle.params)
|
||
entity.geometry = (float(x), float(y))
|
||
except Exception:
|
||
pass
|
||
# Update line geometries from their endpoint positions
|
||
for p1_ent, p2_ent in self._lines:
|
||
if p1_ent.geometry and p2_ent.geometry:
|
||
pass # geometry already updated via point sync
|
||
|
||
def _solve_and_sync(self) -> bool:
|
||
"""Solve constraints, sync positions, update UI. Returns True if solved OK."""
|
||
if not self._sketch:
|
||
return True
|
||
ok = self._sketch.solve()
|
||
self._sync_solved_positions()
|
||
self.update()
|
||
return ok
|
||
|
||
# ─── Mouse events ─────────────────────────────────────────────────────
|
||
|
||
def mousePressEvent(self, event):
|
||
world_pos = self._screen_to_world(event.pos())
|
||
|
||
if event.button() == Qt.MiddleButton:
|
||
self._panning = True
|
||
self._pan_start = event.pos()
|
||
self.setCursor(Qt.ClosedHandCursor)
|
||
return
|
||
|
||
if event.button() == Qt.RightButton:
|
||
self._mode = None
|
||
self._draw_buffer = []
|
||
self._dynamic_line_end = None
|
||
self._selected_entities = []
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
return
|
||
|
||
if event.button() == Qt.LeftButton:
|
||
# Priority order: ① tight point-grab → ② face selection → ③ element move
|
||
if self._mode in ("select", None) and self._sketch:
|
||
# ① Tight point check (4 px) — a deliberate grab on a point.
|
||
tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4)
|
||
if (tight_ent is not None and tight_ent.geometry
|
||
and not self._is_external(tight_ent)):
|
||
x, y = tight_ent.geometry
|
||
moving = self._collect_connected_points(tight_ent)
|
||
orig: Dict[int, Tuple[float, float]] = {}
|
||
for e in moving:
|
||
if e.geometry:
|
||
orig[e.id] = (e.geometry[0], e.geometry[1])
|
||
self._moving_points = moving
|
||
self._move_anchor = tight_ent
|
||
self._move_anchor_orig = QPoint(int(round(x)), int(round(y)))
|
||
self._move_orig_positions = orig
|
||
self._move_active = True
|
||
self.setCursor(Qt.ClosedHandCursor)
|
||
return
|
||
|
||
# ② Face region — click inside a closed face to select it.
|
||
face = self._sketch.find_face_at(world_pos.x(), world_pos.y())
|
||
if face is not None:
|
||
if self._faces_match(face, self._selected_face):
|
||
# Clicking the same face again toggles it off.
|
||
self._selected_face = None
|
||
else:
|
||
self._selected_face = face
|
||
self._hovered_face = None
|
||
self.update()
|
||
return
|
||
|
||
# ③ Wider element-move check (lines and circles). External
|
||
# (underlay) entities are fixed references and can't be
|
||
# dragged — fall through to the constraint / draw handlers
|
||
# so a click on an underlay edge is treated as a constraint
|
||
# pick (the desired behavior) rather than a no-op.
|
||
target = self._find_move_target(event.pos())
|
||
if (target is not None
|
||
and not self._is_external(target[0])):
|
||
anchor_ent, anchor_world = target
|
||
moving = self._collect_connected_points(anchor_ent)
|
||
orig: Dict[int, Tuple[float, float]] = {}
|
||
for e in moving:
|
||
if e.geometry:
|
||
orig[e.id] = (e.geometry[0], e.geometry[1])
|
||
self._moving_points = moving
|
||
self._move_anchor = anchor_ent
|
||
self._move_anchor_orig = anchor_world
|
||
self._move_orig_positions = orig
|
||
self._move_active = True
|
||
self.setCursor(Qt.ClosedHandCursor)
|
||
return
|
||
|
||
snapped_pos = self._apply_all_snaps(
|
||
event.pos(), self._draw_buffer[0] if self._draw_buffer else None
|
||
)
|
||
world_snapped = (
|
||
self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
|
||
)
|
||
|
||
if self._mode == "line":
|
||
self._handle_line_click(world_snapped)
|
||
elif self._mode == "rectangle":
|
||
self._handle_rectangle_click(world_snapped)
|
||
elif self._mode == "circle":
|
||
self._handle_circle_click(world_snapped)
|
||
elif self._mode == "select":
|
||
self._handle_select_click(world_snapped)
|
||
elif self._mode == "constrain_coincident":
|
||
self._handle_constraint_coincident(world_snapped)
|
||
elif self._mode == "constrain_horizontal":
|
||
self._handle_constraint_horizontal(world_snapped)
|
||
elif self._mode == "constrain_vertical":
|
||
self._handle_constraint_vertical(world_snapped)
|
||
elif self._mode == "constrain_distance":
|
||
self._handle_constraint_distance(world_snapped)
|
||
elif self._mode == "constrain_midpoint":
|
||
self._handle_constraint_midpoint(world_snapped)
|
||
elif self._mode == "constrain_perpendicular":
|
||
self._handle_constraint_perpendicular(world_snapped)
|
||
elif self._mode == "constrain_parallel":
|
||
self._handle_constraint_parallel(world_snapped)
|
||
elif self._mode == "constrain_ptline":
|
||
self._handle_constraint_ptline(world_snapped)
|
||
elif self._mode == "constrain_symmetric":
|
||
self._handle_constraint_symmetric(world_snapped)
|
||
|
||
def mouseMoveEvent(self, event):
|
||
if self._panning and self._pan_start:
|
||
delta = event.pos() - self._pan_start
|
||
self._offset += delta
|
||
self._pan_start = event.pos()
|
||
self.update()
|
||
return
|
||
|
||
# Handle element move (grab all points of the element, snap the anchor)
|
||
if self._move_active and self._move_anchor is not None and self._move_anchor_orig is not None:
|
||
anchor_orig_screen = self._world_to_screen(self._move_anchor_orig)
|
||
exclude_ids = set(e.id for e in self._moving_points)
|
||
snapped_screen = self._apply_move_snaps(
|
||
event.pos(), anchor_orig_screen, exclude_ids
|
||
)
|
||
target_world = self._screen_to_world(snapped_screen)
|
||
dx = target_world.x() - self._move_anchor_orig.x()
|
||
dy = target_world.y() - self._move_anchor_orig.y()
|
||
for ent in self._moving_points:
|
||
if ent.id in self._move_orig_positions and ent.geometry is not None:
|
||
ox, oy = self._move_orig_positions[ent.id]
|
||
ent.geometry = (ox + dx, oy + dy)
|
||
self.update()
|
||
return
|
||
|
||
world_pos = self._screen_to_world(event.pos())
|
||
|
||
if self._draw_buffer and self._mode in ["line", "rectangle", "circle"]:
|
||
snapped = self._apply_all_snaps(
|
||
event.pos(), self._world_to_screen(self._draw_buffer[0])
|
||
)
|
||
self._dynamic_line_end = self._screen_to_world(snapped)
|
||
|
||
# Constraint-tag hover takes priority — if the cursor is over a tag,
|
||
# we highlight it for delete and skip point/line hover this move.
|
||
self._constraint_tags = self._compute_constraint_tags()
|
||
tag_hit = None
|
||
for tag in self._constraint_tags:
|
||
if tag["rect"].contains(event.pos()):
|
||
tag_hit = tag["idx"]
|
||
break
|
||
if tag_hit is not None:
|
||
self._hovered_constraint_idx = tag_hit
|
||
self._hovered_point = None
|
||
self._hovered_point_entity = None
|
||
self._hovered_line = None
|
||
self._hovered_line_entity = None
|
||
self.setCursor(Qt.PointingHandCursor)
|
||
self.update()
|
||
return
|
||
if self._hovered_constraint_idx != -1:
|
||
self._hovered_constraint_idx = -1
|
||
self.update()
|
||
|
||
# Point hover — show an open-hand cursor when a move is possible
|
||
# (explicit Move tool OR no tool selected at all)
|
||
hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor
|
||
point_snap = self._find_nearest_point(event.pos())
|
||
if point_snap:
|
||
self._hovered_point = point_snap
|
||
self._hovered_point_entity = self._find_nearest_point_entity(event.pos())
|
||
self._hovered_line = None
|
||
self._hovered_line_entity = None
|
||
self.setCursor(hover_cursor)
|
||
else:
|
||
self._hovered_point = None
|
||
self._hovered_point_entity = None
|
||
# Check line hover
|
||
line_hit = self._get_line_entity_at(world_pos)
|
||
if line_hit:
|
||
p1_ent, p2_ent = line_hit
|
||
if p1_ent.geometry and p2_ent.geometry:
|
||
self._hovered_line = (
|
||
QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))),
|
||
QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))),
|
||
)
|
||
self._hovered_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
self.setCursor(hover_cursor)
|
||
else:
|
||
self._hovered_line = None
|
||
self._hovered_line_entity = None
|
||
self.setCursor(Qt.ArrowCursor)
|
||
else:
|
||
self._hovered_line = None
|
||
self._hovered_line_entity = None
|
||
# In move mode, hovering a circle curve should also show grab cursor
|
||
if self._mode in ("select", None):
|
||
over_circle = False
|
||
for c_ent, r in self._circles:
|
||
if c_ent.geometry and r > 0:
|
||
cx, cy = c_ent.geometry
|
||
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
|
||
if abs(d - r) < 8:
|
||
over_circle = True
|
||
break
|
||
self.setCursor(Qt.OpenHandCursor if over_circle else Qt.ArrowCursor)
|
||
else:
|
||
self.setCursor(Qt.ArrowCursor)
|
||
|
||
# Face region hover (fallback when no point/line/circle/tag is under the cursor).
|
||
if (self._hovered_constraint_idx < 0
|
||
and self._hovered_point is None
|
||
and self._hovered_line is None
|
||
and self._sketch is not None):
|
||
face = self._sketch.find_face_at(world_pos.x(), world_pos.y())
|
||
if face is not None:
|
||
self._hovered_face = face
|
||
self.setCursor(Qt.PointingHandCursor if self._mode in ("select", None) else Qt.ArrowCursor)
|
||
else:
|
||
self._hovered_face = None
|
||
else:
|
||
self._hovered_face = None
|
||
|
||
self.update()
|
||
|
||
def mouseReleaseEvent(self, event):
|
||
# Element move release: commit positions into the solver, solve, and
|
||
# revert on a constraint failure. Solving AFTER the release is what
|
||
# decides whether a constrained element snaps (back) or stays moved.
|
||
if self._move_active and event.button() == Qt.LeftButton:
|
||
if self._sketch and self._moving_points:
|
||
new_positions: Dict[int, Tuple[float, float]] = {}
|
||
for ent in self._moving_points:
|
||
if ent.geometry is not None:
|
||
new_positions[ent.id] = ent.geometry
|
||
original = dict(self._move_orig_positions)
|
||
# Push the dragged positions into the solver's params so free
|
||
# points keep their new location (otherwise solve reverts them).
|
||
self._sketch.set_positions(new_positions)
|
||
ok = self._solve_and_sync()
|
||
if not ok:
|
||
logger.warning("Constraint violation while moving element; reverting")
|
||
self._sketch.set_positions(original)
|
||
self._solve_and_sync()
|
||
# Snap modes are honoured during the move (see _apply_move_snaps
|
||
# in mouseMoveEvent), so the committed positions are already snapped.
|
||
self._clear_move_state()
|
||
self.setCursor(Qt.ArrowCursor)
|
||
self.sketch_updated.emit()
|
||
self.update()
|
||
return
|
||
|
||
if event.button() == Qt.MiddleButton:
|
||
self._panning = False
|
||
self._pan_start = None
|
||
self.setCursor(Qt.ArrowCursor)
|
||
|
||
def wheelEvent(self, event):
|
||
delta = event.angleDelta().y()
|
||
factor = 1.1 if delta > 0 else 0.9
|
||
self._zoom *= factor
|
||
self._zoom = max(0.1, min(10.0, self._zoom))
|
||
self.update()
|
||
|
||
def keyPressEvent(self, event):
|
||
# Delete / Backspace removes the entity currently under the cursor
|
||
# and recomputes the surviving constraints. Priority: constraint tag >
|
||
# line > point. Works in Move mode or when no tool is selected; ignored
|
||
# while actively drawing so an in-progress line isn't clobbered.
|
||
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
|
||
if self._mode in ("select", None) and not self._draw_buffer and self._sketch is not None:
|
||
if self._hovered_constraint_idx >= 0:
|
||
self._delete_hovered_constraint()
|
||
event.accept()
|
||
return
|
||
if self._hovered_line_entity is not None:
|
||
self._delete_hovered_line()
|
||
event.accept()
|
||
return
|
||
if self._hovered_point_entity is not None:
|
||
self._delete_hovered_point()
|
||
event.accept()
|
||
return
|
||
super().keyPressEvent(event)
|
||
|
||
def _delete_hovered_constraint(self):
|
||
"""Delete the hovered constraint (by log index) and recompute the rest."""
|
||
idx = self._hovered_constraint_idx
|
||
if idx < 0 or self._sketch is None:
|
||
return
|
||
ok = self._sketch.remove_constraint_at(idx)
|
||
logger.info(f"Deleted constraint #{idx}; recompute solved={ok}")
|
||
self._hovered_constraint_idx = -1
|
||
self._rebuild_from_sketch()
|
||
self._solve_and_sync()
|
||
self.sketch_updated.emit()
|
||
self.update()
|
||
|
||
def _delete_hovered_line(self):
|
||
"""Delete the hovered line, recompute constraints, and refresh the widget."""
|
||
line_ent = self._hovered_line_entity
|
||
if line_ent is None or self._sketch is None:
|
||
return
|
||
# External (underlay) lines are reference geometry from the source
|
||
# face and can't be deleted one at a time. See delete_point for the
|
||
# same guard — the whole underlay is cleared in one shot via
|
||
# remove_external_entities when the source face is unset.
|
||
if getattr(line_ent, "is_external", False):
|
||
logger.debug("Refusing to delete external (underlay) line")
|
||
return
|
||
ok = self._sketch.delete_line(line_ent)
|
||
logger.info(f"Deleted line {line_ent.id}; recompute solved={ok}")
|
||
# Refresh widget tracking from the pruned sketch and sync solved positions.
|
||
self._rebuild_from_sketch()
|
||
self._hovered_line = None
|
||
self._hovered_line_entity = None
|
||
self._hovered_point = None
|
||
self._hovered_point_entity = None
|
||
self._selected_entities = []
|
||
self._solve_and_sync()
|
||
self.sketch_updated.emit()
|
||
self.update()
|
||
|
||
def _delete_hovered_point(self):
|
||
"""Delete the hovered point (and lines using it), then recompute."""
|
||
point_ent = self._hovered_point_entity
|
||
if point_ent is None or self._sketch is None:
|
||
return
|
||
# External (underlay) points are reference geometry from the source
|
||
# face — they can't be deleted individually. The whole underlay
|
||
# is cleared via remove_external_entities when the source face is
|
||
# removed; silently refuse here so the user gets no surprise
|
||
# cascading deletion of every other underlay element.
|
||
if getattr(point_ent, "is_external", False):
|
||
logger.debug("Refusing to delete external (underlay) point")
|
||
return
|
||
ok = self._sketch.delete_point(point_ent)
|
||
logger.info(f"Deleted point {point_ent.id}; recompute solved={ok}")
|
||
self._rebuild_from_sketch()
|
||
self._hovered_point = None
|
||
self._hovered_point_entity = None
|
||
self._hovered_line = None
|
||
self._hovered_line_entity = None
|
||
self._selected_entities = []
|
||
self._solve_and_sync()
|
||
self.sketch_updated.emit()
|
||
self.update()
|
||
|
||
# ─── Drawing handlers ─────────────────────────────────────────────────
|
||
|
||
def _handle_line_click(self, pos: QPoint):
|
||
if not self._sketch:
|
||
self._sketch = OCCSketch()
|
||
|
||
if not self._draw_buffer:
|
||
point = self._sketch.add_point(pos.x(), pos.y())
|
||
point.is_construction = self._is_construct
|
||
self._points.append(point)
|
||
self._draw_buffer.append(pos)
|
||
else:
|
||
point = self._sketch.add_point(pos.x(), pos.y())
|
||
point.is_construction = self._is_construct
|
||
self._points.append(point)
|
||
|
||
if len(self._points) >= 2:
|
||
line = self._sketch.add_line(self._points[-2], self._points[-1])
|
||
self._lines.append((self._points[-2], self._points[-1]))
|
||
self._solve_and_sync()
|
||
|
||
self._draw_buffer = [pos]
|
||
|
||
self.sketch_updated.emit()
|
||
self.update()
|
||
|
||
def _handle_rectangle_click(self, pos: QPoint):
|
||
if not self._sketch:
|
||
self._sketch = OCCSketch()
|
||
|
||
if not self._draw_buffer:
|
||
self._draw_buffer.append(pos)
|
||
else:
|
||
p1 = self._draw_buffer[0]
|
||
p2 = pos
|
||
corners = [
|
||
QPoint(p1.x(), p1.y()),
|
||
QPoint(p2.x(), p1.y()),
|
||
QPoint(p2.x(), p2.y()),
|
||
QPoint(p1.x(), p2.y()),
|
||
]
|
||
pts = []
|
||
for corner in corners:
|
||
pt = self._sketch.add_point(corner.x(), corner.y())
|
||
pt.is_construction = self._is_construct
|
||
self._points.append(pt)
|
||
pts.append(pt)
|
||
|
||
for i in range(4):
|
||
line = self._sketch.add_line(pts[i], pts[(i + 1) % 4])
|
||
self._lines.append((pts[i], pts[(i + 1) % 4]))
|
||
|
||
self._solve_and_sync()
|
||
self._draw_buffer = []
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
|
||
self.sketch_updated.emit()
|
||
self.update()
|
||
|
||
def _handle_circle_click(self, pos: QPoint):
|
||
if not self._sketch:
|
||
self._sketch = OCCSketch()
|
||
|
||
if not self._draw_buffer:
|
||
center = self._sketch.add_point(pos.x(), pos.y())
|
||
center.is_construction = self._is_construct
|
||
self._points.append(center)
|
||
self._draw_buffer.append(pos)
|
||
else:
|
||
center = self._points[-1]
|
||
cx, cy = center.geometry if center.geometry else (0, 0)
|
||
radius = math.sqrt((pos.x() - cx) ** 2 + (pos.y() - cy) ** 2)
|
||
if radius > 0:
|
||
self._sketch.add_circle(center, radius)
|
||
self._circles.append((center, radius))
|
||
self._draw_buffer = []
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
|
||
self.sketch_updated.emit()
|
||
self.update()
|
||
|
||
def _handle_select_click(self, pos: QPoint):
|
||
"""Handle select mode - find and highlight points/lines for selection."""
|
||
# Check if clicking on an existing point
|
||
nearest_entity = self._get_point_entity_at(pos)
|
||
if nearest_entity:
|
||
if nearest_entity not in self._selected_entities:
|
||
self._selected_entities.append(nearest_entity)
|
||
else:
|
||
self._selected_entities.remove(nearest_entity)
|
||
else:
|
||
# Check if clicking on a line
|
||
line_hit = self._get_line_entity_at(pos)
|
||
if line_hit:
|
||
p1_ent, p2_ent = line_hit
|
||
for entity in [p1_ent, p2_ent]:
|
||
if entity and entity not in self._selected_entities:
|
||
self._selected_entities.append(entity)
|
||
|
||
# ─── Constraint handlers (with solver calls) ──────────────────────────
|
||
|
||
def _handle_constraint_coincident(self, world_pos: QPoint):
|
||
ent = self._get_point_entity_at(world_pos)
|
||
if ent is None:
|
||
return
|
||
self._selected_entities.append(ent)
|
||
if len(self._selected_entities) >= 2:
|
||
e1, e2 = self._selected_entities[:2]
|
||
if self._sketch:
|
||
self._sketch.constrain_coincident(e1, e2)
|
||
ok = self._solve_and_sync()
|
||
if ok:
|
||
logger.info("Coincident constraint added")
|
||
self._selected_entities = []
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
|
||
def _handle_constraint_horizontal(self, world_pos: QPoint):
|
||
line_hit = self._get_line_entity_at(world_pos)
|
||
if line_hit is None:
|
||
return
|
||
p1_ent, p2_ent = line_hit
|
||
# SolveSpace's horizontal() needs the LINE entity's handle, not a
|
||
# point's. Look up the line sketch entity by its endpoints.
|
||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
if line_ent is None:
|
||
return
|
||
if self._sketch:
|
||
self._sketch.constrain_horizontal(line_ent)
|
||
# Tag endpoints so paintEvent renders the "> hrz <" label.
|
||
for ent in (p1_ent, p2_ent):
|
||
if "hrz" not in ent.constraints:
|
||
ent.constraints.append("hrz")
|
||
ok = self._solve_and_sync()
|
||
if ok:
|
||
logger.info("Horizontal constraint added")
|
||
else:
|
||
logger.warning("Horizontal constraint failed to solve")
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
|
||
def _handle_constraint_vertical(self, world_pos: QPoint):
|
||
line_hit = self._get_line_entity_at(world_pos)
|
||
if line_hit is None:
|
||
return
|
||
p1_ent, p2_ent = line_hit
|
||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
if line_ent is None:
|
||
return
|
||
if self._sketch:
|
||
self._sketch.constrain_vertical(line_ent)
|
||
for ent in (p1_ent, p2_ent):
|
||
if "vrt" not in ent.constraints:
|
||
ent.constraints.append("vrt")
|
||
ok = self._solve_and_sync()
|
||
if ok:
|
||
logger.info("Vertical constraint added")
|
||
else:
|
||
logger.warning("Vertical constraint failed to solve")
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
|
||
def _handle_constraint_distance(self, world_pos: QPoint):
|
||
point_ent = self._get_point_entity_at(world_pos)
|
||
if point_ent:
|
||
# Point clicked: collect points; constraint applied after 2nd point.
|
||
self._selected_entities.append(point_ent)
|
||
else:
|
||
line_hit = self._get_line_entity_at(world_pos)
|
||
if line_hit:
|
||
p1_ent, p2_ent = line_hit
|
||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
if line_ent is not None:
|
||
# Line clicked: constrain its length (endpoint distance).
|
||
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
|
||
self._constraint_distance_value, 0, 10000, 2)
|
||
if ok and self._sketch:
|
||
self._sketch.constrain_distance(p1_ent, p2_ent, dist)
|
||
self._solve_and_sync()
|
||
logger.info(f"Line distance {dist:.2f}mm")
|
||
self._selected_entities = []
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
return
|
||
|
||
if len(self._selected_entities) >= 2:
|
||
e1, e2 = self._selected_entities[:2]
|
||
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
|
||
self._constraint_distance_value, 0, 10000, 2)
|
||
if ok and self._sketch:
|
||
self._sketch.constrain_distance(e1, e2, dist)
|
||
self._solve_and_sync()
|
||
logger.info(f"Distance {dist:.2f}mm")
|
||
self._selected_entities = []
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
|
||
def _handle_constraint_midpoint(self, world_pos: QPoint):
|
||
point_ent = self._get_point_entity_at(world_pos)
|
||
if point_ent and not self._selected_entities:
|
||
self._selected_entities.append(point_ent)
|
||
elif self._selected_entities:
|
||
line_hit = self._get_line_entity_at(world_pos)
|
||
if line_hit:
|
||
p1_ent, p2_ent = line_hit
|
||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
if line_ent is not None and self._sketch and self._selected_entities:
|
||
self._sketch.constrain_midpoint(self._selected_entities[0], line_ent)
|
||
for ent in (p1_ent, p2_ent):
|
||
if "mid" not in ent.constraints:
|
||
ent.constraints.append("mid")
|
||
self._solve_and_sync()
|
||
logger.info("Midpoint constraint added")
|
||
self._selected_entities = []
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
|
||
def _handle_constraint_perpendicular(self, world_pos: QPoint):
|
||
line_hit = self._get_line_entity_at(world_pos)
|
||
if line_hit is None:
|
||
return
|
||
p1_ent, p2_ent = line_hit
|
||
target_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
if target_ent is None:
|
||
return
|
||
|
||
if not self._selected_entities:
|
||
# First click: store this line ENTITY (not a point):
|
||
self._selected_entities.append(target_ent)
|
||
else:
|
||
# Second click: apply perpendicular constraint between two LINE entities
|
||
prev_ent = self._selected_entities[0]
|
||
if self._sketch:
|
||
self._sketch.constrain_perpendicular(prev_ent, target_ent)
|
||
self._solve_and_sync()
|
||
logger.info("Perpendicular constraint added")
|
||
self._selected_entities = []
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
|
||
def _handle_constraint_parallel(self, world_pos: QPoint):
|
||
line_hit = self._get_line_entity_at(world_pos)
|
||
if line_hit is None:
|
||
return
|
||
p1_ent, p2_ent = line_hit
|
||
target_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
if target_ent is None:
|
||
return
|
||
|
||
if not self._selected_entities:
|
||
self._selected_entities.append(target_ent)
|
||
else:
|
||
prev_ent = self._selected_entities[0]
|
||
if self._sketch:
|
||
self._sketch.constrain_parallel(prev_ent, target_ent)
|
||
self._solve_and_sync()
|
||
logger.info("Parallel constraint added")
|
||
self._selected_entities = []
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
|
||
def _handle_constraint_ptline(self, world_pos: QPoint):
|
||
"""Point-on-line coincident (point on line)."""
|
||
point_ent = self._get_point_entity_at(world_pos)
|
||
if point_ent and not self._selected_entities:
|
||
self._selected_entities.append(point_ent)
|
||
elif self._selected_entities:
|
||
line_hit = self._get_line_entity_at(world_pos)
|
||
if line_hit:
|
||
p1_ent, p2_ent = line_hit
|
||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
if line_ent is not None and self._sketch and self._selected_entities:
|
||
# coincident(point, line) = point-on-line; needs the line handle
|
||
self._sketch.constrain_coincident(self._selected_entities[0], line_ent)
|
||
self._solve_and_sync()
|
||
logger.info("Point-on-line constraint added")
|
||
self._selected_entities = []
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
|
||
def _handle_constraint_symmetric(self, world_pos: QPoint):
|
||
"""Symmetric constraint: select entity1, entity2, then mirror line."""
|
||
# Click 3: mirror line
|
||
if len(self._selected_entities) == 2:
|
||
line_hit = self._get_line_entity_at(world_pos)
|
||
if line_hit:
|
||
p1_ent, p2_ent = line_hit
|
||
mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
if mirror_line is not None and self._sketch:
|
||
self._sketch.constrain_symmetric(
|
||
self._selected_entities[0], self._selected_entities[1], mirror_line
|
||
)
|
||
ok = self._solve_and_sync()
|
||
if ok:
|
||
logger.info("Symmetric constraint added")
|
||
else:
|
||
logger.warning("Symmetric constraint failed to solve")
|
||
self._selected_entities = []
|
||
self._mode = None
|
||
self.constrain_done.emit()
|
||
self.update()
|
||
return
|
||
# Clicks 1-2: select point entities
|
||
point_ent = self._get_point_entity_at(world_pos)
|
||
if point_ent:
|
||
self._selected_entities.append(point_ent)
|
||
n_selected = len(self._selected_entities)
|
||
if n_selected < 2:
|
||
logger.info(f"Select entity {n_selected + 1} for symmetry (or line for mirror)")
|
||
elif n_selected == 2:
|
||
logger.info("Click on the mirror line")
|
||
self.update()
|
||
|
||
# ─── Painting ─────────────────────────────────────────────────────────
|
||
|
||
def _calculate_midpoint(self, p1: QPoint, p2: QPoint) -> QPointF:
|
||
return QPointF((p1.x() + p2.x()) / 2.0, (p1.y() + p2.y()) / 2.0)
|
||
|
||
def _point_distance(self, p1: QPoint, p2: QPoint) -> float:
|
||
return math.sqrt((p1.x() - p2.x()) ** 2 + (p1.y() - p2.y()) ** 2)
|
||
|
||
def _draw_distance_measurement(self, painter: QPainter, p1: QPoint, p2: QPoint):
|
||
"""Draw dimension lines and distance value between two world-coord points."""
|
||
sp1 = self._world_to_screen(p1)
|
||
sp2 = self._world_to_screen(p2)
|
||
|
||
dx = sp2.x() - sp1.x()
|
||
dy = sp2.y() - sp1.y()
|
||
length = math.sqrt(dx * dx + dy * dy)
|
||
if length == 0:
|
||
return
|
||
|
||
# Perpendicular direction for offset lines
|
||
perp_dx = -dy / length
|
||
perp_dy = dx / length
|
||
offset = 25.0
|
||
|
||
p1a = QPointF(sp1.x() + perp_dx, sp1.y() + perp_dy)
|
||
p1b = QPointF(sp1.x() + perp_dx * offset, sp1.y() + perp_dy * offset)
|
||
p2a = QPointF(sp2.x() + perp_dx, sp2.y() + perp_dy)
|
||
p2b = QPointF(sp2.x() + perp_dx * offset, sp2.y() + perp_dy * offset)
|
||
mid = QPointF((p1b.x() + p2b.x()) / 2, (p1b.y() + p2b.y()) / 2)
|
||
|
||
pen_dim = QPen(QColor("#a6e3a1"), 1.5, Qt.DotLine)
|
||
painter.setPen(pen_dim)
|
||
painter.drawLine(p1a.toPoint(), p1b.toPoint())
|
||
painter.drawLine(p2a.toPoint(), p2b.toPoint())
|
||
painter.drawLine(p1b.toPoint(), p2b.toPoint())
|
||
|
||
# Draw distance text
|
||
dist = self._point_distance(p1, p2)
|
||
painter.save()
|
||
painter.translate(mid)
|
||
painter.scale(1, -1)
|
||
painter.setPen(QPen(QColor("#a6e3a1"), 1))
|
||
painter.drawText(0, 0, f"{dist:.2f}")
|
||
painter.restore()
|
||
|
||
def paintEvent(self, event):
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.Antialiasing)
|
||
painter.fillRect(self.rect(), QColor("#1e1e2e"))
|
||
|
||
# ── Grid ──
|
||
pen = QPen(QColor("#45475a"), 1)
|
||
painter.setPen(pen)
|
||
grid_size = 50 * self._zoom
|
||
ox = int(self._offset.x() % max(1, int(grid_size)))
|
||
oy = int(self._offset.y() % max(1, int(grid_size)))
|
||
for x in range(ox, self.width(), max(1, int(grid_size))):
|
||
painter.drawLine(x, 0, x, self.height())
|
||
for y in range(oy, self.height(), max(1, int(grid_size))):
|
||
painter.drawLine(0, y, self.width(), y)
|
||
|
||
# ── Origin axes ──
|
||
origin = self._world_to_screen(QPoint(0, 0))
|
||
pen = QPen(QColor("#f38ba8"), 2)
|
||
painter.setPen(pen)
|
||
painter.drawLine(origin.x() - 20, origin.y(), origin.x() + 20, origin.y())
|
||
painter.drawLine(origin.x(), origin.y() - 20, origin.x(), origin.y() + 20)
|
||
|
||
# ── Source-face underlay fill (sketch-on-surface) ──
|
||
# The underlay is now drawn as real construction-line entities
|
||
# (rendered below in the Points / Lines sections with an orange
|
||
# dashed style) so the user can pick them for constraints. We
|
||
# still draw a faint fill over the *outer* loop of the projected
|
||
# face here for visual context, but the lines themselves are NOT
|
||
# drawn from this cache any more — that would double-paint the
|
||
# underlay on top of the entity-based lines.
|
||
if self._source_underlay_uv and self._underlay_visible:
|
||
if self._source_underlay_uv[0] and len(self._source_underlay_uv[0]) >= 3:
|
||
fill_poly = QPolygonF([
|
||
self._world_to_screen(QPoint(int(round(u)), int(round(v))))
|
||
for (u, v) in self._source_underlay_uv[0]
|
||
])
|
||
painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
|
||
painter.setPen(Qt.NoPen)
|
||
painter.drawPolygon(fill_poly)
|
||
|
||
# ── Points ──
|
||
for entity in self._points:
|
||
# External / underlay points are rendered in the dedicated
|
||
# underlay block below (with an orange style) and skipped when
|
||
# the underlay is hidden. Skip them here to avoid double draw.
|
||
if self._is_external(entity):
|
||
continue
|
||
if entity.geometry:
|
||
x, y = entity.geometry
|
||
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||
if entity.is_construction:
|
||
painter.setPen(QPen(QColor("#6c7086"), 1))
|
||
painter.setBrush(QBrush(QColor("#6c7086")))
|
||
else:
|
||
painter.setPen(QPen(QColor("#89b4fa"), 2))
|
||
painter.setBrush(QBrush(QColor("#89b4fa")))
|
||
size = 4 if entity.is_construction else 6
|
||
painter.drawEllipse(screen_pos, size, size)
|
||
|
||
# ── Underlay (face-projected) construction lines & points ──
|
||
# Drawn with an orange dashed style so the user can visually tell
|
||
# them apart from user-drawn construction lines (grey). Gated by
|
||
# the underlay visibility toggle.
|
||
if self._underlay_visible:
|
||
for p1_ent, p2_ent in self._lines:
|
||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
if not self._is_external(line_ent):
|
||
continue
|
||
if p1_ent.geometry and p2_ent.geometry:
|
||
x1, y1 = p1_ent.geometry
|
||
x2, y2 = p2_ent.geometry
|
||
sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
|
||
sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
|
||
painter.setPen(QPen(QColor("#fab387"), 1, Qt.DashLine))
|
||
painter.drawLine(sp1, sp2)
|
||
for entity in self._points:
|
||
if not self._is_external(entity):
|
||
continue
|
||
if entity.geometry:
|
||
x, y = entity.geometry
|
||
screen_pos = self._world_to_screen(
|
||
QPoint(int(round(x)), int(round(y)))
|
||
)
|
||
painter.setPen(QPen(QColor("#fab387"), 1))
|
||
painter.setBrush(QBrush(QColor("#fab387")))
|
||
painter.drawEllipse(screen_pos, 4, 4)
|
||
|
||
# ── Lines ──
|
||
for p1_ent, p2_ent in self._lines:
|
||
# External lines are drawn above; skip here to avoid double draw.
|
||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||
if self._is_external(line_ent):
|
||
continue
|
||
if p1_ent.geometry and p2_ent.geometry:
|
||
x1, y1 = p1_ent.geometry
|
||
x2, y2 = p2_ent.geometry
|
||
sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
|
||
sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
|
||
|
||
is_construction = p1_ent.is_construction or p2_ent.is_construction
|
||
if is_construction:
|
||
painter.setPen(QPen(QColor("#6c7086"), 1, Qt.DashLine))
|
||
else:
|
||
painter.setPen(QPen(QColor("#cdd6f4"), 2))
|
||
painter.drawLine(sp1, sp2)
|
||
|
||
# ── Constraint tags (log-driven; drawn upright in screen space) ──
|
||
# Tags are recomputed here so paint stays in sync with the latest solve.
|
||
self._constraint_tags = self._compute_constraint_tags()
|
||
tag_font = QFont("Monospace", 9)
|
||
painter.setFont(tag_font)
|
||
for tag in self._constraint_tags:
|
||
rect: QRect = tag["rect"]
|
||
hovered = tag["idx"] == self._hovered_constraint_idx
|
||
# Background pill so the label is readable over the sketch.
|
||
painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2))
|
||
painter.setBrush(QBrush(QColor(40, 40, 60, 200)))
|
||
painter.drawRoundedRect(rect, 6, 6)
|
||
painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
|
||
painter.drawText(rect, Qt.AlignCenter, tag["label"])
|
||
|
||
# ── Circles ──
|
||
for center_ent, radius in self._circles:
|
||
if center_ent.geometry:
|
||
cx, cy = center_ent.geometry
|
||
sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
|
||
sr = radius * self._zoom
|
||
painter.setPen(QPen(QColor("#cdd6f4"), 2))
|
||
painter.setBrush(Qt.NoBrush)
|
||
painter.drawEllipse(sc, int(sr), int(sr))
|
||
|
||
# ── Dynamic drawing previews ──
|
||
if self._draw_buffer and self._dynamic_line_end and self._mode == "line":
|
||
start = self._world_to_screen(self._draw_buffer[0])
|
||
end = self._world_to_screen(self._dynamic_line_end)
|
||
painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
|
||
painter.drawLine(start, end)
|
||
|
||
if self._draw_buffer and self._dynamic_line_end and self._mode == "rectangle":
|
||
p1 = self._world_to_screen(self._draw_buffer[0])
|
||
p2 = self._world_to_screen(self._dynamic_line_end)
|
||
painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
|
||
painter.drawRect(min(p1.x(), p2.x()), min(p1.y(), p2.y()),
|
||
abs(p2.x() - p1.x()), abs(p2.y() - p1.y()))
|
||
|
||
if self._draw_buffer and self._dynamic_line_end and self._mode == "circle":
|
||
center = self._world_to_screen(self._draw_buffer[0])
|
||
end = self._world_to_screen(self._dynamic_line_end)
|
||
r = math.sqrt((end.x() - center.x()) ** 2 + (end.y() - center.y()) ** 2)
|
||
painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
|
||
painter.setBrush(Qt.NoBrush)
|
||
painter.drawEllipse(center, int(r), int(r))
|
||
|
||
# ── Hovered point highlight ──
|
||
if self._hovered_point:
|
||
screen_pos = self._world_to_screen(self._hovered_point)
|
||
painter.setPen(QPen(QColor("#f9e2af"), 2))
|
||
painter.setBrush(Qt.NoBrush)
|
||
painter.drawEllipse(screen_pos, 10, 10)
|
||
|
||
# ── Hovered line distance measurement ──
|
||
if self._hovered_line and not self._hovered_point:
|
||
p1, p2 = self._hovered_line
|
||
sp1 = self._world_to_screen(p1)
|
||
sp2 = self._world_to_screen(p2)
|
||
painter.setPen(QPen(QColor("#a6e3a1"), 2))
|
||
painter.drawLine(sp1, sp2)
|
||
self._draw_distance_measurement(painter, p1, p2)
|
||
|
||
# ── Moved-element highlight ──
|
||
if self._move_active and self._moving_points:
|
||
painter.setPen(QPen(QColor("#f38ba8"), 2))
|
||
painter.setBrush(Qt.NoBrush)
|
||
for ent in self._moving_points:
|
||
if ent.geometry:
|
||
x, y = ent.geometry
|
||
sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||
painter.drawEllipse(sp, 10, 10)
|
||
if self._move_anchor is not None and self._move_anchor.geometry:
|
||
x, y = self._move_anchor.geometry
|
||
sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||
painter.setPen(QPen(QColor("#f9e2af"), 2))
|
||
painter.drawEllipse(sp, 12, 12)
|
||
|
||
# ── Selected face preview (detected regions) ──
|
||
if self._sketch:
|
||
faces = self._sketch.detect_faces()
|
||
for face in faces:
|
||
is_hovered = self._faces_match(face, self._hovered_face)
|
||
is_selected = self._faces_match(face, self._selected_face)
|
||
if not (is_hovered or is_selected):
|
||
continue
|
||
path = QPainterPath()
|
||
outer = face["outer"]
|
||
if outer["type"] == "polygon":
|
||
pts = outer["points"]
|
||
sp0 = self._world_to_screen(QPoint(int(round(pts[0][0])), int(round(pts[0][1]))))
|
||
path.moveTo(sp0.x(), sp0.y())
|
||
for (px, py) in pts[1:]:
|
||
sp = self._world_to_screen(QPoint(int(round(px)), int(round(py))))
|
||
path.lineTo(sp.x(), sp.y())
|
||
path.closeSubpath()
|
||
else: # circle
|
||
cx, cy = outer["center"]
|
||
spc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
|
||
sr = outer["radius"] * self._zoom
|
||
path.addEllipse(spc, sr, sr)
|
||
# Add holes as sub-paths (odd-even fill punches them out).
|
||
for hole in face["holes"]:
|
||
if hole["type"] == "polygon":
|
||
pts = hole["points"]
|
||
sp0 = self._world_to_screen(QPoint(int(round(pts[0][0])), int(round(pts[0][1]))))
|
||
path.moveTo(sp0.x(), sp0.y())
|
||
for (px, py) in pts[1:]:
|
||
sp = self._world_to_screen(QPoint(int(round(px)), int(round(py))))
|
||
path.lineTo(sp.x(), sp.y())
|
||
path.closeSubpath()
|
||
else:
|
||
hcx, hcy = hole["center"]
|
||
hspc = self._world_to_screen(QPoint(int(round(hcx)), int(round(hcy))))
|
||
hsr = hole["radius"] * self._zoom
|
||
path.addEllipse(hspc, hsr, hsr)
|
||
path.setFillRule(Qt.OddEvenFill)
|
||
|
||
# Determine colours
|
||
if is_selected and not is_hovered:
|
||
fill = QColor(137, 180, 250, 100) # blue-ish
|
||
stroke = QPen(QColor("#89b4fa"), 2)
|
||
elif is_hovered:
|
||
fill = QColor(249, 226, 175, 120) # gold-ish
|
||
stroke = QPen(QColor("#f9e2af"), 2)
|
||
else:
|
||
fill = QColor(166, 227, 161, 80) # green-ish
|
||
stroke = QPen(QColor("#a6e3a1"), 1)
|
||
painter.setPen(stroke)
|
||
painter.setBrush(fill)
|
||
painter.drawPath(path)
|
||
|
||
# ── Selected entities ──
|
||
for entity in self._selected_entities:
|
||
if entity.geometry:
|
||
x, y = entity.geometry
|
||
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||
painter.setPen(QPen(QColor("#f9e2af"), 2))
|
||
painter.setBrush(Qt.NoBrush)
|
||
painter.drawEllipse(screen_pos, 12, 12)
|
||
|
||
# ── DOF display ──
|
||
if self._sketch:
|
||
try:
|
||
dof = self._sketch.get_solver_dof()
|
||
painter.save()
|
||
painter.setPen(QPen(QColor("#a6adc8"), 1))
|
||
font = QFont("Monospace", 9)
|
||
painter.setFont(font)
|
||
painter.drawText(10, 20, f"DOF: {dof}")
|
||
painter.restore()
|
||
except Exception:
|
||
pass
|
||
class Viewer3DWidget(QWidget):
|
||
"""3D viewer widget using OCC's native AIS display."""
|
||
|
||
# Emitted when the user picks a planar face to sketch on.
|
||
# Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z).
|
||
facePicked = Signal(tuple, tuple, tuple, object)
|
||
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
||
pickFaceCancelled = Signal()
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
||
self.setAttribute(Qt.WA_PaintOnScreen)
|
||
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||
self.setAutoFillBackground(False)
|
||
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
||
self.setFocusPolicy(Qt.StrongFocus)
|
||
# Try OCC renderer first; fall back to pygfx if unavailable.
|
||
self._renderer: Any = None
|
||
self._initialized = False
|
||
self._meshes: Dict[str, Any] = {}
|
||
self._selected_normal: Optional[Tuple[float, float, float]] = None
|
||
self._centroid: Optional[Tuple[float, float, float]] = None
|
||
self._pending_meshes: List[Tuple] = []
|
||
# When True, a left-click picks a planar face (for sketch-on-surface)
|
||
# instead of orbiting the camera. Set via set_pick_face_mode().
|
||
self._pick_face_mode: bool = False
|
||
# Most recently recorded owning obj_id for the face returned by
|
||
# ``pick_planar_face``. Stashed on each pick pass so the host can
|
||
# pair the picked face with the body it belongs to (used to auto-
|
||
# target a cut/union extrude against the body the sketch was
|
||
# projected onto).
|
||
self._last_pick_owner_obj_id: Optional[str] = None
|
||
|
||
def _init_renderer(self) -> None:
|
||
"""Create the best available renderer."""
|
||
if self._renderer is not None:
|
||
return
|
||
import sys as _sys
|
||
_sys.stdout.flush()
|
||
logger.info("Renderer: starting import...")
|
||
from fluency.rendering.occ_renderer import OCCRenderer
|
||
from fluency.rendering.pygfx_renderer import PygfxRenderer
|
||
logger.info("Renderer: imports done, creating OCCRenderer...")
|
||
occ = OCCRenderer()
|
||
logger.info("Renderer: calling occ.initialize...")
|
||
try:
|
||
ok = occ.initialize(self)
|
||
except Exception as exc:
|
||
logger.warning(f"OCCRenderer init raised: {exc}")
|
||
ok = False
|
||
logger.info(f"Renderer: OCC result={ok}")
|
||
if ok:
|
||
self._renderer = occ
|
||
logger.info("Using OCCRenderer (native BRep display)")
|
||
else:
|
||
logger.info("Falling back to PygfxRenderer")
|
||
self._renderer = PygfxRenderer()
|
||
logger.info("Renderer: calling pygfx initialize...")
|
||
self._renderer.initialize(self)
|
||
logger.info("Renderer: pygfx init done")
|
||
self._initialized = True
|
||
logger.info("Renderer: initialization complete")
|
||
|
||
def showEvent(self, event):
|
||
logger.info("Viewer3DWidget showEvent - initializing renderer")
|
||
if not self._initialized:
|
||
self._init_renderer()
|
||
logger.info(f"Renderer initialized, pending meshes: {len(self._pending_meshes)}")
|
||
for args in self._pending_meshes:
|
||
self.add_mesh(*args)
|
||
self._pending_meshes.clear()
|
||
self._renderer.render()
|
||
|
||
def _ensure_initialized(self):
|
||
if not self._initialized:
|
||
logger.debug("Ensuring renderer is initialized")
|
||
self._init_renderer()
|
||
|
||
def get_renderer(self):
|
||
self._ensure_initialized()
|
||
return self._renderer
|
||
|
||
def show_shape(self, shape: Any, color=None, name=None) -> str:
|
||
"""Display an OCC TopoDS_Shape.
|
||
|
||
Uses OCCRenderer.add_shape for native AIS display, or falls back to
|
||
triangulation + add_mesh for the PygfxRenderer.
|
||
"""
|
||
self._ensure_initialized()
|
||
from fluency.rendering.occ_renderer import OCCRenderer
|
||
if isinstance(self._renderer, OCCRenderer):
|
||
oid = self._renderer.add_shape(shape, color, name)
|
||
self._renderer.render()
|
||
return oid
|
||
# Fallback: tessellate and use the mesh pipeline.
|
||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||
k = OCGeometryKernel()
|
||
from fluency.geometry_occ.sketch import OCCSketch
|
||
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
|
||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||
obj = OCCGeometryObject(shape)
|
||
verts, faces = k.get_mesh(obj)
|
||
oid = self._renderer.add_mesh(verts, faces, color, name)
|
||
# Edges
|
||
try:
|
||
e_verts, e_edges = k.get_edges(obj)
|
||
if len(e_verts) > 0:
|
||
self._renderer.add_wireframe(e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges")
|
||
except Exception:
|
||
pass
|
||
self._renderer.render()
|
||
return oid
|
||
|
||
def add_mesh(self, vertices, faces, color=None, name=None) -> str:
|
||
logger.debug(
|
||
f"add_mesh called: initialized={self._initialized}, vertices={len(vertices)}, faces={len(faces)}, name={name}"
|
||
)
|
||
if not self._initialized:
|
||
self._pending_meshes.append((vertices, faces, color, name))
|
||
logger.info(f"Queued pending mesh, total pending: {len(self._pending_meshes)}")
|
||
return f"pending_{len(self._pending_meshes)}"
|
||
|
||
self._ensure_initialized()
|
||
mesh_id = self._renderer.add_mesh(vertices, faces, color, name)
|
||
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces, "name": name}
|
||
self._renderer.render()
|
||
logger.info(f"Added mesh: {mesh_id}, name={name}")
|
||
return mesh_id
|
||
|
||
def update_mesh(self, mesh_id: str, vertices, faces):
|
||
self._ensure_initialized()
|
||
self._renderer.update_mesh(mesh_id, vertices, faces)
|
||
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces}
|
||
self._renderer.render()
|
||
|
||
def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
|
||
self._ensure_initialized()
|
||
wid = self._renderer.add_wireframe(vertices, edges, color or (0.9, 0.9, 0.9), line_width, name)
|
||
self._renderer.render()
|
||
return wid
|
||
|
||
def remove_mesh(self, mesh_id: str):
|
||
self._ensure_initialized()
|
||
self._renderer.remove_mesh(mesh_id)
|
||
if mesh_id in self._meshes:
|
||
del self._meshes[mesh_id]
|
||
self._renderer.render()
|
||
|
||
def set_visibility(self, mesh_id: str, visible: bool) -> bool:
|
||
"""Show or hide a previously-added mesh without removing it.
|
||
|
||
Used by the per-body visibility toggle on the body list so the
|
||
user can quickly hide a body (e.g. to verify a cut worked on
|
||
another body). Returns True on success, False if the mesh is
|
||
unknown to the renderer or the renderer doesn't support it
|
||
(e.g. the Pygfx fallback ABI).
|
||
"""
|
||
self._ensure_initialized()
|
||
fn = getattr(self._renderer, "set_visibility", None)
|
||
if fn is None:
|
||
return False
|
||
ok = fn(mesh_id, visible)
|
||
if ok:
|
||
self._renderer.render()
|
||
return ok
|
||
|
||
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
|
||
"""Set a previously-added mesh's transparency (0..1).
|
||
|
||
Used by the live extrude preview to dim the target body so the
|
||
previewed result reads on top of it.
|
||
"""
|
||
self._ensure_initialized()
|
||
fn = getattr(self._renderer, "set_object_transparency", None)
|
||
if fn is None:
|
||
return False
|
||
return fn(mesh_id, transparency)
|
||
|
||
def show_preview(self, shape: Any, color=None, transparency: float = 0.60) -> None:
|
||
"""Display a temporary transparent preview of *shape* in the 3D view.
|
||
|
||
Used by the ExtrudeDialog live preview: as the user drags the
|
||
length spinner or toggles Cut/Through-All, the host recomputes
|
||
the operation result and shows it here. Call clear_preview()
|
||
when the dialog closes.
|
||
"""
|
||
self._ensure_initialized()
|
||
fn = getattr(self._renderer, "preview_shape", None)
|
||
if fn is None:
|
||
return
|
||
fn(shape, color, transparency)
|
||
|
||
def clear_preview(self) -> None:
|
||
"""Remove the live extrude preview shape, if any."""
|
||
if not self._initialized or self._renderer is None:
|
||
return
|
||
fn = getattr(self._renderer, "clear_preview", None)
|
||
if fn is None:
|
||
return
|
||
fn()
|
||
|
||
def clear_scene(self):
|
||
self._ensure_initialized()
|
||
self._renderer.clear_scene()
|
||
self._meshes.clear()
|
||
self._renderer.render()
|
||
|
||
def fit_camera(self):
|
||
self._ensure_initialized()
|
||
self._renderer.fit_camera()
|
||
self._renderer.render()
|
||
|
||
def mousePressEvent(self, event):
|
||
self._ensure_initialized()
|
||
# Face-pick mode: a left-click selects a planar face to sketch on.
|
||
if self._pick_face_mode and event.button() == Qt.LeftButton:
|
||
self._handle_face_pick(event)
|
||
return
|
||
self._renderer.handle_mouse_press(event)
|
||
super().mousePressEvent(event)
|
||
|
||
def mouseMoveEvent(self, event):
|
||
self._ensure_initialized()
|
||
# In face-pick mode, keep dynamic highlighting but don't orbit.
|
||
if self._pick_face_mode:
|
||
if hasattr(self._renderer, "handle_mouse_move"):
|
||
# Still forward for hover-detect (no button held → detect only).
|
||
self._renderer.handle_mouse_move(event)
|
||
super().mouseMoveEvent(event)
|
||
return
|
||
self._renderer.handle_mouse_move(event)
|
||
super().mouseMoveEvent(event)
|
||
|
||
def paintEngine(self):
|
||
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
|
||
return None
|
||
|
||
def paintEvent(self, event):
|
||
"""Empty paintEvent — OCC draws directly via OpenGL."""
|
||
pass
|
||
|
||
def mouseReleaseEvent(self, event):
|
||
self._ensure_initialized()
|
||
self._renderer.handle_mouse_release(event)
|
||
super().mouseReleaseEvent(event)
|
||
|
||
def wheelEvent(self, event):
|
||
self._ensure_initialized()
|
||
self._renderer.handle_wheel(event)
|
||
super().wheelEvent(event)
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
self._ensure_initialized()
|
||
self._renderer.handle_resize(event.size().width(), event.size().height())
|
||
|
||
def set_camera_position(self, position, target):
|
||
self._ensure_initialized()
|
||
self._renderer.set_camera_position(position, target)
|
||
self._renderer.render()
|
||
|
||
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
|
||
|
||
def set_pick_face_mode(self, enabled: bool) -> None:
|
||
"""Toggle face-pick mode.
|
||
|
||
When enabled, the cursor selects planar faces for sketch placement
|
||
instead of orbiting the camera. Middle button still pans; wheel zooms.
|
||
"""
|
||
self._pick_face_mode = bool(enabled)
|
||
if enabled:
|
||
self.setCursor(Qt.CrossCursor)
|
||
else:
|
||
self.unsetCursor()
|
||
|
||
def is_pick_face_mode(self) -> bool:
|
||
return self._pick_face_mode
|
||
|
||
def highlight_face(self, face: Any) -> None:
|
||
"""Tint the picked face light-blue/transparent in the 3D viewer."""
|
||
self._ensure_initialized()
|
||
fn = getattr(self._renderer, "highlight_face", None)
|
||
if fn is not None:
|
||
fn(face)
|
||
self._renderer.render()
|
||
|
||
def clear_face_highlight(self) -> None:
|
||
"""Remove the persistent face-selection tint."""
|
||
self._ensure_initialized()
|
||
fn = getattr(self._renderer, "clear_face_highlight", None)
|
||
if fn is not None:
|
||
fn()
|
||
self._renderer.render()
|
||
|
||
def _handle_face_pick(self, event) -> None:
|
||
"""Detect a planar face under the click and emit facePicked."""
|
||
self._ensure_initialized()
|
||
picker = getattr(self._renderer, "pick_planar_face", None)
|
||
if picker is None:
|
||
logger.warning("Renderer has no pick_planar_face support")
|
||
return
|
||
# Qt6: prefer position().toPoint() over deprecated pos().
|
||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||
info = picker(pos.x(), pos.y())
|
||
if info is None:
|
||
logger.info("Face pick: no planar face under cursor")
|
||
return
|
||
# Stash the owning obj_id so MainWindow._on_face_picked can pair the
|
||
# picked face with the body it belongs to (for auto-targeted cut).
|
||
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
||
self.facePicked.emit(
|
||
tuple(info["origin"]),
|
||
tuple(info["normal"]),
|
||
tuple(info["x_dir"]),
|
||
info["face"],
|
||
)
|
||
|
||
def set_view(self, view: str):
|
||
# Prefer the renderer's native orientation snap (preserves target,
|
||
# refits the scene). Falls back to absolute eye positions for
|
||
# renderers that don't implement set_view_orientation.
|
||
self._ensure_initialized()
|
||
if hasattr(self._renderer, "set_view_orientation"):
|
||
self._renderer.set_view_orientation(view)
|
||
self._renderer.render()
|
||
return
|
||
positions = {
|
||
"iso": ((100, 100, 100), (0, 0, 0)),
|
||
"top": ((0, 0, 200), (0, 0, 0)),
|
||
"front": ((0, -200, 0), (0, 0, 0)),
|
||
"right": ((200, 0, 0), (0, 0, 0)),
|
||
"back": ((0, 200, 0), (0, 0, 0)),
|
||
"left": ((-200, 0, 0), (0, 0, 0)),
|
||
"bottom": ((0, 0, -200), (0, 0, 0)),
|
||
}
|
||
if view in positions:
|
||
pos, target = positions[view]
|
||
self.set_camera_position(pos, target)
|
||
|
||
def mouseDoubleClickEvent(self, event):
|
||
# Double-click → fit all (common CAD convention).
|
||
self._ensure_initialized()
|
||
if event.button() == Qt.LeftButton:
|
||
self.fit_camera()
|
||
super().mouseDoubleClickEvent(event)
|
||
|
||
def keyPressEvent(self, event):
|
||
# Esc cancels face-pick mode.
|
||
if self._pick_face_mode and event.key() == Qt.Key_Escape:
|
||
self.set_pick_face_mode(False)
|
||
self.pickFaceCancelled.emit()
|
||
return
|
||
# Navigation shortcuts (lowercase = view presets, F = fit,
|
||
# P/O = perspective/orthographic, R = reset).
|
||
self._ensure_initialized()
|
||
key = event.text().lower()
|
||
mapping = {
|
||
"f": "fit",
|
||
"r": "reset",
|
||
"1": "front",
|
||
"2": "back",
|
||
"3": "top",
|
||
"4": "bottom",
|
||
"5": "left",
|
||
"6": "right",
|
||
"7": "iso",
|
||
}
|
||
action = mapping.get(key)
|
||
if action == "fit":
|
||
self.fit_camera()
|
||
return
|
||
if action == "reset":
|
||
if hasattr(self._renderer, "reset_camera"):
|
||
self._renderer.reset_camera()
|
||
self._renderer.render()
|
||
else:
|
||
self.set_view("iso")
|
||
return
|
||
if action in ("front", "back", "top", "bottom", "left", "right", "iso"):
|
||
self.set_view(action)
|
||
return
|
||
if key == "p" and hasattr(self._renderer, "set_camera_perspective"):
|
||
self._renderer.set_camera_perspective()
|
||
self._renderer.render()
|
||
return
|
||
if key == "o" and hasattr(self._renderer, "set_camera_orthographic"):
|
||
self._renderer.set_camera_orthographic()
|
||
self._renderer.render()
|
||
return
|
||
super().keyPressEvent(event)
|
||
|
||
|
||
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
|
||
|
||
self._setup_ui()
|
||
self._setup_connections()
|
||
self._create_initial_component()
|
||
logger.info("MainWindow initialization complete")
|
||
|
||
def _setup_ui(self):
|
||
self.setWindowTitle("Fluency CAD 2.0")
|
||
self.setMinimumSize(1400, 900)
|
||
|
||
self._create_menus()
|
||
self._create_central_widget()
|
||
self._create_dock_widgets()
|
||
|
||
logger.info("Ready")
|
||
|
||
def _create_menus(self):
|
||
menubar = self.menuBar()
|
||
|
||
file_menu = menubar.addMenu("&File")
|
||
|
||
new_action = QAction("&New Project", self)
|
||
new_action.setShortcut(QKeySequence.New)
|
||
new_action.triggered.connect(self._new_project)
|
||
file_menu.addAction(new_action)
|
||
|
||
open_action = QAction("&Open STEP/IGES...", self)
|
||
open_action.setShortcut(QKeySequence.Open)
|
||
open_action.triggered.connect(self._import_file)
|
||
file_menu.addAction(open_action)
|
||
|
||
file_menu.addSeparator()
|
||
|
||
export_step = QAction("Export &STEP...", self)
|
||
export_step.triggered.connect(self._export_step)
|
||
file_menu.addAction(export_step)
|
||
|
||
export_iges = QAction("Export &IGES...", self)
|
||
export_iges.triggered.connect(self._export_iges)
|
||
file_menu.addAction(export_iges)
|
||
|
||
export_stl = QAction("Export S&TL...", self)
|
||
export_stl.triggered.connect(self._export_stl)
|
||
file_menu.addAction(export_stl)
|
||
|
||
file_menu.addSeparator()
|
||
|
||
exit_action = QAction("E&xit", self)
|
||
exit_action.setShortcut(QKeySequence.Quit)
|
||
exit_action.triggered.connect(self.close)
|
||
file_menu.addAction(exit_action)
|
||
|
||
view_menu = menubar.addMenu("&View")
|
||
view_menu.addAction("Fit All", self._fit_view)
|
||
view_menu.addAction("Reset View", self._reset_view)
|
||
view_menu.addSeparator()
|
||
|
||
for view_name in ["Isometric", "Top", "Front", "Right", "Back", "Left", "Bottom"]:
|
||
action = QAction(view_name, self)
|
||
action.triggered.connect(
|
||
lambda checked, v=view_name.lower(): self._viewer_3d.set_view(v)
|
||
)
|
||
view_menu.addAction(action)
|
||
|
||
help_menu = menubar.addMenu("&Help")
|
||
help_menu.addAction("About", self._show_about)
|
||
|
||
def _create_central_widget(self):
|
||
"""
|
||
Recreates the classic grid-based layout from the original fluencyCAD.
|
||
|
||
Grid layout:
|
||
Col 0 (≤200px) Col 1 (expand) Col 2 (expand) Col 3 (≤200px)
|
||
r0: Workplanes InputTab Model Viewer Modify
|
||
r1: Drawing
|
||
r2: Constrain
|
||
r3: Snaps (tab) r6: Export
|
||
r4: (Snaps cont) r7-8: Bodies
|
||
r5: (Snaps cont)
|
||
r6-8: Sketch List
|
||
r9: Comp Tools Comp Buttons (span 2) Assembly Tools
|
||
"""
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
|
||
grid = QGridLayout(central)
|
||
grid.setContentsMargins(5, 5, 5, 5)
|
||
grid.setSpacing(4)
|
||
grid.setColumnStretch(0, 0) # left column fixed
|
||
# Sketch (col 1) and 3D viewer (col 2) share the remaining width.
|
||
# The split is hover-driven: entering the sketch enlarges it, entering
|
||
# the 3D viewer grows it to 2/3 (sketch shrinks to 1/3). Resting = 1:1.
|
||
grid.setColumnStretch(1, 1)
|
||
grid.setColumnStretch(2, 1)
|
||
grid.setColumnStretch(3, 0) # right column fixed
|
||
self._grid = grid
|
||
|
||
# ---- Row 0, Col 0: Workplanes ----
|
||
wp_group = QGroupBox("Workplanes")
|
||
wp_group.setMaximumWidth(200)
|
||
wp_layout = QGridLayout(wp_group)
|
||
self._btn_wp_origin = QPushButton("WP Origin")
|
||
self._btn_wp_origin.setToolTip("Working Plane at 0, 0, 0")
|
||
self._btn_wp_origin.setShortcut("W")
|
||
wp_layout.addWidget(self._btn_wp_origin, 0, 0)
|
||
self._btn_wp_face = QPushButton("WP Face")
|
||
self._btn_wp_face.setToolTip("Working Plane from selected face")
|
||
self._btn_wp_face.setShortcut("P")
|
||
self._btn_wp_face.setCheckable(True)
|
||
wp_layout.addWidget(self._btn_wp_face, 0, 1)
|
||
self._btn_wp_flip = QPushButton("WP Flip")
|
||
self._btn_wp_flip.setToolTip("Flip normal direction")
|
||
self._btn_wp_flip.setShortcut("N")
|
||
wp_layout.addWidget(self._btn_wp_flip, 1, 0)
|
||
self._btn_wp_move = QPushButton("WP Mve")
|
||
self._btn_wp_move.setToolTip("Move workplane")
|
||
self._btn_wp_move.setShortcut("M")
|
||
wp_layout.addWidget(self._btn_wp_move, 1, 1)
|
||
# Underlay: show / hide the construction lines projected from the
|
||
# source face. Off by default is meaningless (no face picked yet)
|
||
# so it auto-unchecks when the user clears the source face.
|
||
self._btn_underlay = QPushButton("Underlay")
|
||
self._btn_underlay.setToolTip(
|
||
"Show / hide the construction lines projected from the source face"
|
||
)
|
||
self._btn_underlay.setCheckable(True)
|
||
self._btn_underlay.setChecked(True)
|
||
self._btn_underlay.setEnabled(False) # becomes enabled after WP Face
|
||
wp_layout.addWidget(self._btn_underlay, 2, 0)
|
||
# ClrFace: forget the picked source face. Removes the underlay
|
||
# construction-line entities and leaves the workplane + sketch
|
||
# intact, so the user can keep drawing on a free-standing sketch.
|
||
self._btn_clr_face = QPushButton("ClrFace")
|
||
self._btn_clr_face.setToolTip("Forget the picked source face (keep the workplane)")
|
||
self._btn_clr_face.setEnabled(False) # becomes enabled after WP Face
|
||
wp_layout.addWidget(self._btn_clr_face, 2, 1)
|
||
grid.addWidget(wp_group, 0, 0)
|
||
|
||
# ---- Row 1, Col 0: Drawing ----
|
||
draw_group = QGroupBox("Drawing")
|
||
draw_group.setMaximumWidth(200)
|
||
draw_layout = QGridLayout(draw_group)
|
||
self._btn_line = QPushButton("Line")
|
||
self._btn_line.setCheckable(True)
|
||
self._btn_line.setShortcut("S")
|
||
draw_layout.addWidget(self._btn_line, 0, 0)
|
||
self._btn_rect = QPushButton("Rctgl")
|
||
self._btn_rect.setCheckable(True)
|
||
draw_layout.addWidget(self._btn_rect, 0, 1)
|
||
self._btn_circle = QPushButton("Circle")
|
||
self._btn_circle.setCheckable(True)
|
||
draw_layout.addWidget(self._btn_circle, 1, 0)
|
||
self._btn_slot = QPushButton("Slot")
|
||
self._btn_slot.setCheckable(True)
|
||
draw_layout.addWidget(self._btn_slot, 1, 1)
|
||
self._btn_move_sketch = QPushButton("Move")
|
||
self._btn_move_sketch.setCheckable(True)
|
||
self._btn_move_sketch.setToolTip("Move a drawn element (drag a point/line/rectangle/circle).")
|
||
self._btn_move_sketch.setShortcut("Q")
|
||
draw_layout.addWidget(self._btn_move_sketch, 1, 2, 1, 1)
|
||
# separator
|
||
sep = QFrame()
|
||
sep.setFrameShape(QFrame.HLine)
|
||
sep.setFrameShadow(QFrame.Sunken)
|
||
draw_layout.addWidget(sep, 2, 0, 1, 3)
|
||
self._btn_construct = QPushButton("Cstrct")
|
||
self._btn_construct.setCheckable(True)
|
||
draw_layout.addWidget(self._btn_construct, 3, 0)
|
||
self._btn_snap = QPushButton("Snap")
|
||
self._btn_snap.setCheckable(True)
|
||
self._btn_snap.setChecked(True)
|
||
draw_layout.addWidget(self._btn_snap, 3, 1)
|
||
grid.addWidget(draw_group, 1, 0)
|
||
|
||
# ---- Row 2, Col 0: Constrain ----
|
||
con_group = QGroupBox("Constrain")
|
||
con_group.setMaximumWidth(200)
|
||
con_layout = QGridLayout(con_group)
|
||
self._btn_con_ptpt = QPushButton("Pt_Pt")
|
||
self._btn_con_ptpt.setCheckable(True)
|
||
self._btn_con_ptpt.setToolTip("Point to Point Coincident")
|
||
con_layout.addWidget(self._btn_con_ptpt, 0, 0)
|
||
self._btn_con_ptline = QPushButton("Pt_Lne")
|
||
self._btn_con_ptline.setCheckable(True)
|
||
self._btn_con_ptline.setToolTip("Point to Line Distance")
|
||
con_layout.addWidget(self._btn_con_ptline, 0, 1)
|
||
self._btn_con_mid = QPushButton("Pt_Mid_L")
|
||
self._btn_con_mid.setCheckable(True)
|
||
self._btn_con_mid.setToolTip("Point to Midpoint")
|
||
con_layout.addWidget(self._btn_con_mid, 1, 0)
|
||
self._btn_con_perp = QPushButton("Perp_Lne")
|
||
self._btn_con_perp.setCheckable(True)
|
||
self._btn_con_perp.setToolTip("Perpendicular Constraint")
|
||
con_layout.addWidget(self._btn_con_perp, 1, 1)
|
||
self._btn_con_horiz = QPushButton("Horiz")
|
||
self._btn_con_horiz.setCheckable(True)
|
||
self._btn_con_horiz.setToolTip("Horizontal Constraint")
|
||
con_layout.addWidget(self._btn_con_horiz, 2, 0)
|
||
self._btn_con_vert = QPushButton("Vert")
|
||
self._btn_con_vert.setCheckable(True)
|
||
self._btn_con_vert.setToolTip("Vertical Constraint")
|
||
con_layout.addWidget(self._btn_con_vert, 2, 1)
|
||
self._btn_con_dist = QPushButton("Distnce")
|
||
self._btn_con_dist.setCheckable(True)
|
||
self._btn_con_dist.setToolTip("Distance Constraint")
|
||
con_layout.addWidget(self._btn_con_dist, 3, 0)
|
||
self._btn_con_sym = QPushButton("Symetrc")
|
||
self._btn_con_sym.setCheckable(True)
|
||
con_layout.addWidget(self._btn_con_sym, 3, 1)
|
||
grid.addWidget(con_group, 2, 0)
|
||
|
||
# ---- Row 3-5, Col 0: Snaps tab ----
|
||
self._snaps_tab = QTabWidget()
|
||
self._snaps_tab.setMaximumWidth(200)
|
||
self._snaps_tab.setTabPosition(QTabWidget.TabPosition.South)
|
||
snaps_tab1 = QWidget()
|
||
snaps_layout = QVBoxLayout(snaps_tab1)
|
||
snap_group = QGroupBox("Snapping Points")
|
||
snap_grid = QGridLayout(snap_group)
|
||
snap_grid.setContentsMargins(2, 2, 2, 2)
|
||
self._btn_snap_point = QPushButton("Pnt")
|
||
self._btn_snap_point.setCheckable(True)
|
||
self._btn_snap_point.setChecked(True)
|
||
snap_grid.addWidget(self._btn_snap_point, 0, 0)
|
||
self._btn_snap_mid = QPushButton("MidP")
|
||
self._btn_snap_mid.setCheckable(True)
|
||
snap_grid.addWidget(self._btn_snap_mid, 0, 1)
|
||
self._btn_snap_horiz = QPushButton("Horiz")
|
||
self._btn_snap_horiz.setCheckable(True)
|
||
snap_grid.addWidget(self._btn_snap_horiz, 1, 0)
|
||
self._btn_snap_vert = QPushButton("Vert")
|
||
self._btn_snap_vert.setCheckable(True)
|
||
snap_grid.addWidget(self._btn_snap_vert, 1, 1)
|
||
self._btn_snap_angle = QPushButton("Angles")
|
||
self._btn_snap_angle.setCheckable(True)
|
||
snap_grid.addWidget(self._btn_snap_angle, 2, 0)
|
||
self._btn_snap_grid = QPushButton("Grid")
|
||
self._btn_snap_grid.setCheckable(True)
|
||
snap_grid.addWidget(self._btn_snap_grid, 2, 1)
|
||
# separator
|
||
sep2 = QFrame()
|
||
sep2.setFrameShape(QFrame.HLine)
|
||
sep2.setFrameShadow(QFrame.Sunken)
|
||
snap_grid.addWidget(sep2, 3, 0, 1, 2)
|
||
snap_grid.addWidget(QLabel("Snp Dst"), 4, 0)
|
||
snap_grid.addWidget(QLabel("Angl Stps"), 4, 1)
|
||
self._spin_snap_dist = QSpinBox()
|
||
self._spin_snap_dist.setRange(1, 30)
|
||
self._spin_snap_dist.setValue(10)
|
||
self._spin_snap_dist.setSuffix("px")
|
||
snap_grid.addWidget(self._spin_snap_dist, 5, 0)
|
||
self._spin_angle = QSpinBox()
|
||
self._spin_angle.setRange(1, 180)
|
||
self._spin_angle.setValue(15)
|
||
self._spin_angle.setSuffix("°")
|
||
snap_grid.addWidget(self._spin_angle, 5, 1)
|
||
snaps_layout.addWidget(snap_group)
|
||
self._snaps_tab.addTab(snaps_tab1, "Setg 1")
|
||
self._snaps_tab.addTab(QWidget(), "Setg 2")
|
||
grid.addWidget(self._snaps_tab, 3, 0)
|
||
# snaps tab spans rows 3-5
|
||
grid.setRowStretch(3, 0)
|
||
grid.setRowStretch(4, 1)
|
||
grid.setRowStretch(5, 1)
|
||
# Build row 6-8 spacer for left column
|
||
# Reserve space so the sketch list reaches the bottom
|
||
grid.setRowMinimumHeight(6, 0)
|
||
grid.setRowMinimumHeight(7, 0)
|
||
grid.setRowMinimumHeight(8, 0)
|
||
|
||
# ---- Row 6-8, Col 0: Sketch List ----
|
||
sk_list_group = QGroupBox("Sketch")
|
||
sk_list_group.setMaximumWidth(200)
|
||
sk_list_layout = QVBoxLayout(sk_list_group)
|
||
self._sketch_list = QListWidget()
|
||
self._sketch_list.setSelectionRectVisible(True)
|
||
sk_list_layout.addWidget(self._sketch_list)
|
||
sk_tools = QGroupBox("Tools")
|
||
sk_tools_grid = QGridLayout(sk_tools)
|
||
sk_tools_grid.setContentsMargins(2, 2, 2, 2)
|
||
self._btn_add_sketch = QPushButton("Add")
|
||
sk_tools_grid.addWidget(self._btn_add_sketch, 0, 0)
|
||
self._btn_edit_sketch = QPushButton("Edt")
|
||
sk_tools_grid.addWidget(self._btn_edit_sketch, 0, 1)
|
||
self._btn_del_sketch = QPushButton("Del")
|
||
sk_tools_grid.addWidget(self._btn_del_sketch, 0, 2)
|
||
sk_list_layout.addWidget(sk_tools)
|
||
grid.addWidget(sk_list_group, 6, 0, 3, 1)
|
||
|
||
# ---- Row 0-8, Col 1: Input Tabs ----
|
||
self._input_tabs = QTabWidget()
|
||
sketch_tab = QWidget()
|
||
sketch_tab_layout = QVBoxLayout(sketch_tab)
|
||
sketch_tab_layout.setContentsMargins(0, 0, 0, 0)
|
||
self._sketch_widget = Sketch2DWidget()
|
||
sketch_tab_layout.addWidget(self._sketch_widget)
|
||
self._input_tabs.addTab(sketch_tab, "Sketch")
|
||
code_tab = QWidget()
|
||
code_tab_layout = QVBoxLayout(code_tab)
|
||
self._code_edit = QTextEdit()
|
||
self._code_edit.setFont(QFont("Monaco", 10))
|
||
self._code_edit.setPlaceholderText("# Enter Python code here...")
|
||
code_tab_layout.addWidget(self._code_edit)
|
||
code_tools = QHBoxLayout()
|
||
self._btn_apply_code = QPushButton("Apply Code")
|
||
self._btn_load_code = QPushButton("Load Code")
|
||
self._btn_save_code = QPushButton("Save Code")
|
||
self._btn_del_code = QPushButton("Delete Code")
|
||
code_tools.addWidget(self._btn_apply_code)
|
||
code_tools.addWidget(self._btn_load_code)
|
||
code_tools.addWidget(self._btn_save_code)
|
||
code_tools.addWidget(self._btn_del_code)
|
||
code_tab_layout.addLayout(code_tools)
|
||
self._input_tabs.addTab(code_tab, "Code")
|
||
grid.addWidget(self._input_tabs, 0, 1, 9, 1)
|
||
|
||
# ---- Row 0-8, Col 2: Model Viewer ----
|
||
viewer_group = QGroupBox("Model Viewer")
|
||
viewer_layout = QVBoxLayout(viewer_group)
|
||
viewer_layout.setContentsMargins(5, 5, 5, 5)
|
||
self._viewer_3d = Viewer3DWidget()
|
||
viewer_layout.addWidget(self._viewer_3d)
|
||
grid.addWidget(viewer_group, 0, 2, 9, 1)
|
||
|
||
# Focus split is toggled by Spacebar / the Layout button
|
||
# (see _toggle_panel_focus).
|
||
self._panel_focus: str = "equal" # "equal" | "sketch" | "viewer"
|
||
|
||
# ---- Row 0, Col 3: Modify ----
|
||
modify_group = QGroupBox("Modify")
|
||
modify_group.setMaximumWidth(200)
|
||
modify_layout = QGridLayout(modify_group)
|
||
self._btn_extrude = QPushButton("Extrd")
|
||
self._btn_extrude.setToolTip("Extrude sketch")
|
||
modify_layout.addWidget(self._btn_extrude, 0, 0)
|
||
self._btn_cut = QPushButton("Cut")
|
||
self._btn_cut.setToolTip("Boolean cut")
|
||
modify_layout.addWidget(self._btn_cut, 0, 1)
|
||
self._btn_combine = QPushButton("Comb")
|
||
self._btn_combine.setToolTip("Boolean union")
|
||
modify_layout.addWidget(self._btn_combine, 1, 0)
|
||
self._btn_move = QPushButton("Mve")
|
||
self._btn_move.setToolTip("Move body")
|
||
modify_layout.addWidget(self._btn_move, 1, 1)
|
||
self._btn_revolve = QPushButton("Rev")
|
||
self._btn_revolve.setToolTip("Revolve sketch")
|
||
modify_layout.addWidget(self._btn_revolve, 2, 0)
|
||
self._btn_array = QPushButton("Arry")
|
||
self._btn_array.setToolTip("Pattern array")
|
||
modify_layout.addWidget(self._btn_array, 2, 1)
|
||
grid.addWidget(modify_group, 0, 3, 1, 1, Qt.AlignTop)
|
||
|
||
# ---- Row 6, Col 3: Export ----
|
||
export_group = QGroupBox("Export")
|
||
export_group.setMaximumWidth(200)
|
||
export_layout = QVBoxLayout(export_group)
|
||
self._btn_export_stl = QPushButton("STL")
|
||
export_layout.addWidget(self._btn_export_stl)
|
||
self._btn_export_step = QPushButton("STEP")
|
||
export_layout.addWidget(self._btn_export_step)
|
||
self._btn_export_iges = QPushButton("IGES")
|
||
export_layout.addWidget(self._btn_export_iges)
|
||
grid.addWidget(export_group, 6, 3)
|
||
|
||
# ---- Row 7-8, Col 3: Bodies / Operations ----
|
||
body_group = QGroupBox("Bodys / Operations")
|
||
body_group.setMaximumWidth(200)
|
||
body_layout = QVBoxLayout(body_group)
|
||
self._body_list = QListWidget()
|
||
self._body_list.setSelectionRectVisible(True)
|
||
body_layout.addWidget(self._body_list)
|
||
body_tools_grid = QGridLayout()
|
||
body_tools_grid.setContentsMargins(2, 2, 2, 2)
|
||
self._btn_update_body = QPushButton("Upd")
|
||
body_tools_grid.addWidget(self._btn_update_body, 0, 0)
|
||
self._btn_edit_sketch_3 = QPushButton("Nothing")
|
||
body_tools_grid.addWidget(self._btn_edit_sketch_3, 0, 1)
|
||
self._btn_del_body = QPushButton("Del")
|
||
body_tools_grid.addWidget(self._btn_del_body, 0, 2)
|
||
body_layout.addLayout(body_tools_grid)
|
||
grid.addWidget(body_group, 7, 3, 2, 1)
|
||
|
||
# ---- Row 9, Col 0: Component Tools ----
|
||
comp_tool_group = QGroupBox("Component Tools")
|
||
comp_tool_group.setMinimumHeight(50)
|
||
comp_tool_layout = QHBoxLayout(comp_tool_group)
|
||
self._btn_new_compo = QPushButton("New")
|
||
self._btn_new_compo.setFixedSize(QSize(50, 50))
|
||
comp_tool_layout.addWidget(self._btn_new_compo)
|
||
self._btn_del_compo = QPushButton("Del")
|
||
self._btn_del_compo.setFixedSize(QSize(50, 50))
|
||
comp_tool_layout.addWidget(self._btn_del_compo)
|
||
grid.addWidget(comp_tool_group, 9, 0)
|
||
|
||
# ---- Row 9, Col 1-2: Components (button box) ----
|
||
compo_group = QGroupBox("Components")
|
||
compo_group.setMinimumHeight(50)
|
||
compo_layout = QHBoxLayout(compo_group)
|
||
self._component_box = QWidget()
|
||
self._component_box_layout = QHBoxLayout(self._component_box)
|
||
self._component_box_layout.setAlignment(Qt.AlignLeft)
|
||
self._component_group = QButtonGroup(self)
|
||
self._component_group.setExclusive(True)
|
||
compo_layout.addWidget(self._component_box)
|
||
compo_layout.addStretch()
|
||
grid.addWidget(compo_group, 9, 1, 1, 2)
|
||
|
||
# ---- Row 9, Col 3: Assembly Tools ----
|
||
assy_group = QGroupBox("Assembly Tools")
|
||
assy_group.setMinimumHeight(50)
|
||
assy_layout = QHBoxLayout(assy_group)
|
||
self._btn_add_connector = QPushButton("+ Cnct")
|
||
self._btn_add_connector.setFixedSize(QSize(50, 50))
|
||
assy_layout.addWidget(self._btn_add_connector)
|
||
self._btn_del_connector = QPushButton("- Cnct")
|
||
self._btn_del_connector.setFixedSize(QSize(50, 50))
|
||
assy_layout.addWidget(self._btn_del_connector)
|
||
grid.addWidget(assy_group, 9, 3)
|
||
|
||
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_slot.clicked.connect(lambda: self._set_sketch_mode("slot"))
|
||
self._btn_move_sketch.clicked.connect(lambda: self._set_sketch_mode("select"))
|
||
self._btn_construct.clicked.connect(self._on_construct_change)
|
||
|
||
self._btn_con_ptpt.clicked.connect(lambda: self._set_sketch_mode("constrain_coincident"))
|
||
self._btn_con_ptline.clicked.connect(lambda: self._set_sketch_mode("constrain_ptline"))
|
||
self._btn_con_horiz.clicked.connect(lambda: self._set_sketch_mode("constrain_horizontal"))
|
||
self._btn_con_vert.clicked.connect(lambda: self._set_sketch_mode("constrain_vertical"))
|
||
self._btn_con_mid.clicked.connect(lambda: self._set_sketch_mode("constrain_midpoint"))
|
||
self._btn_con_perp.clicked.connect(lambda: self._set_sketch_mode("constrain_perpendicular"))
|
||
self._btn_con_dist.clicked.connect(lambda: self._set_sketch_mode("constrain_distance"))
|
||
self._btn_con_sym.clicked.connect(lambda: self._set_sketch_mode("constrain_symmetric"))
|
||
|
||
self._btn_snap_point.clicked.connect(
|
||
lambda c: self._sketch_widget.set_snap_mode("point", c)
|
||
)
|
||
self._btn_snap_mid.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("mpoint", c))
|
||
self._btn_snap_horiz.clicked.connect(
|
||
lambda c: self._sketch_widget.set_snap_mode("horiz", c)
|
||
)
|
||
self._btn_snap_vert.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("vert", c))
|
||
self._btn_snap_angle.clicked.connect(
|
||
lambda c: self._sketch_widget.set_snap_mode("angle", c)
|
||
)
|
||
self._btn_snap_grid.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("grid", c))
|
||
|
||
self._spin_snap_dist.valueChanged.connect(self._sketch_widget.set_snap_distance)
|
||
self._spin_angle.valueChanged.connect(self._sketch_widget.set_angle_steps)
|
||
|
||
self._btn_extrude.clicked.connect(self._extrude_sketch)
|
||
self._btn_cut.clicked.connect(self._boolean_cut)
|
||
self._btn_combine.clicked.connect(self._boolean_union)
|
||
self._btn_revolve.clicked.connect(self._revolve_sketch)
|
||
|
||
self._btn_add_sketch.clicked.connect(self._add_sketch_to_component)
|
||
self._btn_edit_sketch.clicked.connect(self._edit_sketch)
|
||
self._btn_del_sketch.clicked.connect(self._delete_sketch)
|
||
self._btn_wp_face.toggled.connect(self._on_face_sketch_toggled)
|
||
self._viewer_3d.facePicked.connect(self._on_face_picked)
|
||
self._viewer_3d.pickFaceCancelled.connect(
|
||
lambda: self._btn_wp_face.setChecked(False)
|
||
)
|
||
|
||
self._btn_new_compo.clicked.connect(self._new_component)
|
||
self._btn_del_compo.clicked.connect(self._delete_component)
|
||
|
||
self._btn_update_body.clicked.connect(self._redraw_bodies)
|
||
self._btn_del_body.clicked.connect(self._delete_body)
|
||
|
||
self._btn_export_stl.clicked.connect(self._export_stl)
|
||
self._btn_export_step.clicked.connect(self._export_step)
|
||
self._btn_export_iges.clicked.connect(self._export_iges)
|
||
|
||
self._sketch_widget.constrain_done.connect(self._on_constrain_done)
|
||
self._sketch_widget.sketch_updated.connect(self._on_sketch_updated)
|
||
|
||
self._sketch_list.currentItemChanged.connect(self._on_sketch_selected)
|
||
self._body_list.currentItemChanged.connect(self._on_body_list_changed)
|
||
# Per-body visibility toggle: the user clicks the checkbox next
|
||
# to a body name in the right-hand list. We update the body's
|
||
# ``visible`` flag and ask the viewer to show/hide the mesh.
|
||
# (itemChanged also fires for selection changes; the handler
|
||
# filters on the check-state role.)
|
||
self._body_list.itemChanged.connect(self._on_body_visibility_changed)
|
||
|
||
self._btn_wp_origin.clicked.connect(self._new_sketch_origin)
|
||
self._btn_wp_flip.clicked.connect(self._flip_workplane)
|
||
self._btn_wp_move.clicked.connect(self._move_workplane)
|
||
# Underlay show/hide and ClrFace — both stay in sync with the
|
||
# source face state managed by set_source_face / clear_source_face.
|
||
self._btn_underlay.toggled.connect(self._on_underlay_toggled)
|
||
self._btn_clr_face.clicked.connect(self._on_clear_source_face)
|
||
|
||
# Generic buttons
|
||
self._btn_move.clicked.connect(self._translate_body)
|
||
self._btn_array.clicked.connect(self._pattern_array)
|
||
self._btn_edit_sketch_3.clicked.connect(self._edit_sketch)
|
||
|
||
# Snap toggle
|
||
self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c))
|
||
|
||
def _create_initial_component(self):
|
||
self._new_component()
|
||
|
||
def _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_slot,
|
||
self._btn_move_sketch,
|
||
self._btn_con_ptpt,
|
||
self._btn_con_ptline,
|
||
self._btn_con_horiz,
|
||
self._btn_con_vert,
|
||
self._btn_con_mid,
|
||
self._btn_con_perp,
|
||
self._btn_con_dist,
|
||
self._btn_con_sym,
|
||
]:
|
||
btn.setChecked(False)
|
||
|
||
if mode in ["line", "rectangle", "circle", "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 == "slot":
|
||
self._btn_slot.setChecked(True)
|
||
elif mode == "select":
|
||
self._btn_move_sketch.setChecked(True)
|
||
elif mode.startswith("constrain_"):
|
||
if mode == "constrain_coincident":
|
||
self._btn_con_ptpt.setChecked(True)
|
||
elif mode == "constrain_horizontal":
|
||
self._btn_con_horiz.setChecked(True)
|
||
elif mode == "constrain_vertical":
|
||
self._btn_con_vert.setChecked(True)
|
||
|
||
def _on_construct_change(self, checked):
|
||
self._sketch_widget.set_construct_mode(checked)
|
||
|
||
def _on_constrain_done(self):
|
||
for btn in [
|
||
self._btn_line,
|
||
self._btn_rect,
|
||
self._btn_circle,
|
||
self._btn_slot,
|
||
self._btn_move_sketch,
|
||
self._btn_con_ptpt,
|
||
self._btn_con_ptline,
|
||
self._btn_con_horiz,
|
||
self._btn_con_vert,
|
||
self._btn_con_mid,
|
||
self._btn_con_perp,
|
||
self._btn_con_dist,
|
||
self._btn_con_sym,
|
||
]:
|
||
btn.setChecked(False)
|
||
self._sketch_widget.set_mode(None)
|
||
|
||
def _on_sketch_updated(self):
|
||
pass
|
||
|
||
def _get_active_component_index(self) -> int:
|
||
for i, btn in enumerate(self._component_buttons):
|
||
if btn.isChecked():
|
||
return i
|
||
return 0
|
||
|
||
def _new_component(self):
|
||
logger.info("=== NEW COMPONENT ===")
|
||
comp = self._project.add_component()
|
||
self._current_component = comp
|
||
logger.info(f"Created component: {comp.name}")
|
||
|
||
btn = QPushButton(str(len(self._project.components)))
|
||
btn.setCheckable(True)
|
||
btn.setFixedSize(QSize(40, 40))
|
||
btn.clicked.connect(self._on_component_button_clicked)
|
||
btn.setChecked(True)
|
||
|
||
for b in self._component_buttons:
|
||
b.setChecked(False)
|
||
|
||
self._component_buttons.append(btn)
|
||
self._component_group.addButton(btn)
|
||
self._component_box_layout.addWidget(btn)
|
||
|
||
self._refresh_lists()
|
||
logger.info(f"Created component: {comp.name}")
|
||
|
||
def _delete_component(self):
|
||
idx = self._get_active_component_index()
|
||
comp_ids = list(self._project.components.keys())
|
||
if idx < len(comp_ids):
|
||
comp_id = comp_ids[idx]
|
||
del self._project.components[comp_id]
|
||
|
||
if self._component_buttons:
|
||
btn = self._component_buttons.pop(idx)
|
||
self._component_group.removeButton(btn)
|
||
btn.deleteLater()
|
||
|
||
if self._component_buttons:
|
||
self._component_buttons[0].setChecked(True)
|
||
|
||
self._refresh_lists()
|
||
logger.info(f"Deleted component")
|
||
|
||
def _on_component_button_clicked(self):
|
||
idx = self._get_active_component_index()
|
||
comp_ids = list(self._project.components.keys())
|
||
if idx < len(comp_ids):
|
||
self._current_component = self._project.components[comp_ids[idx]]
|
||
self._refresh_lists()
|
||
self._redraw_bodies()
|
||
|
||
def _refresh_lists(self):
|
||
self._sketch_list.clear()
|
||
self._body_list.clear()
|
||
|
||
if self._current_component:
|
||
for sketch_id, sketch in self._current_component.sketches.items():
|
||
self._sketch_list.addItem(sketch.name)
|
||
|
||
for body_id, body in self._current_component.bodies.items():
|
||
# QListWidgetItem with a checkbox so the user can toggle
|
||
# each body's visibility in the 3D viewer. The item's
|
||
# data role stores the body id so the toggle handler can
|
||
# look up the right body without relying on display text.
|
||
item = QListWidgetItem(body.name)
|
||
item.setData(Qt.UserRole, body_id)
|
||
# Qt.Checked = visible, Qt.Unchecked = hidden. Default
|
||
# is whatever the body model says.
|
||
item.setCheckState(
|
||
Qt.Checked if body.visible else Qt.Unchecked
|
||
)
|
||
# Greying out a hidden body's name is a nice UX touch.
|
||
if not body.visible:
|
||
item.setForeground(QColor("#6c7086"))
|
||
self._body_list.addItem(item)
|
||
|
||
def _redraw_bodies(self):
|
||
self._viewer_3d.clear_scene()
|
||
|
||
if self._current_component:
|
||
for body_id, body in self._current_component.bodies.items():
|
||
if body.geometry:
|
||
logger.debug(f"Redrawing body: {body.name}")
|
||
shape = self._kernel._get_shape(body.geometry)
|
||
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||
logger.info(f"Redraw render object: {body.render_object}")
|
||
|
||
self._viewer_3d.fit_camera()
|
||
|
||
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")
|
||
|
||
# ─── 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()
|
||
sketch.name = f"Sketch on face {len(self._current_component.sketches)}"
|
||
# Place the sketch on the picked plane (sets fields + syncs occ_sketch).
|
||
sketch.set_workplane(origin, normal, x_dir)
|
||
# Keep the face reference for the projection underlay (Phase 3).
|
||
sketch._source_face = face_shape
|
||
# Remember which body the sketch lives on so a later cut / combine
|
||
# extrude auto-targets it. ``source_body`` may be None if the
|
||
# pick landed on an untracked shape (e.g. an imported STEP that
|
||
# wasn't registered as a component body — robust fallback then).
|
||
sketch._source_body_id = source_body.id if source_body else None
|
||
|
||
# Hand the sketch to the 2D widget and focus the sketch panel.
|
||
# Always build a clean OCC sketch carrying the face workplane so the
|
||
# widget draws on the picked plane.
|
||
if sketch.occ_sketch is None or sketch.occ_sketch.get_entity_count() > 0:
|
||
sketch.occ_sketch = self._sketch_widget.create_sketch()
|
||
sketch.apply_workplane()
|
||
self._sketch_widget.set_sketch(sketch.occ_sketch)
|
||
self._sketch_widget.set_source_face(face_shape, origin, normal, x_dir)
|
||
self._current_sketch = sketch
|
||
self._sketch_widget.set_mode("line")
|
||
self._btn_line.setChecked(True)
|
||
|
||
self._refresh_lists()
|
||
# Auto-select the freshly created sketch in the left-hand list so a
|
||
# 3D op (Extrude/Cut) operates on it without the user hunting for
|
||
# the row. _on_sketch_selected loads it into the widget for editing.
|
||
for row in range(self._sketch_list.count()):
|
||
item = self._sketch_list.item(row)
|
||
if item is not None and item.text() == sketch.name:
|
||
self._sketch_list.setCurrentRow(row)
|
||
break
|
||
# Switch focus to the sketch panel so the user can draw immediately.
|
||
self._set_panel_focus("sketch")
|
||
self.statusBar().showMessage(
|
||
f"Sketch placed on face — drawing in 2D on that plane", 6000
|
||
)
|
||
# The face is now the source for the underlay construction lines:
|
||
# enable the show/hide toggle and the ClrFace button.
|
||
self._btn_underlay.setEnabled(True)
|
||
self._btn_underlay.setChecked(True)
|
||
self._btn_clr_face.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)
|
||
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 _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()
|
||
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)
|
||
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._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._refresh_lists()
|
||
logger.info(f"Deleted sketch: {name}")
|
||
|
||
def _on_sketch_list_changed(self, current, previous):
|
||
if current and self._current_component:
|
||
name = current.text()
|
||
for sketch_id, sketch in self._current_component.sketches.items():
|
||
if sketch.name == name:
|
||
self._current_sketch = sketch
|
||
break
|
||
|
||
def _on_body_list_changed(self, current, previous):
|
||
if current and self._current_component:
|
||
name = current.text()
|
||
for body_id, body in self._current_component.bodies.items():
|
||
if body.name == name:
|
||
self._selected_body = body
|
||
logger.info(f"Selected: {name}")
|
||
break
|
||
|
||
def _on_body_visibility_changed(self, item: QListWidgetItem) -> None:
|
||
"""Toggle a body's 3D visibility when the user flips its checkbox.
|
||
|
||
itemChanged also fires for selection (not just check-state) changes,
|
||
so we filter on the check state being the changed role. The body
|
||
is looked up via the UserRole data we set in _refresh_lists.
|
||
"""
|
||
if self._current_component is None:
|
||
return
|
||
body_id = item.data(Qt.UserRole)
|
||
if body_id is None:
|
||
return
|
||
body = self._current_component.bodies.get(body_id)
|
||
if body is None:
|
||
return
|
||
new_visible = item.checkState() == Qt.Checked
|
||
if body.visible == new_visible:
|
||
return # no change
|
||
body.visible = new_visible
|
||
# Greying out hidden bodies gives a quick visual hint in the list.
|
||
item.setForeground(QColor("#1e1e2e") if new_visible else QColor("#6c7086"))
|
||
# Apply to the 3D viewer: if the body has a rendered object, hide
|
||
# or show it. Bodies without a render_object (e.g. just-created,
|
||
# not yet displayed) don't need viewer updates; they'll pick up
|
||
# the visibility at the next redraw.
|
||
if body.render_object is not None:
|
||
ok = self._viewer_3d.set_visibility(body.render_object, new_visible)
|
||
if not ok:
|
||
logger.debug(
|
||
"set_visibility failed for body %s (render_object=%r)",
|
||
body.name, body.render_object,
|
||
)
|
||
logger.info(
|
||
f"{'Visible' if new_visible else 'Hidden'}: {body.name}"
|
||
)
|
||
|
||
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
|
||
|
||
def _resolve_extrude_target(
|
||
self, sketch: Sketch, exclude_body: Optional[Body] = None
|
||
) -> Optional[Body]:
|
||
"""Choose the body a cut / union should target.
|
||
|
||
Preference order:
|
||
1. the body the sketch was projected onto (``sketch._source_body_id``)
|
||
2. the first body in the component that isn't the *exclude_body*
|
||
(the freshly-extruded tool itself, which we don't want to cut
|
||
*itself*).
|
||
Returns *None* if there is no candidate (e.g. the sketch wasn't
|
||
on a face and the component has no other bodies).
|
||
"""
|
||
if self._current_component is None:
|
||
return None
|
||
bodies = self._current_component.bodies
|
||
src_id = getattr(sketch, "_source_body_id", None)
|
||
if src_id is not None and src_id in bodies:
|
||
cand = bodies[src_id]
|
||
if cand is not exclude_body:
|
||
return cand
|
||
for body in bodies.values():
|
||
if body is exclude_body:
|
||
continue
|
||
return body
|
||
return None
|
||
|
||
def _through_all_length(self, target: Body, sketch: Sketch) -> float:
|
||
"""Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass
|
||
*through* the target body.
|
||
|
||
Computes the target body's bounding-box extent along the sketch's
|
||
workplane normal direction ("extent" = how far the body reaches on
|
||
either side of the face). With ``symmetric=True`` the kernel
|
||
extrudes ``± height/2``, so to clear the full ``extent`` on each
|
||
side we need ``height = 2 × (extent + buffer)``. The 5 mm buffer
|
||
on each side guarantees the tool pokes out past the body so the
|
||
boolean reliably removes the through volume.
|
||
"""
|
||
import numpy as _np
|
||
try:
|
||
p_min, p_max = self._kernel.get_bounding_box(target.geometry)
|
||
except Exception:
|
||
logger.debug("through-all bbox failed", exc_info=True)
|
||
return 2000.0 # generous fallback if bbox fails for any reason
|
||
origin = _np.asarray(sketch.workplane_origin, dtype=float)
|
||
normal = _np.asarray(sketch.workplane_normal, dtype=float)
|
||
normal = normal / max(_np.linalg.norm(normal), 1e-12)
|
||
corners = []
|
||
for xs in (p_min.x, p_max.x):
|
||
for ys in (p_min.y, p_max.y):
|
||
for zs in (p_min.z, p_max.z):
|
||
corners.append(_np.array([xs, ys, zs]))
|
||
ds = [_np.dot(c - origin, normal) for c in corners]
|
||
extent = max(abs(min(ds)), abs(max(ds)))
|
||
# Symmetric through: cover ±(extent + 5 mm) on each side of the
|
||
# face plane, which means a total height of 2×(extent + 5).
|
||
return 2.0 * float(extent) + 10.0
|
||
|
||
def _compute_extrude_result(
|
||
self,
|
||
sketch: Sketch,
|
||
face_geom: Any,
|
||
length: float,
|
||
symmetric: bool,
|
||
invert: bool,
|
||
cut: bool,
|
||
union: bool,
|
||
through_all: bool,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Compute the *previewable* result of an extrude/cut/union.
|
||
|
||
Returns a dict with:
|
||
- "result_shape": final TopoDS_Shape (the thing to show / commit)
|
||
- "target_body": the Body being modified (None for plain extrude)
|
||
- "tool_geom": the extruded profile geometry (the boolean tool)
|
||
- "tool_shape": same, as a TopoDS_Shape (for show/remove)
|
||
Or *None* if the geometry can't be built (e.g. empty sketch).
|
||
|
||
Mutates nothing on the project — safe to call repeatedly for the
|
||
live preview. The apply path (:meth:`_extrude_sketch`) commits
|
||
the returned shape onto ``target_body`` (or creates a new body
|
||
for plain extrudes).
|
||
"""
|
||
if face_geom is None:
|
||
return None
|
||
# Resolve target (only meaningful for cut / union).
|
||
target = self._resolve_extrude_target(sketch) if (cut or union) else None
|
||
# Determine the extrude length and direction.
|
||
if through_all and target is not None:
|
||
# Pass-through: symmetric extrude large enough to clear the body
|
||
# on both sides of the face plane (direction-agnostic).
|
||
extrude_length = self._through_all_length(target, sketch)
|
||
symmetric = True
|
||
invert = False
|
||
else:
|
||
# Cut targeting a body must go *into* the body — the picked face's
|
||
# outward normal points AWAY from the body, so a non-inverted
|
||
# extrude would build a boss ABOVE the face and the boolean cut
|
||
# would remove nothing. Force the tool into the body so
|
||
# "Perform Cut" always carves a real pocket.
|
||
if cut and target is not None:
|
||
invert = True
|
||
extrude_length = -length if invert else length
|
||
try:
|
||
tool_geom = self._kernel.extrude(
|
||
face_geom, extrude_length, symmetric=symmetric
|
||
)
|
||
except Exception as exc:
|
||
logger.debug("preview extrude failed: %s", exc)
|
||
return None
|
||
if tool_geom is None:
|
||
return None
|
||
tool_shape = self._kernel._get_shape(tool_geom)
|
||
if target is not None:
|
||
try:
|
||
if cut:
|
||
result_geom = self._kernel.boolean_difference(
|
||
target.geometry, tool_geom
|
||
)
|
||
else: # union
|
||
result_geom = self._kernel.boolean_union(
|
||
target.geometry, tool_geom
|
||
)
|
||
except Exception as exc:
|
||
logger.debug("preview boolean failed: %s", exc)
|
||
return None
|
||
result_shape = self._kernel._get_shape(result_geom)
|
||
return {
|
||
"result_shape": result_shape,
|
||
"result_geom": result_geom,
|
||
"target_body": target,
|
||
"tool_geom": tool_geom,
|
||
"tool_shape": tool_shape,
|
||
}
|
||
# Plain extrude: the tool IS the result.
|
||
return {
|
||
"result_shape": tool_shape,
|
||
"result_geom": tool_geom,
|
||
"target_body": None,
|
||
"tool_geom": tool_geom,
|
||
"tool_shape": tool_shape,
|
||
}
|
||
|
||
def _start_extrude_preview(self, dialog: ExtrudeDialog, sketch: Sketch, face_geom: Any) -> None:
|
||
"""Install a live-preview callback on *dialog* for *sketch*.
|
||
|
||
The host dims the body the cut/union will target (if any) so the
|
||
previewed result reads clearly on top of it. The dimming is
|
||
reverted on dialog close (see hideEvent → callback(None)).
|
||
"""
|
||
# Track which bodies we dimmed so we can restore their transparency
|
||
# exactly (they might have had a non-zero transparency to start, in
|
||
# which case we leave them alone).
|
||
state = {"dimmed": []}
|
||
|
||
def _apply_dim(target: Optional[Body]):
|
||
# Undo any prior dim, then dim the new target.
|
||
for bid, tval in state["dimmed"]:
|
||
body = self._current_component.bodies.get(bid) if self._current_component else None
|
||
if body is not None and body.render_object is not None:
|
||
self._viewer_3d.set_transparency(body.render_object, 0.0)
|
||
state["dimmed"].clear()
|
||
if target is not None and target.render_object is not None:
|
||
ok = self._viewer_3d.set_transparency(target.render_object, 0.6)
|
||
if ok:
|
||
state["dimmed"].append((target.id, 0.6))
|
||
|
||
def _clear():
|
||
self._viewer_3d.clear_preview()
|
||
for bid, _tval in state["dimmed"]:
|
||
body = self._current_component.bodies.get(bid) if self._current_component else None
|
||
if body is not None and body.render_object is not None:
|
||
self._viewer_3d.set_transparency(body.render_object, 0.0)
|
||
state["dimmed"].clear()
|
||
|
||
def _callback(values):
|
||
if values is None:
|
||
_clear()
|
||
return
|
||
length, symmetric, invert, cut, union, through_all, _rounded = values
|
||
result = self._compute_extrude_result(
|
||
sketch, face_geom,
|
||
length, symmetric, invert, bool(cut), bool(union),
|
||
bool(through_all),
|
||
)
|
||
if result is None or result["result_shape"] is None:
|
||
self._viewer_3d.clear_preview()
|
||
_apply_dim(None)
|
||
return
|
||
self._viewer_3d.show_preview(result["result_shape"])
|
||
_apply_dim(result["target_body"])
|
||
|
||
dialog.set_preview_callback(_callback)
|
||
|
||
def _extrude_sketch(self):
|
||
logger.info("=== EXTRUDE SKETCH ===")
|
||
if not self._current_component:
|
||
logger.warning("No current component")
|
||
return
|
||
|
||
sketch = self._current_sketch
|
||
logger.debug(f"Current sketch: {sketch}")
|
||
if not sketch or not sketch.occ_sketch:
|
||
sketch_entity = self._sketch_widget.get_sketch()
|
||
logger.debug(f"Sketch from widget: {sketch_entity}")
|
||
if not sketch_entity:
|
||
logger.warning("No sketch entity found")
|
||
QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
|
||
return
|
||
sketch.occ_sketch = sketch_entity
|
||
|
||
# Resolve the profile geometry *before* opening the dialog so the
|
||
# live preview can use it. Prefer the selected face (which can
|
||
# include holes) over the full sketch.
|
||
face_geom = self._sketch_widget.get_selected_face_geometry()
|
||
if face_geom is not None:
|
||
logger.info("Using selected face geometry (with holes)")
|
||
else:
|
||
face_geom = sketch.occ_sketch.get_geometry()
|
||
logger.debug(f"Geometry: {face_geom}")
|
||
if not face_geom:
|
||
logger.error("No geometry from sketch")
|
||
QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
|
||
return
|
||
|
||
dialog = ExtrudeDialog(self)
|
||
# Wire up the live preview: every spinbox/checkbox change rebuilds
|
||
# the result via the shared helper and shows it transparent.
|
||
self._start_extrude_preview(dialog, sketch, face_geom)
|
||
accepted = dialog.exec()
|
||
# The dialog's hideEvent already fired the callback with *None* to
|
||
# clear the preview and un-dim any body — but be defensive in case
|
||
# a subclass swallows the event.
|
||
self._viewer_3d.clear_preview()
|
||
if not accepted:
|
||
logger.info("Extrude dialog cancelled")
|
||
return
|
||
|
||
length, symmetric, invert, cut, union, through_all, rounded = dialog.get_values()
|
||
logger.info(
|
||
f"Extrude params: length={length}, symmetric={symmetric}, "
|
||
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}"
|
||
)
|
||
|
||
try:
|
||
result = self._compute_extrude_result(
|
||
sketch, face_geom,
|
||
length, symmetric, invert, bool(cut), bool(union),
|
||
bool(through_all),
|
||
)
|
||
if result is None or result["result_geom"] is None:
|
||
logger.warning("Extrude produced no geometry")
|
||
QMessageBox.warning(self, "No Geometry", "Extrude produced no geometry")
|
||
return
|
||
|
||
target = result["target_body"]
|
||
if target is not None:
|
||
# Cut / union: commit the result onto the *target* body in
|
||
# place (don't create a separate tool body — the previous
|
||
# implementation did, and that was the user-perceived
|
||
# "added without cut" bug once the spurious body was
|
||
# deleted).
|
||
target.geometry = result["result_geom"]
|
||
if target.render_object is not None:
|
||
self._viewer_3d.remove_mesh(target.render_object)
|
||
shape = self._kernel._get_shape(target.geometry)
|
||
target.render_object = self._viewer_3d.show_shape(
|
||
shape, target.color, target.name
|
||
)
|
||
op = "cut" if cut else "union"
|
||
logger.info(
|
||
f"{op.capitalize()} applied: {target.name} now holds the result"
|
||
)
|
||
body_name = target.name
|
||
else:
|
||
# Plain extrude: create a new body for the extrusion.
|
||
body = self._current_component.add_body(
|
||
Body(
|
||
name=f"Extrusion_{len(self._current_component.bodies) + 1}",
|
||
geometry=result["result_geom"],
|
||
source_sketch=sketch,
|
||
source_operation="extrude",
|
||
)
|
||
)
|
||
logger.info(f"Created body: {body.name}")
|
||
logger.debug("Adding shape to OCC viewer")
|
||
shape = self._kernel._get_shape(body.geometry)
|
||
body.render_object = self._viewer_3d.show_shape(
|
||
shape, body.color, body.name
|
||
)
|
||
logger.info(f"Render object: {body.render_object}")
|
||
body_name = body.name
|
||
|
||
self._refresh_lists()
|
||
self._viewer_3d.fit_camera()
|
||
logger.info(f"Extruded: {body_name}")
|
||
logger.info("=== EXTRUDE COMPLETE ===")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Extrude failed: {e}")
|
||
QMessageBox.critical(self, "Error", f"Extrude failed: {e}")
|
||
|
||
def _revolve_sketch(self):
|
||
logger.info("=== REVOLVE SKETCH ===")
|
||
if not self._current_component:
|
||
logger.warning("No current component")
|
||
return
|
||
|
||
sketch = self._current_sketch
|
||
if not sketch or not sketch.occ_sketch:
|
||
sketch_entity = self._sketch_widget.get_sketch()
|
||
if not sketch_entity:
|
||
QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
|
||
return
|
||
sketch.occ_sketch = sketch_entity
|
||
|
||
dialog = RevolveDialog(self)
|
||
if not dialog.exec():
|
||
logger.info("Revolve dialog cancelled")
|
||
return
|
||
|
||
angle = dialog.angle_input.value()
|
||
|
||
try:
|
||
face_geom = self._sketch_widget.get_selected_face_geometry()
|
||
if face_geom is not None:
|
||
geometry = face_geom
|
||
else:
|
||
geometry = sketch.occ_sketch.get_geometry()
|
||
if not geometry:
|
||
QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
|
||
return
|
||
|
||
body_geometry = self._kernel.revolve(geometry, angle)
|
||
body = self._current_component.add_body(
|
||
Body(
|
||
name=f"Revolution_{len(self._current_component.bodies) + 1}",
|
||
geometry=body_geometry,
|
||
source_sketch=sketch,
|
||
source_operation="revolve",
|
||
)
|
||
)
|
||
|
||
logger.debug("Adding shape to OCC viewer")
|
||
shape = self._kernel._get_shape(body_geometry)
|
||
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||
logger.info(f"Render object: {body.render_object}")
|
||
|
||
self._refresh_lists()
|
||
self._viewer_3d.fit_camera()
|
||
logger.info(f"Revolved: {body.name}")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Revolve failed: {e}")
|
||
QMessageBox.critical(self, "Error", f"Revolve failed: {e}")
|
||
|
||
def _boolean_cut(self):
|
||
logger.info("=== BOOLEAN CUT ===")
|
||
if not self._current_component or len(self._current_component.bodies) < 2:
|
||
QMessageBox.warning(self, "Need Bodies", "Need at least 2 bodies to perform cut.\nCreate multiple bodies first.")
|
||
return
|
||
|
||
# Use the first body in the list as base, last as tool
|
||
body_ids = list(self._current_component.bodies.keys())
|
||
if len(body_ids) < 2:
|
||
return
|
||
|
||
# Let user pick which body to use as tool
|
||
body_names = [self._current_component.bodies[bid].name for bid in body_ids]
|
||
tool_name, ok = QInputDialog.getItem(
|
||
self, "Select Tool Body", "Body to subtract (tool):", body_names, len(body_names) - 1, False
|
||
)
|
||
if not ok:
|
||
return
|
||
|
||
tool_id = None
|
||
base_id = None
|
||
for bid in body_ids:
|
||
if self._current_component.bodies[bid].name == tool_name:
|
||
tool_id = bid
|
||
else:
|
||
base_id = bid
|
||
|
||
if tool_id is None or base_id is None:
|
||
return
|
||
|
||
base_body = self._current_component.bodies[base_id]
|
||
tool_body = self._current_component.bodies[tool_id]
|
||
|
||
if not base_body.geometry or not tool_body.geometry:
|
||
QMessageBox.warning(self, "No Geometry", "One of the bodies has no geometry")
|
||
return
|
||
|
||
try:
|
||
result_geom = self._kernel.boolean_difference(base_body.geometry, tool_body.geometry)
|
||
new_body = self._current_component.add_body(
|
||
Body(
|
||
name=f"Cut_{len(self._current_component.bodies) + 1}",
|
||
geometry=result_geom,
|
||
source_operation="boolean_cut",
|
||
)
|
||
)
|
||
|
||
logger.debug("Adding shape to OCC viewer")
|
||
shape = self._kernel._get_shape(result_geom)
|
||
new_body.render_object = self._viewer_3d.show_shape(shape, new_body.color, new_body.name)
|
||
logger.info(f"Render object: {new_body.render_object}")
|
||
|
||
self._refresh_lists()
|
||
self._viewer_3d.fit_camera()
|
||
logger.info(f"Cut complete: {new_body.name}")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Boolean cut failed: {e}")
|
||
QMessageBox.critical(self, "Error", f"Boolean cut failed: {e}")
|
||
|
||
def _boolean_union(self):
|
||
logger.info("=== BOOLEAN UNION ===")
|
||
if not self._current_component or len(self._current_component.bodies) < 2:
|
||
QMessageBox.warning(self, "Need Bodies", "Need at least 2 bodies to perform union.")
|
||
return
|
||
|
||
bodies = list(self._current_component.bodies.values())
|
||
geometries = [b.geometry for b in bodies if b.geometry]
|
||
|
||
if len(geometries) < 2:
|
||
QMessageBox.warning(self, "Need Bodies", "Not enough bodies with valid geometry.")
|
||
return
|
||
|
||
try:
|
||
result_geom = self._kernel.boolean_union(*geometries)
|
||
new_body = self._current_component.add_body(
|
||
Body(
|
||
name=f"Union_{len(self._current_component.bodies) + 1}",
|
||
geometry=result_geom,
|
||
source_operation="boolean_union",
|
||
)
|
||
)
|
||
|
||
logger.debug("Adding shape to OCC viewer")
|
||
shape = self._kernel._get_shape(result_geom)
|
||
new_body.render_object = self._viewer_3d.show_shape(shape, new_body.color, new_body.name)
|
||
logger.info(f"Render object: {new_body.render_object}")
|
||
|
||
self._refresh_lists()
|
||
self._viewer_3d.fit_camera()
|
||
logger.info(f"Union complete: {new_body.name}")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Boolean union failed: {e}")
|
||
QMessageBox.critical(self, "Error", f"Boolean union failed: {e}")
|
||
|
||
def _delete_body(self):
|
||
selected = self._body_list.currentItem()
|
||
if not selected or not self._current_component:
|
||
return
|
||
|
||
name = selected.text()
|
||
to_delete = None
|
||
for body_id, body in self._current_component.bodies.items():
|
||
if body.name == name:
|
||
to_delete = body_id
|
||
if body.render_object:
|
||
self._viewer_3d.remove_mesh(body.render_object)
|
||
break
|
||
|
||
if to_delete:
|
||
del self._current_component.bodies[to_delete]
|
||
self._refresh_lists()
|
||
logger.info(f"Deleted body: {name}")
|
||
|
||
def _new_project(self):
|
||
self._project = Project()
|
||
self._current_component = None
|
||
self._current_sketch = None
|
||
self._selected_body = None
|
||
|
||
for btn in self._component_buttons:
|
||
btn.deleteLater()
|
||
self._component_buttons.clear()
|
||
|
||
# set_sketch(None) clears the underlay entities via the new
|
||
# set_sketch guard, but we also need to drop the saved source face
|
||
# and reset the workplane buttons to their disabled state.
|
||
self._sketch_widget.clear_source_face()
|
||
self._sketch_widget.set_sketch(None)
|
||
self._viewer_3d.clear_scene()
|
||
self._refresh_lists()
|
||
self._btn_underlay.setEnabled(False)
|
||
self._btn_underlay.setChecked(True)
|
||
self._btn_clr_face.setEnabled(False)
|
||
|
||
self._create_initial_component()
|
||
logger.info("New project created")
|
||
|
||
def _import_file(self):
|
||
filepath, _ = QFileDialog.getOpenFileName(
|
||
self, "Import File", "", "STEP Files (*.step *.stp);;IGES Files (*.iges *.igs)"
|
||
)
|
||
if filepath:
|
||
try:
|
||
if filepath.lower().endswith((".step", ".stp")):
|
||
geometry = self._kernel.import_step(filepath)
|
||
else:
|
||
geometry = self._kernel.import_iges(filepath)
|
||
|
||
if not self._current_component:
|
||
self._current_component = self._project.add_component()
|
||
|
||
body = self._current_component.add_body(
|
||
Body(name="Imported", geometry=geometry, source_operation="import")
|
||
)
|
||
|
||
vertices, faces = body.get_mesh(self._kernel)
|
||
body.render_object = self._viewer_3d.add_mesh(
|
||
vertices, faces, body.color, body.name
|
||
)
|
||
|
||
self._refresh_lists()
|
||
self._viewer_3d.fit_camera()
|
||
logger.info(f"Imported: {filepath}")
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", f"Failed to import: {e}")
|
||
|
||
def _export_step(self):
|
||
if not self._selected_body:
|
||
QMessageBox.warning(self, "No Selection", "Please select a body")
|
||
return
|
||
|
||
filepath, _ = QFileDialog.getSaveFileName(
|
||
self, "Export STEP", "", "STEP Files (*.step *.stp)"
|
||
)
|
||
if filepath:
|
||
if self._kernel.export_step(self._selected_body.geometry, filepath):
|
||
logger.info(f"Exported: {filepath}")
|
||
else:
|
||
QMessageBox.warning(self, "Export Failed", "Failed to export STEP")
|
||
|
||
def _export_iges(self):
|
||
if not self._selected_body:
|
||
QMessageBox.warning(self, "No Selection", "Please select a body")
|
||
return
|
||
|
||
filepath, _ = QFileDialog.getSaveFileName(
|
||
self, "Export IGES", "", "IGES Files (*.iges *.igs)"
|
||
)
|
||
if filepath:
|
||
if self._kernel.export_iges(self._selected_body.geometry, filepath):
|
||
logger.info(f"Exported: {filepath}")
|
||
else:
|
||
QMessageBox.warning(self, "Export Failed", "Failed to export IGES")
|
||
|
||
def _export_stl(self):
|
||
if not self._selected_body:
|
||
QMessageBox.warning(self, "No Selection", "Please select a body")
|
||
return
|
||
|
||
filepath, _ = QFileDialog.getSaveFileName(self, "Export STL", "", "STL Files (*.stl)")
|
||
if filepath:
|
||
if self._kernel.export_stl(self._selected_body.geometry, filepath):
|
||
logger.info(f"Exported: {filepath}")
|
||
else:
|
||
QMessageBox.warning(self, "Export Failed", "Failed to export STL")
|
||
|
||
def _fit_view(self):
|
||
self._viewer_3d.fit_camera()
|
||
|
||
def _reset_view(self):
|
||
self._viewer_3d.set_camera_position((100, 100, 100), (0, 0, 0))
|
||
|
||
def _show_about(self):
|
||
QMessageBox.about(
|
||
self,
|
||
"About Fluency CAD",
|
||
"Fluency CAD 2.0\n\n"
|
||
"A parametric CAD application built on:\n"
|
||
"- OpenCASCADE Technology (OCCT)\n"
|
||
"- CadQuery Python bindings\n"
|
||
"- pygfx WebGPU renderer\n\n"
|
||
"Features:\n"
|
||
"- STEP/IGES import/export\n"
|
||
"- Parametric sketching\n"
|
||
"- Boolean operations\n"
|
||
"- Fillets and chamfers\n"
|
||
"- Component timeline",
|
||
)
|
||
|
||
|
||
def main() -> int:
|
||
app = QApplication(sys.argv)
|
||
app.setStyle("Fusion")
|
||
|
||
window = MainWindow()
|
||
window.show()
|
||
|
||
return app.exec()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|