Files
fluencyCAD/src/fluency/ui/sketch_widget.py
T
bklronin 80ba3cc70a - Added save file foramt
- Split main.py refactor
2026-07-07 21:51:27 +02:00

2882 lines
130 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""2D sketch widget — paints the sketcher, handles drawing / snapping / constraints."""
from __future__ import annotations
import logging
import math
from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
from PySide6.QtGui import (
QBrush,
QColor,
QCursor,
QFont,
QFontMetrics,
QPainter,
QPainterPath,
QPen,
QPolygonF,
)
from PySide6.QtWidgets import (
QInputDialog,
QWidget,
)
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
logger = logging.getLogger(__name__)
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 \u2192 endpoints,
curves \u2192 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 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._arcs: List[Tuple[OCCSketchEntity, float, OCCSketchEntity, OCCSketchEntity, float]] = [] # (center, radius, start_point, end_point, sweep)
# Accumulated sweep tracking during arc draw (smoothly follows cursor)
self._arc_accum_sweep: float = 0.0
self._arc_prev_angle: Optional[float] = None
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
# Auto-constraint tracking on snap
self._snap_point_target: Optional[OCCSketchEntity] = None
self._snap_line_target: Optional[OCCSketchEntity] = None
self._snap_horizontal: bool = False
self._snap_vertical: bool = False
# Rectangle first-click snap target (stored so the second click
# doesn't overwrite it and the correct corner gets constrained).
self._rect_first_snap_target: Optional[OCCSketchEntity] = None
# Offset preview state (live preview while the OffsetDialog is open).
self._offset_preview_points: List[Tuple[float, float]] = []
self._offset_preview_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.clear_offset_preview()
self._hovered_point = None
self._hovered_point_entity = None
self._hovered_line = None
self._hovered_line_entity = None
self._selected_entities = []
self.update()
def _convert_underlay_to_sketch(self) -> None:
"""Convert underlay (external/construction) entities into regular geometry.
Creates new non-construction point and line entities at the same
positions as every external entity in the sketch, coincident-
constrained to their external counterparts so they stay aligned.
The underlay reference entities are left intact (the user can still
toggle them on/off).
After calling this, the newly created regular geometry participates
in face detection and can be selected for offset, extrude, or cut
operations — without the user having to manually trace the
projected outlines.
"""
if self._sketch is None:
return
# Map external point IDs → newly created regular point entities
ext_to_new: Dict[int, OCCSketchEntity] = {}
# Snapshot the entity list before modifying (add_point adds to
# _entities, which would change dict size mid-iteration).
for eid, entity in list(self._sketch._entities.items()):
if (
entity.is_external
and entity.entity_type == "point"
and entity.geometry is not None
):
x, y = entity.geometry
new_pt = self._sketch.add_point(x, y)
new_pt.is_construction = False
self._points.append(new_pt)
self._sketch.constrain_coincident(new_pt, entity)
ext_to_new[eid] = new_pt
if not ext_to_new:
return
# Create matching lines for each external line segment
for eid, (sid, eid2) in list(self._sketch._lines.items()):
s_ent = self._sketch._entities.get(sid)
e_ent = self._sketch._entities.get(eid2)
if s_ent and e_ent and s_ent.is_external and e_ent.is_external:
new_s = ext_to_new.get(sid)
new_e = ext_to_new.get(eid2)
if new_s is not None and new_e is not None:
new_line = self._sketch.add_line(new_s, new_e)
self._lines.append((new_s, new_e))
self._solve_and_sync()
self._rebuild_from_sketch()
# Hide the underlay so the user can see the new solid geometry
self._underlay_visible = False
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._arcs = []
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))
# Rebuild arcs: arc data is stored as {"center": cid, "start": sid, "end": eid2, "radius": r}
for eid, arc_data in self._sketch._arcs.items():
center_id = arc_data.get("center")
start_id = arc_data.get("start")
end_id = arc_data.get("end")
radius = arc_data.get("radius", 0)
sweep = arc_data.get("sweep") # may be None (legacy arcs)
c_ent = self._sketch._entities.get(center_id) if center_id else None
s_ent = self._sketch._entities.get(start_id) if start_id else None
e_ent = self._sketch._entities.get(end_id) if end_id else None
if c_ent and s_ent and e_ent:
self._arcs.append((c_ent, radius, s_ent, e_ent, sweep))
@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 _is_centerline(self, entity: Any) -> bool:
"""True if an entity is a centerline reference axis (X or Y).
Centerlines are the permanent X (horizontal) and Y (vertical)
construction lines through the sketch origin. They are rendered
with distinct colors (red for X, green for Y) as viewport-spanning
dashed lines, and are protected from deletion.
"""
if entity is None or self._sketch is None:
return False
return entity.id in self._sketch._centerline_ids
def get_sketch(self) -> Optional[OCCSketch]:
return self._sketch
def create_sketch(self) -> OCCSketch:
self._sketch = OCCSketch()
self._sketch.add_centerlines()
# Sync widget tracking lists from sketch so centerlines (and any
# future auto-created entities) are immediately pickable.
self._rebuild_from_sketch()
self.update()
return self._sketch
def _ensure_sketch_with_centerlines(self) -> None:
"""Create a sketch with centerlines if none exists yet.
Drawing handlers (line, rect, circle, arc, slot) auto-create an
OCCSketch when the user starts drawing without first pressing
WP Origin — but the old code used ``OCCSketch()`` directly,
bypassing ``create_sketch()`` and leaving the sketch without
reference axes. This helper ensures centerlines are always
present, whether the sketch was created via WP Origin, WP Face,
or on-the-fly by a drawing tool.
"""
if self._sketch is None:
self._sketch = OCCSketch()
self._sketch.add_centerlines()
self._rebuild_from_sketch()
def reset_buffers(self):
self._draw_buffer = []
self._dynamic_line_end = None
self._mode = None
self._clear_move_state()
self._rect_first_snap_target = None
self.clear_offset_preview()
self.update()
def set_offset_preview(self, points: Optional[List[Tuple[float, float]]]) -> None:
"""Show or clear the offset preview overlay in the 2D view.
*points* is a list of (x, y) tuples forming a closed polygon, or
*None* / empty to clear the preview. Used by the OffsetDialog
live-preview callback to show the offset result in real time.
"""
if points:
self._offset_preview_points = list(points)
self._offset_preview_active = True
else:
self._offset_preview_points = []
self._offset_preview_active = False
self.update()
def clear_offset_preview(self) -> None:
"""Remove the offset preview overlay."""
self._offset_preview_points = []
self._offset_preview_active = False
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
self._snap_point_target = None
self._snap_line_target = None
self._snap_horizontal = False
self._snap_vertical = False
self._rect_first_snap_target = None
self._arc_accum_sweep = 0.0
self._arc_prev_angle = 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
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
self.clear_offset_preview()
# 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 _screen_to_world_f(self, pos: QPoint) -> Tuple[float, float]:
"""Float-precision version of ``_screen_to_world``.
Used by face detection so that small shapes (sub-integer size at
the current zoom) are pickable. The integer version truncates
fractional world coords, which can place the hit point outside
a tiny polygon.
"""
wx = (pos.x() - self.width() / 2 - self._offset.x()) / self._zoom
wy = (self.height() / 2 - pos.y() + self._offset.y()) / self._zoom
return (wx, wy)
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
# Reset auto-constraint tracking
self._snap_point_target = None
self._snap_line_target = None
self._snap_horizontal = False
self._snap_vertical = False
point_snap = self._find_nearest_point(pos)
if point_snap:
self._snap_point_target = self._find_nearest_point_entity(pos)
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
self._snap_horizontal = True
if self._snap_mode.get("vert", False):
vert = self._apply_vertical_snap(start, result)
if abs(result.x() - start.x()) < 10:
result = vert
self._snap_vertical = True
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 = 10 # 10mm world units per grid cell (matches 10mm minor 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
# Reset auto-constraint tracking
self._snap_point_target = None
self._snap_line_target = None
self._snap_horizontal = False
self._snap_vertical = False
# Point snap (excluding moved points)
if self._snap_mode.get("point", False):
nearest = None
nearest_entity = 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
nearest_entity = entity
if nearest is not None:
self._snap_point_target = nearest_entity
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.
Centerlines are treated as infinite reference axes — the hit test
uses perpendicular distance to the infinite line (no segment
clamping) with a zoom-adjusted tolerance so they are pickable at
any zoom level.
"""
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
dx = x2 - x1
dy = y2 - y1
if dx == 0 and dy == 0:
continue
# Compute perpendicular distance to the infinite line.
line_len_sq = dx * dx + dy * dy
# Perpendicular distance: |(P - P1) × (P2 - P1)| / |P2 - P1|
perp_dist = abs(dx * (y1 - world_pos.y()) - dy * (x1 - world_pos.x())) / math.sqrt(line_len_sq)
is_cl = line_ent is not None and self._is_centerline(line_ent)
if is_cl:
# Centerlines are infinite reference axes: use the
# perpendicular distance directly (no segment clamping).
# Zoom-adjusted tolerance: at least 20 pixels in screen space
# so the axes are easily pickable regardless of zoom level.
tol = max(10.0, 20.0 / max(self._zoom, 0.01))
if perp_dist < tol:
return (p1_ent, p2_ent)
else:
# Regular lines: clamp the projection to the segment
# and check distance to that clamped point.
t = ((world_pos.x() - x1) * dx + (world_pos.y() - y1) * dy) / line_len_sq
t = max(0.0, min(1.0, t))
proj_x = x1 + t * dx
proj_y = y1 + t * dy
seg_dist = math.sqrt((world_pos.x() - proj_x)**2 + (world_pos.y() - proj_y)**2)
if seg_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 AND arcs.
``OCCSketch._lines`` maps line id -> (start_point_id, end_point_id) and
``OCCSketch._arcs`` maps arc id -> {"center": cid, "start": sid, "end": eid, ...}.
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, or all 6 points of a slot).
"""
if not self._sketch:
return [anchor]
adjacency: Dict[int, List[int]] = {}
# Line connections
for _line_id, (sid, eid2) in self._sketch._lines.items():
adjacency.setdefault(sid, []).append(eid2)
adjacency.setdefault(eid2, []).append(sid)
# Arc connections — an arc ties its centre to its start and end, and
# also ties start to end so the whole arc body moves as a single unit.
for _arc_id, arc_data in self._sketch._arcs.items():
center_id = arc_data.get("center")
start_id = arc_data.get("start")
end_id = arc_data.get("end")
if center_id is not None:
if start_id is not None:
adjacency.setdefault(center_id, []).append(start_id)
adjacency.setdefault(start_id, []).append(center_id)
if end_id is not None:
adjacency.setdefault(center_id, []).append(end_id)
adjacency.setdefault(end_id, []).append(center_id)
# Connect start <-> end so the arc body moves as a unit even
# without traversing through centre (e.g. grabbing an endpoint
# pulls the other endpoint and the centre together).
if start_id is not None and end_id is not None:
adjacency.setdefault(start_id, []).append(end_id)
adjacency.setdefault(end_id, []).append(start_id)
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._arc_accum_sweep = 0.0
self._arc_prev_angle = None
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)
and not self._is_centerline(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.
# Use float-precision world coords so small shapes (sub-integer
# at the current zoom) are still pickable.
fwx, fwy = self._screen_to_world_f(event.pos())
face = self._sketch.find_face_at(fwx, fwy)
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])
and not self._is_centerline(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._world_to_screen(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 == "arc":
self._handle_arc_click(world_snapped)
elif self._mode == "slot":
self._handle_slot_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", "arc", "slot"]:
snapped = self._apply_all_snaps(
event.pos(), self._world_to_screen(self._draw_buffer[0])
)
self._dynamic_line_end = self._screen_to_world(snapped)
# Project arc end onto the circle so the preview snaps to
# the correct radius (matching the start point's distance).
if self._mode == "arc" and len(self._draw_buffer) == 2:
cw = self._draw_buffer[0]
sw = self._draw_buffer[1]
ew = self._dynamic_line_end
cx_f, cy_f = cw.x(), cw.y()
sx_f, sy_f = sw.x(), sw.y()
r = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2) if (
(sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2 > 0
) else 1.0
dx = ew.x() - cx_f
dy = ew.y() - cy_f
d = math.sqrt(dx * dx + dy * dy)
if d > 0:
self._dynamic_line_end = QPoint(
int(round(cx_f + dx / d * r)),
int(round(cy_f + dy / d * r)),
)
else:
self._dynamic_line_end = sw
# Accumulated sweep tracking for arc (after click 2, before click 3)
if self._mode == "arc" and len(self._draw_buffer) == 2:
cw = self._draw_buffer[0]
ew = self._dynamic_line_end
current_angle = math.atan2(ew.y() - cw.y(), ew.x() - cw.x())
if self._arc_prev_angle is not None:
delta = current_angle - self._arc_prev_angle
# Normalise delta to [-PI, PI] (handle atan2 wrap-around)
while delta > math.pi:
delta -= 2 * math.pi
while delta < -math.pi:
delta += 2 * math.pi
self._arc_accum_sweep += delta
self._arc_prev_angle = current_angle
# Constraint-tag hover takes priority — if the cursor is over a tag,
# we highlight it for delete and skip point/line/face 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._hovered_face = None
self.setCursor(Qt.PointingHandCursor)
self.update()
return
if self._hovered_constraint_idx != -1:
self._hovered_constraint_idx = -1
# Priority for select/move mode: point > face > line > circle.
# Face is checked before line so the user can see and select the
# wall region between two concentric boundaries (e.g. after offset).
# In drawing modes, lines take priority so the user can snap to them.
hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor
# ── ① Point hover (tightest, always first) ──
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._hovered_face = None
self.setCursor(hover_cursor)
else:
self._hovered_point = None
self._hovered_point_entity = None
# ── ② Face hover (checked before line in select mode) ──
# In select/move mode the face is the primary interaction target
# (select to extrude etc.). Lines remain selectable via click
# (element move) but the visual highlight shows the face region.
face_found = False
if self._mode in ("select", None) and self._sketch is not None:
fwx, fwy = self._screen_to_world_f(event.pos())
face = self._sketch.find_face_at(fwx, fwy)
if face is not None:
self._hovered_face = face
self._hovered_line = None
self._hovered_line_entity = None
self.setCursor(Qt.PointingHandCursor)
face_found = True
if not face_found:
self._hovered_face = None
# ── ③ Line hover (when no face under cursor) ──
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
# ── ④ Circle hover ──
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)
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()
self._snap_point_target = None
else:
# Auto-constrain: point snap during move → coincident
if self._snap_point_target is not None and self._move_anchor is not None:
self._sketch.constrain_coincident(self._move_anchor, self._snap_point_target)
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
# C key toggles the hovered line between normal and construction mode.
if event.key() == Qt.Key_C:
if self._mode in ("select", None) and not self._draw_buffer and self._sketch is not None:
if self._hovered_line_entity is not None:
self._toggle_hovered_line_construction()
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
# Centerlines are permanent reference axes — refuse deletion.
if self._is_centerline(line_ent):
logger.debug("Refusing to delete centerline")
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
# Centerline points are permanent reference anchors — refuse deletion.
if self._is_centerline(point_ent):
logger.debug("Refusing to delete centerline 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()
def _toggle_hovered_line_construction(self) -> None:
"""Toggle the hovered line between normal and construction mode.
The line entity's ``is_construction`` flag is flipped. The line
entity is then marked as the sole authority for its construction
state — its endpoint points are NOT toggled, so that other lines
sharing the same endpoint are unaffected. The paint code checks
the line entity's flag in addition to the endpoint flags.
"""
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 — they can't be toggled individually.
if getattr(line_ent, "is_external", False):
logger.debug("Refusing to toggle external (underlay) line")
return
# Centerlines are permanent reference axes — refuse toggling.
if self._is_centerline(line_ent):
logger.debug("Refusing to toggle centerline")
return
# Flip the construction flag on the line entity itself.
new_val = not line_ent.is_construction
line_ent.is_construction = new_val
logger.info(
f"Toggled line {line_ent.id} construction -> {new_val}"
)
self._hovered_line = None
self._hovered_line_entity = None
self._solve_and_sync()
self.sketch_updated.emit()
self.update()
# ─── Drawing handlers ─────────────────────────────────────────────────
def _handle_line_click(self, pos: QPoint):
self._ensure_sketch_with_centerlines()
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)
# Auto-constrain: point snap → coincident on start point
if self._snap_point_target is not None:
self._sketch.constrain_coincident(point, self._snap_point_target)
self._solve_and_sync()
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]))
# Auto-constrain: point snap → coincident on end point
if self._snap_point_target is not None:
self._sketch.constrain_coincident(self._points[-1], self._snap_point_target)
# Auto-constrain: detect horizontal / vertical from geometry
if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
p1_geom = self._points[-2].geometry
p2_geom = self._points[-1].geometry
if p1_geom is not None and p2_geom is not None:
x1, y1 = p1_geom
x2, y2 = p2_geom
if self._snap_mode.get("horiz", False) and abs(y1 - y2) < 1e-6:
self._sketch.constrain_horizontal(line)
elif self._snap_mode.get("vert", False) and abs(x1 - x2) < 1e-6:
self._sketch.constrain_vertical(line)
self._solve_and_sync()
self._draw_buffer = [pos]
self.sketch_updated.emit()
self.update()
def _handle_rectangle_click(self, pos: QPoint):
self._ensure_sketch_with_centerlines()
if not self._draw_buffer:
self._draw_buffer.append(pos)
self._rect_first_snap_target = self._snap_point_target
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)
line_entities = []
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]))
line_entities.append(line)
# Auto-constrain: point snap → coincident on the correct corners.
# pts[0] = first click snapped position
# pts[2] = second click snapped position
if self._rect_first_snap_target is not None:
self._sketch.constrain_coincident(pts[0], self._rect_first_snap_target)
if self._snap_point_target is not None:
self._sketch.constrain_coincident(pts[2], self._snap_point_target)
# Auto-constrain: detect horizontal / vertical from geometry
if self._snap_mode.get("horiz", False):
for idx in (0, 2):
p_start = pts[idx]
p_end = pts[(idx + 1) % 4]
if p_start.geometry and p_end.geometry:
dx = p_end.geometry[0] - p_start.geometry[0]
if abs(dx) > 1e-6:
self._sketch.constrain_horizontal(line_entities[idx])
if self._snap_mode.get("vert", False):
for idx in (1, 3):
p_start = pts[idx]
p_end = pts[(idx + 1) % 4]
if p_start.geometry and p_end.geometry:
dy = p_end.geometry[1] - p_start.geometry[1]
if abs(dy) > 1e-6:
self._sketch.constrain_vertical(line_entities[idx])
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):
self._ensure_sketch_with_centerlines()
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)
# Auto-constrain: point snap → coincident on center
if self._snap_point_target is not None:
self._sketch.constrain_coincident(center, self._snap_point_target)
self._solve_and_sync()
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_arc_click(self, pos: QPoint):
"""Three-click arc: center → start point (radius) → end point (sweep)."""
self._ensure_sketch_with_centerlines()
if not self._draw_buffer:
# Click 1: place center point
center = self._sketch.add_point(pos.x(), pos.y())
center.is_construction = self._is_construct
self._points.append(center)
self._draw_buffer.append(pos)
# Auto-constrain: point snap → coincident on center
if self._snap_point_target is not None:
self._sketch.constrain_coincident(center, self._snap_point_target)
self._solve_and_sync()
elif len(self._draw_buffer) == 1:
# Click 2: place start point (defines radius + start angle)
start = self._sketch.add_point(pos.x(), pos.y())
start.is_construction = self._is_construct
self._points.append(start)
self._draw_buffer.append(pos)
# Initialise accumulated sweep tracking
cx = self._draw_buffer[0].x()
cy = self._draw_buffer[0].y()
self._arc_prev_angle = math.atan2(pos.y() - cy, pos.x() - cx)
self._arc_accum_sweep = 0.0
# Auto-constrain: point snap → coincident on start
if self._snap_point_target is not None:
self._sketch.constrain_coincident(start, self._snap_point_target)
self._solve_and_sync()
else:
# Click 3: place end point, then create arc
center = self._points[-2]
start_point = self._points[-1]
cx, cy = center.geometry if center.geometry else (0, 0)
sx, sy = start_point.geometry if start_point.geometry else (0, 0)
radius = math.sqrt((sx - cx) ** 2 + (sy - cy) ** 2)
# Project the click onto the circle so the end point
# stays exactly on the same radius as the start.
dx = pos.x() - cx
dy = pos.y() - cy
dist = math.sqrt(dx * dx + dy * dy)
if dist > 0:
ex = cx + dx / dist * radius
ey = cy + dy / dist * radius
else:
ex = cx + radius
ey = cy
end = self._sketch.add_point(ex, ey)
end.is_construction = self._is_construct
self._points.append(end)
# Use accumulated sweep when significant; fall back to computed.
sweep = self._arc_accum_sweep
if abs(sweep) < 0.01:
# Barely moved — compute shortest-path from geometry
# using the projected end position.
ea = math.atan2(ey - cy, ex - cx)
sa = math.atan2(sy - cy, sx - cx)
sweep = ea - sa
while sweep > math.pi:
sweep -= 2 * math.pi
while sweep < -math.pi:
sweep += 2 * math.pi
if radius > 0:
self._sketch.add_arc(center, radius, start_point, end, sweep=sweep)
self._arcs.append((center, radius, start_point, end, sweep))
# Auto-constrain: point snap → coincident on end point
if self._snap_point_target is not None:
self._sketch.constrain_coincident(end, self._snap_point_target)
self._solve_and_sync()
self._draw_buffer = []
self._mode = None
self.constrain_done.emit()
self.sketch_updated.emit()
self.update()
def _handle_slot_click(self, pos: QPoint):
"""Three-click slot: center1 → center2 (centerline) → width radius.
Builds two semicircular arcs and two connecting lines forming a
closed slot profile suitable for extrusion.
"""
self._ensure_sketch_with_centerlines()
if not self._draw_buffer:
# Click 1: place first arc center C1.
c1 = self._sketch.add_point(pos.x(), pos.y())
c1.is_construction = self._is_construct
self._points.append(c1)
self._draw_buffer.append(pos)
if self._snap_point_target is not None:
self._sketch.constrain_coincident(c1, self._snap_point_target)
self._solve_and_sync()
self.sketch_updated.emit()
self.update()
return
if len(self._draw_buffer) == 1:
# Click 2: place second arc center C2 (defines centerline).
c2 = self._sketch.add_point(pos.x(), pos.y())
c2.is_construction = self._is_construct
self._points.append(c2)
self._draw_buffer.append(pos)
if self._snap_point_target is not None:
self._sketch.constrain_coincident(c2, self._snap_point_target)
self._solve_and_sync()
self.sketch_updated.emit()
self.update()
return
# Click 3: compute radius from perpendicular distance to centerline
# and build the full slot profile.
c1_pos = self._draw_buffer[0]
c2_pos = self._draw_buffer[1]
c1_ent = self._points[-2]
c2_ent = self._points[-1]
dx_c = c2_pos.x() - c1_pos.x()
dy_c = c2_pos.y() - c1_pos.y()
L_sq = dx_c * dx_c + dy_c * dy_c
if L_sq < 1.0:
# Degenerate: centers on top of each other → draw a circle
# using the midpoint-to-cursor distance as radius.
import math as _m
r_fallback = _m.hypot(pos.x() - c1_pos.x(), pos.y() - c1_pos.y())
if r_fallback > 0:
self._sketch.add_circle(c1_ent, r_fallback)
self._circles.append((c1_ent, r_fallback))
self._draw_buffer = []
self._mode = None
self.constrain_done.emit()
self.sketch_updated.emit()
self.update()
return
# Perpendicular distance from cursor to the centerline C1↔C2.
import math as _m
r = abs((pos.x() - c1_pos.x()) * dy_c
- (pos.y() - c1_pos.y()) * dx_c) / _m.sqrt(L_sq)
if r < 0.5:
r = 0.5 # minimum radius so the slot doesn't collapse
L = _m.sqrt(L_sq)
alpha = _m.atan2(dy_c, dx_c)
# Unit perpendicular (rotate 90° CCW)
perp_x = -dy_c / L
perp_y = dx_c / L
# Four corner points (world coords)
def corner(cx, cy, sign):
return QPoint(
int(round(cx + sign * r * perp_x)),
int(round(cy + sign * r * perp_y)),
)
# Top / bottom are relative to the perpendicular direction.
# Perp points "up" from the centreline; -perp points "down".
t1 = corner(c1_pos.x(), c1_pos.y(), 1) # C1 + perp
b1 = corner(c1_pos.x(), c1_pos.y(), -1) # C1 - perp
t2 = corner(c2_pos.x(), c2_pos.y(), 1) # C2 + perp
b2 = corner(c2_pos.x(), c2_pos.y(), -1) # C2 - perp
# Create point entities
pt1 = self._sketch.add_point(t1.x(), t1.y())
pt1.is_construction = self._is_construct
self._points.append(pt1)
pb1 = self._sketch.add_point(b1.x(), b1.y())
pb1.is_construction = self._is_construct
self._points.append(pb1)
pt2 = self._sketch.add_point(t2.x(), t2.y())
pt2.is_construction = self._is_construct
self._points.append(pt2)
pb2 = self._sketch.add_point(b2.x(), b2.y())
pb2.is_construction = self._is_construct
self._points.append(pb2)
# Auto-constrain: coincident on each corner point if it snaps onto
# an existing point entity (e.g. vertex of another shape).
for corner_pt in (pt1, pb1, pt2, pb2):
if corner_pt.geometry is not None:
cgx, cgy = corner_pt.geometry
snap_target = self._get_point_entity_at(
QPoint(int(round(cgx)), int(round(cgy)))
)
if snap_target is not None and snap_target.id != corner_pt.id:
self._sketch.constrain_coincident(corner_pt, snap_target)
# Arc 1 (at C1): exterior sweep from top to bottom (CCW)
arc1_ent = None
arc2_ent = None
if r > 0:
arc1_ent = self._sketch.add_arc(c1_ent, r, pt1, pb1, sweep=_m.pi)
self._arcs.append((c1_ent, r, pt1, pb1, _m.pi))
# Arc 2 (at C2): exterior sweep from bottom to top (CCW)
arc2_ent = self._sketch.add_arc(c2_ent, r, pb2, pt2, sweep=_m.pi)
self._arcs.append((c2_ent, r, pb2, pt2, _m.pi))
# Lines connecting the arc endpoints
line1 = self._sketch.add_line(pb1, pb2) # bottom line
self._lines.append((pb1, pb2))
line2 = self._sketch.add_line(pt2, pt1) # top line
self._lines.append((pt2, pt1))
# ── Parametric construction: perpendicular reference per arc ──
# Each arc needs a construction line from its centre, perpendicular
# to the connecting lines, to define the arc-endpoint direction and
# maintain smooth tangency between arc and line. The corner points
# sit on this line at distance r from the centre (the arc radius).
# Without this the solver can rotate the endpoints around the
# centre, breaking the visual arc-line tangency, and bare distance
# constraints lock the slot because the centre is the sketch's
# first (reference-fixed) point.
# Arc 1 (C1): construction line from centre to top endpoint
acl1 = self._sketch.add_line(c1_ent, pt1)
acl1.is_construction = True
self._lines.append((c1_ent, pt1))
# ⟂ to the connecting lines (line1 ∥ line2, so ⟂⁠to one = ⟂⁠to both)
self._sketch.constrain_perpendicular(acl1, line1)
# Bottom endpoint sits on this same line (diametrically opposite)
self._sketch.constrain_coincident(pb1, acl1)
# Arc radius: both endpoints at distance r from centre
self._sketch.constrain_distance(pt1, c1_ent, r)
self._sketch.constrain_distance(pb1, c1_ent, r)
# Arc 2 (C2): construction line from centre to top endpoint
acl2 = self._sketch.add_line(c2_ent, pt2)
acl2.is_construction = True
self._lines.append((c2_ent, pt2))
self._sketch.constrain_perpendicular(acl2, line1)
self._sketch.constrain_coincident(pb2, acl2)
self._sketch.constrain_distance(pt2, c2_ent, r)
self._sketch.constrain_distance(pb2, c2_ent, r)
# Auto-constrain: horizontal / vertical on the two slot lines
if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
for line_ent, pa, pb in [(line1, pb1, pb2), (line2, pt2, pt1)]:
if pa.geometry and pb.geometry:
x1, y1 = pa.geometry
x2, y2 = pb.geometry
if self._snap_mode.get("horiz", False) and abs(y1 - y2) < 1e-6:
self._sketch.constrain_horizontal(line_ent)
elif self._snap_mode.get("vert", False) and abs(x1 - x2) < 1e-6:
self._sketch.constrain_vertical(line_ent)
# Auto-constrain: the two slot lines are always parallel
self._sketch.constrain_parallel(line1, line2)
# Auto-constrain: the two arcs have equal radius
if arc1_ent is not None and arc2_ent is not None:
self._sketch.constrain_equal_radius(arc1_ent, arc2_ent)
self._solve_and_sync()
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 (fixed 10mm world-units spacing) ──
# Minor grid: 10 world-unit (10mm) spacing.
# Major grid: 100 world-unit (100mm) spacing drawn bolder.
# Grid lines are drawn in WORLD space so they keep their meaning
# regardless of zoom. When zoomed out too far to show 10mm lines
# clearly, only the 100mm major grid is drawn.
MIN_PX_SPACING = 6.0 # skip a grid level if screen spacing is below this
grid_10 = 10 # 10mm minor grid
grid_100 = 100 # 100mm major grid
# Compute screen-space spacing
px_10 = grid_10 * self._zoom
px_100 = grid_100 * self._zoom
# Viewport bounds in world coordinates
top_left = self._screen_to_world(QPoint(0, 0))
bottom_right = self._screen_to_world(QPoint(self.width(), self.height()))
pen_major = QPen(QColor("#45475a"), 1)
pen_minor = QPen(QColor("#3a3a4e"), 0.5)
# ── Draw 100mm major grid (always if visible) ──
if px_100 >= MIN_PX_SPACING:
start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_100) * grid_100
end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_100) * grid_100
start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_100) * grid_100
end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_100) * grid_100
painter.setPen(pen_major)
wx = start_x
while wx <= end_x:
sx = int(wx * self._zoom + self.width() / 2 + self._offset.x())
painter.drawLine(sx, 0, sx, self.height())
wx += grid_100
wy = start_y
while wy <= end_y:
sy = int(self.height() / 2 - wy * self._zoom + self._offset.y())
painter.drawLine(0, sy, self.width(), sy)
wy += grid_100
# ── Draw 10mm minor grid (only when wide enough spacing) ──
if px_10 >= MIN_PX_SPACING:
start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_10) * grid_10
end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_10) * grid_10
start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_10) * grid_10
end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_10) * grid_10
painter.setPen(pen_minor)
wx = start_x
while wx <= end_x:
sx = int(wx * self._zoom + self.width() / 2 + self._offset.x())
painter.drawLine(sx, 0, sx, self.height())
wx += grid_10
wy = start_y
while wy <= end_y:
sy = int(self.height() / 2 - wy * self._zoom + self._offset.y())
painter.drawLine(0, sy, self.width(), sy)
wy += grid_10
# ── Centerlines (X and Y reference axes through origin) ──
# X centerline = horizontal (red dashed), Y centerline = vertical (green dashed).
# Both span the full viewport so they are always visible as reference guides.
origin_sc = self._world_to_screen(QPoint(0, 0))
# X centerline (red) — horizontal through origin
painter.setPen(QPen(QColor("#f38ba8"), 1.5, Qt.DashLine))
painter.drawLine(0, origin_sc.y(), self.width(), origin_sc.y())
# Y centerline (green) — vertical through origin
painter.setPen(QPen(QColor("#a6e3a1"), 1.5, Qt.DashLine))
painter.drawLine(origin_sc.x(), 0, origin_sc.x(), self.height())
# Origin intersection point (small ring)
painter.setPen(QPen(QColor("#cdd6f4"), 1))
painter.setBrush(Qt.NoBrush)
painter.drawEllipse(origin_sc, 3, 3)
# ── 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
# Centerline points are rendered as part of the viewport-spanning
# axes above; skip them here to avoid double-drawing tiny dots
# at the endpoints far out of view.
if self._is_centerline(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
# Centerlines are rendered as viewport-spanning axes above;
# skip the finite-endpoint version here.
if line_ent is not None and self._is_centerline(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 or (line_ent is not None and line_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))
# ── Arcs ──
for arc_item in self._arcs:
center_ent, radius, start_ent, end_ent, sweep = arc_item[:5]
if not (center_ent.geometry and start_ent.geometry and end_ent.geometry):
continue
cx, cy = center_ent.geometry
sx, sy = start_ent.geometry
sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
sr = int(radius * self._zoom)
# Use stored sweep if available; fall back to shortest-path.
if sweep is None:
ex, ey = end_ent.geometry
sa = math.atan2(sy - cy, sx - cx)
ea = math.atan2(ey - cy, ex - cx)
sweep = ea - sa
while sweep > math.pi:
sweep -= 2 * math.pi
while sweep < -math.pi:
sweep += 2 * math.pi
start_angle = math.atan2(sy - cy, sx - cx)
# QPainter: 1/16 degree, positive = CCW; 0° = 3 o'clock
start_deg_16 = int(math.degrees(start_angle) * 16)
span_deg_16 = int(math.degrees(sweep) * 16)
rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2)
painter.setPen(QPen(QColor("#cdd6f4"), 2))
painter.setBrush(Qt.NoBrush)
painter.drawArc(rect, start_deg_16, span_deg_16)
# ── Offset preview (live preview from OffsetDialog) ──
if self._offset_preview_active and self._offset_preview_points:
preview = self._offset_preview_points
painter.setPen(QPen(QColor("#f9e2af"), 2, Qt.DashLine))
painter.setBrush(Qt.NoBrush)
for i in range(len(preview)):
p1 = self._world_to_screen(
QPoint(int(round(preview[i][0])), int(round(preview[i][1])))
)
p2 = self._world_to_screen(
QPoint(int(round(preview[(i + 1) % len(preview)][0])),
int(round(preview[(i + 1) % len(preview)][1])))
)
painter.drawLine(p1, p2)
# ── 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))
if self._draw_buffer and self._dynamic_line_end and self._mode == "arc":
if len(self._draw_buffer) == 1:
# Click 1 done: show circle preview (center to cursor)
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))
elif len(self._draw_buffer) == 2:
# Click 2 done: show arc from start to cursor
cw = self._draw_buffer[0] # center world
sw = self._draw_buffer[1] # start world
ew = self._dynamic_line_end # end world (mouse)
cx_f, cy_f = cw.x(), cw.y()
sx_f, sy_f = sw.x(), sw.y()
arc_radius = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2)
sr = int(arc_radius * self._zoom)
sc = self._world_to_screen(QPoint(int(round(cx_f)), int(round(cy_f))))
# Use accumulated sweep so the arc follows the mouse
# smoothly even past 180° (no shortest-path flip).
start_angle = math.atan2(sy_f - cy_f, sx_f - cx_f)
sweep = self._arc_accum_sweep
start_deg_16 = int(math.degrees(start_angle) * 16)
span_deg_16 = int(math.degrees(sweep) * 16)
rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2)
painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
painter.setBrush(Qt.NoBrush)
painter.drawArc(rect, start_deg_16, span_deg_16)
# Also draw a helper line from center to cursor so the
# user can see the sweep angle being defined
painter.setPen(QPen(QColor("#a6e3a1"), 1, Qt.DotLine))
painter.drawLine(sc, self._world_to_screen(ew))
# ── Slot preview ──
if self._draw_buffer and self._dynamic_line_end and self._mode == "slot":
if len(self._draw_buffer) == 1:
# Click 1 done: preview centerline
c1 = self._world_to_screen(self._draw_buffer[0])
c2 = self._world_to_screen(self._dynamic_line_end)
painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
painter.drawLine(c1, c2)
elif len(self._draw_buffer) == 2:
# Click 2 done: preview full slot outline
c1 = self._draw_buffer[0]
c2 = self._draw_buffer[1]
cursor = self._dynamic_line_end
dx_c = c2.x() - c1.x()
dy_c = c2.y() - c1.y()
L = math.sqrt(dx_c * dx_c + dy_c * dy_c)
if L > 0:
r = abs((cursor.x() - c1.x()) * dy_c
- (cursor.y() - c1.y()) * dx_c) / L
if r < 0.5:
r = 0.5
perp_x = -dy_c / L
perp_y = dx_c / L
def sc(pt):
return self._world_to_screen(QPoint(int(round(pt[0])), int(round(pt[1]))))
c1s = (c1.x(), c1.y())
c2s = (c2.x(), c2.y())
t1 = (c1.x() + r * perp_x, c1.y() + r * perp_y)
b1 = (c1.x() - r * perp_x, c1.y() - r * perp_y)
t2 = (c2.x() + r * perp_x, c2.y() + r * perp_y)
b2 = (c2.x() - r * perp_x, c2.y() - r * perp_y)
painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
painter.setBrush(Qt.NoBrush)
# Bottom line
painter.drawLine(sc(b1), sc(b2))
# Top line
painter.drawLine(sc(t2), sc(t1))
# Arc 1 (center C1, from t1 to b1, CCW exterior)
sa1 = math.atan2(t1[1] - c1s[1], t1[0] - c1s[0])
sweep1 = math.pi
n_seg = 16
for i in range(n_seg):
a1 = sa1 + (i / n_seg) * sweep1
a2 = sa1 + ((i + 1) / n_seg) * sweep1
p1 = (c1s[0] + r * math.cos(a1), c1s[1] + r * math.sin(a1))
p2 = (c1s[0] + r * math.cos(a2), c1s[1] + r * math.sin(a2))
painter.drawLine(sc(p1), sc(p2))
# Arc 2 (center C2, from b2 to t2)
sa2 = math.atan2(b2[1] - c2s[1], b2[0] - c2s[0])
sweep2 = math.pi
for i in range(n_seg):
a1 = sa2 + (i / n_seg) * sweep2
a2 = sa2 + ((i + 1) / n_seg) * sweep2
p1 = (c2s[0] + r * math.cos(a1), c2s[1] + r * math.sin(a1))
p2 = (c2s[0] + r * math.cos(a2), c2s[1] + r * math.sin(a2))
painter.drawLine(sc(p1), sc(p2))
# ── 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