- added contrain context menu

- improved line pickability.
This commit is contained in:
bklronin
2026-07-19 11:18:10 +02:00
parent d6e829c23d
commit 2037106c0d
+493 -118
View File
@@ -6,11 +6,10 @@ import logging
import math import math
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect from PySide6.QtCore import Qt, Signal, QPoint, QPointF, QRect
from PySide6.QtGui import ( from PySide6.QtGui import (
QBrush, QBrush,
QColor, QColor,
QCursor,
QFont, QFont,
QFontMetrics, QFontMetrics,
QPainter, QPainter,
@@ -20,6 +19,8 @@ from PySide6.QtGui import (
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QInputDialog, QInputDialog,
QMenu,
QMessageBox,
QWidget, QWidget,
) )
@@ -31,7 +32,9 @@ logger = logging.getLogger(__name__)
def _project_face_to_uv( def _project_face_to_uv(
face: Any, face: Any,
workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]], workplane: Tuple[
Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]
],
) -> List[List[Tuple[float, float]]]: ) -> List[List[Tuple[float, float]]]:
"""Project a planar ``TopoDS_Face``'s boundary edges into the UV frame. """Project a planar ``TopoDS_Face``'s boundary edges into the UV frame.
@@ -48,9 +51,9 @@ def _project_face_to_uv(
from OCP.GeomAbs import GeomAbs_Line from OCP.GeomAbs import GeomAbs_Line
from OCP.gp import gp_Pnt from OCP.gp import gp_Pnt
origin = np.asarray(workplane[0], dtype=float) # (x,y,z) origin = np.asarray(workplane[0], dtype=float) # (x,y,z)
normal = np.asarray(workplane[1], dtype=float) # plane normal normal = np.asarray(workplane[1], dtype=float) # plane normal
x_dir = np.asarray(workplane[2], dtype=float) # in-plane x axis x_dir = np.asarray(workplane[2], dtype=float) # in-plane x axis
x_dir = x_dir / np.linalg.norm(x_dir) x_dir = x_dir / np.linalg.norm(x_dir)
normal = normal / np.linalg.norm(normal) normal = normal / np.linalg.norm(normal)
y_dir = np.cross(normal, x_dir) y_dir = np.cross(normal, x_dir)
@@ -107,17 +110,25 @@ class Sketch2DWidget(QWidget):
self._points: List[OCCSketchEntity] = [] self._points: List[OCCSketchEntity] = []
self._lines: List[Tuple[OCCSketchEntity, OCCSketchEntity]] = [] self._lines: List[Tuple[OCCSketchEntity, OCCSketchEntity]] = []
self._circles: List[Tuple[OCCSketchEntity, float]] = [] self._circles: List[Tuple[OCCSketchEntity, float]] = []
self._arcs: List[Tuple[OCCSketchEntity, float, OCCSketchEntity, OCCSketchEntity, float]] = [] # (center, radius, start_point, end_point, sweep) 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) # Accumulated sweep tracking during arc draw (smoothly follows cursor)
self._arc_accum_sweep: float = 0.0 self._arc_accum_sweep: float = 0.0
self._arc_prev_angle: Optional[float] = None self._arc_prev_angle: Optional[float] = None
self._draw_buffer: List[QPoint] = [] self._draw_buffer: List[QPoint] = []
self._hovered_point: Optional[QPoint] = None self._hovered_point: Optional[QPoint] = None
self._hovered_point_entity: Optional[OCCSketchEntity] = None # point entity under cursor (for Delete) self._hovered_point_entity: Optional[OCCSketchEntity] = (
None # point entity under cursor (for Delete)
)
self._hovered_line: Optional[Tuple[QPoint, QPoint]] = None self._hovered_line: Optional[Tuple[QPoint, QPoint]] = None
self._hovered_line_entity: Optional[OCCSketchEntity] = None # line entity under cursor (for Delete) self._hovered_line_entity: Optional[OCCSketchEntity] = (
self._hovered_constraint_idx: int = -1 # constraint-log index hovered over its tag (for Delete) 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._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._hovered_face: Any = None # detected face dict (see OCCSketch.detect_faces) or None
self._selected_face: Any = None self._selected_face: Any = None
@@ -254,11 +265,7 @@ class Sketch2DWidget(QWidget):
# Snapshot the entity list before modifying (add_point adds to # Snapshot the entity list before modifying (add_point adds to
# _entities, which would change dict size mid-iteration). # _entities, which would change dict size mid-iteration).
for eid, entity in list(self._sketch._entities.items()): for eid, entity in list(self._sketch._entities.items()):
if ( if entity.is_external and entity.entity_type == "point" and entity.geometry is not None:
entity.is_external
and entity.entity_type == "point"
and entity.geometry is not None
):
x, y = entity.geometry x, y = entity.geometry
new_pt = self._sketch.add_point(x, y) new_pt = self._sketch.add_point(x, y)
new_pt.is_construction = False new_pt.is_construction = False
@@ -379,9 +386,7 @@ class Sketch2DWidget(QWidget):
imported += len(lines) imported += len(lines)
except Exception as exc: except Exception as exc:
logger.debug("underlay polyline import failed: %s", exc) logger.debug("underlay polyline import failed: %s", exc)
logger.info( logger.info("Imported %d construction-line segments from source face", imported)
"Imported %d construction-line segments from source face", imported
)
# Pull the new external entities into the UI lists so they're # Pull the new external entities into the UI lists so they're
# snap/hover/paint targets. # snap/hover/paint targets.
self._rebuild_from_sketch() self._rebuild_from_sketch()
@@ -654,6 +659,15 @@ class Sketch2DWidget(QWidget):
int(self.height() / 2 - pos.y() * self._zoom + self._offset.y()), int(self.height() / 2 - pos.y() * self._zoom + self._offset.y()),
) )
def _pick_tolerance_world(self, min_pixels: float = 10.0) -> float:
"""Screen-pixel pick tolerance converted to world coordinates.
Keeps the clickable zone roughly constant in screen space regardless
of zoom level. A small floor (0.5 world units) prevents the
tolerance from vanishing when fully zoomed out.
"""
return max(0.5, min_pixels / max(self._zoom, 0.01))
# ─── Snapping ───────────────────────────────────────────────────────── # ─── Snapping ─────────────────────────────────────────────────────────
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]: def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
@@ -674,7 +688,9 @@ class Sketch2DWidget(QWidget):
nearest = point nearest = point
return nearest return nearest
def _find_nearest_point_entity(self, pos: QPoint, max_distance: int = 15) -> Optional[OCCSketchEntity]: def _find_nearest_point_entity(
self, pos: QPoint, max_distance: int = 15
) -> Optional[OCCSketchEntity]:
"""Find the nearest point entity to a screen position. """Find the nearest point entity to a screen position.
External (underlay) points are pickable when the underlay is visible External (underlay) points are pickable when the underlay is visible
@@ -877,11 +893,13 @@ class Sketch2DWidget(QWidget):
if entity.geometry: if entity.geometry:
x, y = entity.geometry x, y = entity.geometry
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2) dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
if dist < 5: # tolerance in world coords if dist < self._pick_tolerance_world(10):
return entity return entity
return None return None
def _get_line_entity_at(self, world_pos: QPoint) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]: def _get_line_entity_at(
self, world_pos: QPoint
) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
"""Find a line near the given world position. """Find a line near the given world position.
External (underlay) lines are pickable when the underlay is visible External (underlay) lines are pickable when the underlay is visible
@@ -910,15 +928,15 @@ class Sketch2DWidget(QWidget):
# Compute perpendicular distance to the infinite line. # Compute perpendicular distance to the infinite line.
line_len_sq = dx * dx + dy * dy line_len_sq = dx * dx + dy * dy
# Perpendicular distance: |(P - P1) × (P2 - P1)| / |P2 - P1| # 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) 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) is_cl = line_ent is not None and self._is_centerline(line_ent)
if is_cl: if is_cl:
# Centerlines are infinite reference axes: use the # Centerlines are infinite reference axes: use the
# perpendicular distance directly (no segment clamping). # perpendicular distance directly (no segment clamping).
# Zoom-adjusted tolerance: at least 20 pixels in screen space tol = self._pick_tolerance_world(12)
# 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: if perp_dist < tol:
return (p1_ent, p2_ent) return (p1_ent, p2_ent)
else: else:
@@ -928,8 +946,15 @@ class Sketch2DWidget(QWidget):
t = max(0.0, min(1.0, t)) t = max(0.0, min(1.0, t))
proj_x = x1 + t * dx proj_x = x1 + t * dx
proj_y = y1 + t * dy proj_y = y1 + t * dy
seg_dist = math.sqrt((world_pos.x() - proj_x)**2 + (world_pos.y() - proj_y)**2) seg_dist = math.sqrt(
if seg_dist < 10: # tolerance (world_pos.x() - proj_x) ** 2 + (world_pos.y() - proj_y) ** 2
)
# External (underlay) lines get a tighter tolerance so
# they don't steal picks from user-drawn sketch lines.
tol = (
self._pick_tolerance_world(8) if is_ext else self._pick_tolerance_world(14)
)
if seg_dist < tol:
return (p1_ent, p2_ent) return (p1_ent, p2_ent)
return None return None
@@ -965,7 +990,9 @@ class Sketch2DWidget(QWidget):
return self._sketch._entities.get(sid), self._sketch._entities.get(eid2) return self._sketch._entities.get(sid), self._sketch._entities.get(eid2)
return None, None return None, None
def _find_line_entity_for_line_xy(self, p1_xy: Tuple[float, float], p2_xy: Tuple[float, float]) -> Optional[OCCSketchEntity]: 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.""" """Find the OCCSketchEntity for a line defined by its endpoint tuples."""
for eid, start_end in self._sketch._lines.items(): for eid, start_end in self._sketch._lines.items():
sid, eid2 = start_end sid, eid2 = start_end
@@ -974,15 +1001,25 @@ class Sketch2DWidget(QWidget):
if s_ent and e_ent and s_ent.geometry and e_ent.geometry: if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
sx, sy = s_ent.geometry sx, sy = s_ent.geometry
ex, ey = e_ent.geometry ex, ey = e_ent.geometry
if (abs(sx - p1_xy[0]) < 0.1 and abs(sy - p1_xy[1]) < 0.1 and if (
abs(ex - p2_xy[0]) < 0.1 and abs(ey - p2_xy[1]) < 0.1): 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 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 if (
abs(ex - p1_xy[0]) < 0.1 and abs(ey - p1_xy[1]) < 0.1): 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 s_ent
return None return None
def _get_constraints_for_line(self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity) -> List[str]: def _get_constraints_for_line(
self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
) -> List[str]:
"""Get constraint labels from both endpoint entities.""" """Get constraint labels from both endpoint entities."""
return list(set(p1_ent.constraints + p2_ent.constraints)) return list(set(p1_ent.constraints + p2_ent.constraints))
@@ -1040,7 +1077,9 @@ class Sketch2DWidget(QWidget):
# tag is dropped rather than the paint event crashing. # tag is dropped rather than the paint event crashing.
logger.debug( logger.debug(
"_point_world: could not round geometry for entity %s (%r, %r)", "_point_world: could not round geometry for entity %s (%r, %r)",
pid, x, y, pid,
x,
y,
) )
return None return None
@@ -1091,46 +1130,57 @@ class Sketch2DWidget(QWidget):
label = "" label = ""
if ctype == "horizontal": if ctype == "horizontal":
anchor = self._line_world_mid(ids[0]); label = "hrz" anchor = self._line_world_mid(ids[0])
label = "hrz"
elif ctype == "vertical": elif ctype == "vertical":
anchor = self._line_world_mid(ids[0]); label = "vrt" anchor = self._line_world_mid(ids[0])
label = "vrt"
elif ctype == "midpoint": elif ctype == "midpoint":
anchor = self._line_world_mid(ids[1]); label = "mid" anchor = self._line_world_mid(ids[1])
label = "mid"
elif ctype == "distance": elif ctype == "distance":
# Distance may be point-to-point OR point-to-line (e.g. # Distance may be point-to-point OR point-to-line (e.g.
# point-on-line coincident surfaces as a coincident entry; # point-on-line coincident surfaces as a coincident entry;
# a future point-to-line distance would do the same). # a future point-to-line distance would do the same).
# Use _entity_anchor so a line id routes to the line # Use _entity_anchor so a line id routes to the line
# midpoint instead of crashing on round(<tuple>). # midpoint instead of crashing on round(<tuple>).
a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1]) 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. # NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
if a is not None and b is not None: if a is not None and b is not None:
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2) anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
label = f"dst {params[0]:.1f}" if params else "dst" label = f"dst {params[0]:.1f}" if params else "dst"
elif ctype == "parallel": elif ctype == "parallel":
anchor = self._line_world_mid(ids[0]); label = "par" anchor = self._line_world_mid(ids[0])
label = "par"
elif ctype == "perpendicular": elif ctype == "perpendicular":
anchor = self._line_world_mid(ids[0]); label = "perp" anchor = self._line_world_mid(ids[0])
label = "perp"
elif ctype == "angle": elif ctype == "angle":
anchor = self._line_world_mid(ids[0]) anchor = self._line_world_mid(ids[0])
label = f"ang {params[0]:.0f}" if params else "ang" label = f"ang {params[0]:.0f}" if params else "ang"
elif ctype == "equal": elif ctype == "equal":
anchor = self._line_world_mid(ids[0]); label = "eql" anchor = self._line_world_mid(ids[0])
label = "eql"
elif ctype == "coincident": elif ctype == "coincident":
# Coincident can be point-to-point OR point-on-line (when # Coincident can be point-to-point OR point-on-line (when
# a line is one of the targets). Use _entity_anchor so # a line is one of the targets). Use _entity_anchor so
# the line's midpoint is used as a fallback anchor when # the line's midpoint is used as a fallback anchor when
# one of the ids is a line. # one of the ids is a line.
a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1]) a = self._entity_anchor(ids[0])
b = self._entity_anchor(ids[1])
if a is not None and b is not None: if a is not None and b is not None:
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2) anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
label = "coin" label = "coin"
elif ctype == "symmetric": elif ctype == "symmetric":
anchor = self._line_world_mid(ids[2]); label = "sym" anchor = self._line_world_mid(ids[2])
label = "sym"
elif ctype == "fixed": elif ctype == "fixed":
anchor = self._point_world(ids[0]); label = "fix" anchor = self._point_world(ids[0])
label = "fix"
elif ctype == "equal_radius": elif ctype == "equal_radius":
anchor = self._point_world(ids[0]); label = "eqr" anchor = self._point_world(ids[0])
label = "eqr"
else: else:
continue continue
@@ -1157,7 +1207,10 @@ class Sketch2DWidget(QWidget):
# other tags as normal. # other tags as normal.
logger.debug( logger.debug(
"Skipped constraint tag #%s (%s) due to %s: %s", "Skipped constraint tag #%s (%s) due to %s: %s",
idx, entry.get("type"), type(exc).__name__, exc, idx,
entry.get("type"),
type(exc).__name__,
exc,
) )
continue continue
return tags return tags
@@ -1242,7 +1295,7 @@ class Sketch2DWidget(QWidget):
if c_ent.geometry and r > 0: if c_ent.geometry and r > 0:
cx, cy = c_ent.geometry cx, cy = c_ent.geometry
d = math.sqrt((world.x() - cx) ** 2 + (world.y() - cy) ** 2) d = math.sqrt((world.x() - cx) ** 2 + (world.y() - cy) ** 2)
if abs(d - r) < 8: if abs(d - r) < self._pick_tolerance_world(16):
return c_ent, QPoint(int(round(cx)), int(round(cy))) return c_ent, QPoint(int(round(cx)), int(round(cy)))
return None return None
@@ -1271,6 +1324,162 @@ class Sketch2DWidget(QWidget):
self.update() self.update()
return ok return ok
# ─── Context menu: loop detection & constraint presets ─────────────────
def _find_loop_from_point(self, point_entity: OCCSketchEntity) -> Optional[List[int]]:
"""Trace a 4-sided closed loop starting from *point_entity*.
Returns ``[p1_id, l1_id, p2_id, l2_id, p3_id, l3_id, p4_id, l4_id]``
(alternating point/line ids, 8 elements for a quadrilateral) if found,
otherwise ``None``.
"""
if self._sketch is None:
return None
start_id = point_entity.id
lines = self._sketch._lines # line_id -> (p1_id, p2_id)
# Collect all lines touching the start point and the 'other' endpoint.
connected: List[Tuple[int, int]] = []
for lid, (p1, p2) in lines.items():
if p1 == start_id:
connected.append((lid, p2))
elif p2 == start_id:
connected.append((lid, p1))
if len(connected) < 2:
return None
# DFS from each first edge looking for a 4-edge cycle back to start.
for first_lid, second_pid in connected:
path: List[int] = [start_id, first_lid, second_pid]
if self._trace_loop_recursive(path, lines, start_id, depth=1):
# Strip the duplicate start point at the end so the path is
# exactly [p1,l1,p2,l2,p3,l3,p4,l4] (8 elements).
return path[:-1]
return None
@staticmethod
def _trace_loop_recursive(
path: List[int],
lines: Dict[int, Tuple[int, int]],
start_id: int,
depth: int,
) -> bool:
"""Recursively extend *path* looking for a 4-edge cycle.
``path`` alternates point-id, line-id, point-id, …
Returns ``True`` when a valid 4-edge cycle back to *start_id* is found.
"""
if depth >= 4:
return path[-1] == start_id
current_pid = path[-1]
prev_lid = path[-2]
for lid, (p1, p2) in lines.items():
if lid == prev_lid:
continue
if p1 == current_pid:
next_pid = p2
elif p2 == current_pid:
next_pid = p1
else:
continue
path.append(lid)
path.append(next_pid)
if Sketch2DWidget._trace_loop_recursive(path, lines, start_id, depth + 1):
return True
path.pop()
path.pop()
return False
def _classify_loop_lines(
self, loop: List[int]
) -> Tuple[List[OCCSketchEntity], List[OCCSketchEntity]]:
"""Classify the four lines of *loop* as horizontal-ish / vertical-ish.
Uses an angle heuristic: lines within ±45° of the u-axis are
"horizontal", the other two are "vertical".
Returns ``(horiz_entities, vert_entities)``.
"""
horiz: List[OCCSketchEntity] = []
vert: List[OCCSketchEntity] = []
if self._sketch is None:
return horiz, vert
for i in range(1, len(loop), 2): # line ids at indices 1, 3, 5, 7
lid = loop[i]
ent = self._sketch._entities.get(lid)
if ent is None:
continue
p1_id, p2_id = self._sketch._lines[lid]
p1_ent = self._sketch._entities.get(p1_id)
p2_ent = self._sketch._entities.get(p2_id)
if not p1_ent or not p2_ent or not p1_ent.geometry or not p2_ent.geometry:
continue
x1, y1 = p1_ent.geometry
x2, y2 = p2_ent.geometry
angle = abs(math.atan2(y2 - y1, x2 - x1))
if angle < math.pi / 4 or angle > 3 * math.pi / 4:
horiz.append(ent)
else:
vert.append(ent)
return horiz, vert
def _constrain_loop(self, loop: List[int], variant: str) -> None:
"""Apply constraint preset *variant* to the 4-sided *loop*.
Variants:
- ``"square"`` — H + V + equal-length
- ``"rectangle"`` — H + V
- ``"rectangle_construction"`` — H + V + diagonal construction lines
- ``"trapezoid"`` — parallel pair on the horizontal lines only
"""
if self._sketch is None:
return
sketch = self._sketch
horiz, vert = self._classify_loop_lines(loop)
if len(horiz) != 2 or len(vert) != 2:
logger.warning("Loop classification failed cannot constrain")
return
if self._undo_manager:
self._undo_manager.save_state()
# Point entities in loop order (indices 0, 2, 4, 6)
point_ents = [sketch._entities.get(loop[i]) for i in range(0, len(loop), 2)]
if variant == "trapezoid":
# Only parallel constraint on the horizontal pair.
sketch.constrain_parallel(horiz[0], horiz[1])
else:
# Square / Rectangle / Rectangle+Construction all start with H + V.
sketch.constrain_horizontal(horiz[0])
sketch.constrain_horizontal(horiz[1])
sketch.constrain_vertical(vert[0])
sketch.constrain_vertical(vert[1])
if variant == "square":
# Force one adjacent pair equal → all four equal via H + V.
sketch.constrain_equal_length(horiz[0], vert[0])
elif variant == "rectangle_construction":
# Add the two diagonals as construction lines.
p1, p2, p3, p4 = point_ents
if p1 and p2 and p3 and p4:
d1 = sketch.add_line(p1, p3)
d2 = sketch.add_line(p2, p4)
d1.is_construction = True
d2.is_construction = True
self._solve_and_sync()
sketch_updated = getattr(self, "sketch_updated", None)
if sketch_updated is not None:
sketch_updated.emit()
self.update()
# ─── Mouse events ───────────────────────────────────────────────────── # ─── Mouse events ─────────────────────────────────────────────────────
def mousePressEvent(self, event): def mousePressEvent(self, event):
@@ -1283,14 +1492,16 @@ class Sketch2DWidget(QWidget):
return return
if event.button() == Qt.RightButton: if event.button() == Qt.RightButton:
self._mode = None # Only clear drawing-state, preserve any point selection for the
self._draw_buffer = [] # context menu (contextMenuEvent is invoked by Qt after this).
self._dynamic_line_end = None if self._mode in ("line", "rectangle", "circle", "arc", "slot"):
self._selected_entities = [] self._mode = None
self._arc_accum_sweep = 0.0 self._draw_buffer = []
self._arc_prev_angle = None self._dynamic_line_end = None
self.constrain_done.emit() self._arc_accum_sweep = 0.0
self.update() self._arc_prev_angle = None
self.constrain_done.emit()
# Do NOT return — let Qt deliver the contextMenuEvent.
return return
if event.button() == Qt.LeftButton: if event.button() == Qt.LeftButton:
@@ -1298,9 +1509,12 @@ class Sketch2DWidget(QWidget):
if self._mode in ("select", None) and self._sketch: if self._mode in ("select", None) and self._sketch:
# ① Tight point check (4 px) — a deliberate grab on a point. # ① Tight point check (4 px) — a deliberate grab on a point.
tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4) tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4)
if (tight_ent is not None and tight_ent.geometry if (
and not self._is_external(tight_ent) tight_ent is not None
and not self._is_centerline(tight_ent)): and tight_ent.geometry
and not self._is_external(tight_ent)
and not self._is_centerline(tight_ent)
):
x, y = tight_ent.geometry x, y = tight_ent.geometry
moving = self._collect_connected_points(tight_ent) moving = self._collect_connected_points(tight_ent)
orig: Dict[int, Tuple[float, float]] = {} orig: Dict[int, Tuple[float, float]] = {}
@@ -1324,9 +1538,11 @@ class Sketch2DWidget(QWidget):
# selection zone. External (underlay) entities are fixed # selection zone. External (underlay) entities are fixed
# references and can't be dragged. # references and can't be dragged.
target = self._find_move_target(event.pos()) target = self._find_move_target(event.pos())
if (target is not None if (
and not self._is_external(target[0]) target is not None
and not self._is_centerline(target[0])): and not self._is_external(target[0])
and not self._is_centerline(target[0])
):
anchor_ent, anchor_world = target anchor_ent, anchor_world = target
moving = self._collect_connected_points(anchor_ent) moving = self._collect_connected_points(anchor_ent)
orig: Dict[int, Tuple[float, float]] = {} orig: Dict[int, Tuple[float, float]] = {}
@@ -1362,7 +1578,8 @@ class Sketch2DWidget(QWidget):
return return
snapped_pos = self._apply_all_snaps( snapped_pos = self._apply_all_snaps(
event.pos(), self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None event.pos(),
self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None,
) )
world_snapped = ( world_snapped = (
self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
@@ -1410,12 +1627,14 @@ class Sketch2DWidget(QWidget):
return return
# Handle element move (grab all points of the element, snap the anchor) # 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: 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) anchor_orig_screen = self._world_to_screen(self._move_anchor_orig)
exclude_ids = set(e.id for e in self._moving_points) exclude_ids = set(e.id for e in self._moving_points)
snapped_screen = self._apply_move_snaps( snapped_screen = self._apply_move_snaps(event.pos(), anchor_orig_screen, exclude_ids)
event.pos(), anchor_orig_screen, exclude_ids
)
target_world = self._screen_to_world(snapped_screen) target_world = self._screen_to_world(snapped_screen)
dx = target_world.x() - self._move_anchor_orig.x() dx = target_world.x() - self._move_anchor_orig.x()
dy = target_world.y() - self._move_anchor_orig.y() dy = target_world.y() - self._move_anchor_orig.y()
@@ -1442,9 +1661,11 @@ class Sketch2DWidget(QWidget):
ew = self._dynamic_line_end ew = self._dynamic_line_end
cx_f, cy_f = cw.x(), cw.y() cx_f, cy_f = cw.x(), cw.y()
sx_f, sy_f = sw.x(), sw.y() sx_f, sy_f = sw.x(), sw.y()
r = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2) if ( r = (
(sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2 > 0 math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2)
) else 1.0 if ((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2 > 0)
else 1.0
)
dx = ew.x() - cx_f dx = ew.x() - cx_f
dy = ew.y() - cy_f dy = ew.y() - cy_f
d = math.sqrt(dx * dx + dy * dy) d = math.sqrt(dx * dx + dy * dy)
@@ -1541,7 +1762,7 @@ class Sketch2DWidget(QWidget):
if c_ent.geometry and r > 0: if c_ent.geometry and r > 0:
cx, cy = c_ent.geometry cx, cy = c_ent.geometry
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2) d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
if abs(d - r) < 8: if abs(d - r) < self._pick_tolerance_world(16):
over_circle = True over_circle = True
break break
self._hovered_face = None self._hovered_face = None
@@ -1595,7 +1816,9 @@ class Sketch2DWidget(QWidget):
else: else:
# Auto-constrain: point snap during move → coincident # Auto-constrain: point snap during move → coincident
if self._snap_point_target is not None and self._move_anchor is not None: 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._sketch.constrain_coincident(
self._move_anchor, self._snap_point_target
)
self._solve_and_sync() self._solve_and_sync()
# Snap modes are honoured during the move (see _apply_move_snaps # Snap modes are honoured during the move (see _apply_move_snaps
# in mouseMoveEvent), so the committed positions are already snapped. # in mouseMoveEvent), so the committed positions are already snapped.
@@ -1614,7 +1837,7 @@ class Sketch2DWidget(QWidget):
delta = event.angleDelta().y() delta = event.angleDelta().y()
factor = 1.1 if delta > 0 else 0.9 factor = 1.1 if delta > 0 else 0.9
self._zoom *= factor self._zoom *= factor
self._zoom = max(0.1, min(10.0, self._zoom)) self._zoom = max(0.05, min(200.0, self._zoom))
self.update() self.update()
def keyPressEvent(self, event): def keyPressEvent(self, event):
@@ -1632,8 +1855,9 @@ class Sketch2DWidget(QWidget):
return return
# Redo: Ctrl+Shift+Z or Ctrl+Y # Redo: Ctrl+Shift+Z or Ctrl+Y
if (event.key() == Qt.Key_Z and event.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier)) or \ if (
(event.key() == Qt.Key_Y and event.modifiers() & Qt.ControlModifier): event.key() == Qt.Key_Z and event.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier)
) or (event.key() == Qt.Key_Y and event.modifiers() & Qt.ControlModifier):
if self._undo_manager and self._undo_manager.can_redo: if self._undo_manager and self._undo_manager.can_redo:
self._undo_manager.redo() self._undo_manager.redo()
self._rebuild_from_sketch() self._rebuild_from_sketch()
@@ -1648,7 +1872,11 @@ class Sketch2DWidget(QWidget):
# line > point. Works in Move mode or when no tool is selected; ignored # line > point. Works in Move mode or when no tool is selected; ignored
# while actively drawing so an in-progress line isn't clobbered. # while actively drawing so an in-progress line isn't clobbered.
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace): 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._mode in ("select", None)
and not self._draw_buffer
and self._sketch is not None
):
if self._hovered_constraint_idx >= 0: if self._hovered_constraint_idx >= 0:
self._delete_hovered_constraint() self._delete_hovered_constraint()
event.accept() event.accept()
@@ -1664,7 +1892,11 @@ class Sketch2DWidget(QWidget):
# C key toggles the hovered line between normal and construction mode. # C key toggles the hovered line between normal and construction mode.
if event.key() == Qt.Key_C: 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._mode in ("select", None)
and not self._draw_buffer
and self._sketch is not None
):
if self._hovered_line_entity is not None: if self._hovered_line_entity is not None:
self._toggle_hovered_line_construction() self._toggle_hovered_line_construction()
event.accept() event.accept()
@@ -1672,6 +1904,83 @@ class Sketch2DWidget(QWidget):
super().keyPressEvent(event) super().keyPressEvent(event)
def contextMenuEvent(self, event):
"""Right-click context menu — constraint presets for quadrilateral loops."""
if self._sketch is None:
return
world_pos = self._screen_to_world(event.pos())
point_entity = self._get_point_entity_at(world_pos)
# Only offer the constraint menu when clicking directly on a sketch
# point that belongs to a closed 4-sided loop.
if point_entity is None or self._is_external(point_entity):
return
loop = self._find_loop_from_point(point_entity)
menu = QMenu(self)
constraint_actions = []
if loop is not None and len(loop) == 8:
horiz, vert = self._classify_loop_lines(loop)
valid = len(horiz) == 2 and len(vert) == 2
a_square = menu.addAction("Constrain as Square")
a_square.setEnabled(valid)
constraint_actions.append((a_square, "square"))
a_rect = menu.addAction("Constrain as Rectangle")
a_rect.setEnabled(valid)
constraint_actions.append((a_rect, "rectangle"))
a_rect_c = menu.addAction("Constrain as Rectangle + Construction")
a_rect_c.setEnabled(valid)
constraint_actions.append((a_rect_c, "rectangle_construction"))
a_trap = menu.addAction("Constrain as Trapezoid")
a_trap.setEnabled(valid)
constraint_actions.append((a_trap, "trapezoid"))
menu.addSeparator()
elif loop is not None:
# Loop detected but not a quadrilateral → inform the user.
QMessageBox.information(
self,
"Not a Quadrilateral",
"The selected point belongs to a shape with "
f"{len(loop) // 2} sides.\n"
"Only four-sided loops can be constrained with these presets.",
)
return
# else: no loop at all — fall through to the basic point actions.
fix_action = menu.addAction("Fix Point")
delete_action = menu.addAction("Delete Point")
action = menu.exec_(event.globalPos())
if action is None:
return
for act, variant in constraint_actions:
if action is act:
assert loop is not None # guaranteed by constraint_actions population
self._constrain_loop(loop, variant)
return
if action == fix_action:
if self._undo_manager:
self._undo_manager.save_state()
self._sketch.constrain_fixed(point_entity)
self._solve_and_sync()
self.update()
elif action == delete_action:
if self._undo_manager:
self._undo_manager.save_state()
self._sketch.delete_point(point_entity)
self._rebuild_from_sketch()
self._solve_and_sync()
self.update()
def _delete_hovered_constraint(self): def _delete_hovered_constraint(self):
"""Delete the hovered constraint (by log index) and recompute the rest.""" """Delete the hovered constraint (by log index) and recompute the rest."""
idx = self._hovered_constraint_idx idx = self._hovered_constraint_idx
@@ -1779,9 +2088,7 @@ class Sketch2DWidget(QWidget):
# Flip the construction flag on the line entity itself. # Flip the construction flag on the line entity itself.
new_val = not line_ent.is_construction new_val = not line_ent.is_construction
line_ent.is_construction = new_val line_ent.is_construction = new_val
logger.info( logger.info(f"Toggled line {line_ent.id} construction -> {new_val}")
f"Toggled line {line_ent.id} construction -> {new_val}"
)
self._hovered_line = None self._hovered_line = None
self._hovered_line_entity = None self._hovered_line_entity = None
self._solve_and_sync() self._solve_and_sync()
@@ -2080,6 +2387,7 @@ class Sketch2DWidget(QWidget):
# Degenerate: centers on top of each other → draw a circle # Degenerate: centers on top of each other → draw a circle
# using the midpoint-to-cursor distance as radius. # using the midpoint-to-cursor distance as radius.
import math as _m import math as _m
r_fallback = _m.hypot(pos.x() - c1_pos.x(), pos.y() - c1_pos.y()) r_fallback = _m.hypot(pos.x() - c1_pos.x(), pos.y() - c1_pos.y())
if r_fallback > 0: if r_fallback > 0:
self._sketch.add_circle(c1_ent, r_fallback) self._sketch.add_circle(c1_ent, r_fallback)
@@ -2093,8 +2401,8 @@ class Sketch2DWidget(QWidget):
# Perpendicular distance from cursor to the centerline C1↔C2. # Perpendicular distance from cursor to the centerline C1↔C2.
import math as _m import math as _m
r = abs((pos.x() - c1_pos.x()) * dy_c
- (pos.y() - c1_pos.y()) * dx_c) / _m.sqrt(L_sq) r = abs((pos.x() - c1_pos.x()) * dy_c - (pos.y() - c1_pos.y()) * dx_c) / _m.sqrt(L_sq)
if r < 0.5: if r < 0.5:
r = 0.5 # minimum radius so the slot doesn't collapse r = 0.5 # minimum radius so the slot doesn't collapse
@@ -2113,9 +2421,9 @@ class Sketch2DWidget(QWidget):
# Top / bottom are relative to the perpendicular direction. # Top / bottom are relative to the perpendicular direction.
# Perp points "up" from the centreline; -perp points "down". # Perp points "up" from the centreline; -perp points "down".
t1 = corner(c1_pos.x(), c1_pos.y(), 1) # C1 + perp t1 = corner(c1_pos.x(), c1_pos.y(), 1) # C1 + perp
b1 = 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 t2 = corner(c2_pos.x(), c2_pos.y(), 1) # C2 + perp
b2 = corner(c2_pos.x(), c2_pos.y(), -1) # C2 - perp b2 = corner(c2_pos.x(), c2_pos.y(), -1) # C2 - perp
# Create point entities # Create point entities
@@ -2137,9 +2445,7 @@ class Sketch2DWidget(QWidget):
for corner_pt in (pt1, pb1, pt2, pb2): for corner_pt in (pt1, pb1, pt2, pb2):
if corner_pt.geometry is not None: if corner_pt.geometry is not None:
cgx, cgy = corner_pt.geometry cgx, cgy = corner_pt.geometry
snap_target = self._get_point_entity_at( snap_target = self._get_point_entity_at(QPoint(int(round(cgx)), int(round(cgy))))
QPoint(int(round(cgx)), int(round(cgy)))
)
if snap_target is not None and snap_target.id != corner_pt.id: if snap_target is not None and snap_target.id != corner_pt.id:
self._sketch.constrain_coincident(corner_pt, snap_target) self._sketch.constrain_coincident(corner_pt, snap_target)
@@ -2322,8 +2628,15 @@ class Sketch2DWidget(QWidget):
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent) line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
if line_ent is not None: if line_ent is not None:
# Line clicked: constrain its length (endpoint distance). # Line clicked: constrain its length (endpoint distance).
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):", dist, ok = QInputDialog.getDouble(
self._constraint_distance_value, 0, 10000, 2) self,
"Distance",
"Distance (mm):",
self._constraint_distance_value,
0,
10000,
2,
)
if ok and self._sketch: if ok and self._sketch:
# Save state before adding constraint # Save state before adding constraint
if self._undo_manager: if self._undo_manager:
@@ -2339,8 +2652,9 @@ class Sketch2DWidget(QWidget):
if len(self._selected_entities) >= 2: if len(self._selected_entities) >= 2:
e1, e2 = self._selected_entities[:2] e1, e2 = self._selected_entities[:2]
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):", dist, ok = QInputDialog.getDouble(
self._constraint_distance_value, 0, 10000, 2) self, "Distance", "Distance (mm):", self._constraint_distance_value, 0, 10000, 2
)
if ok and self._sketch: if ok and self._sketch:
# Save state before adding constraint # Save state before adding constraint
if self._undo_manager: if self._undo_manager:
@@ -2495,12 +2809,11 @@ class Sketch2DWidget(QWidget):
if c_ent.geometry and r > 0: if c_ent.geometry and r > 0:
cx, cy = c_ent.geometry cx, cy = c_ent.geometry
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2) d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
if abs(d - r) < 8: # Clicked on circle circumference if abs(d - r) < self._pick_tolerance_world(16): # Clicked on circle circumference
# Prompt for diameter value # Prompt for diameter value
current_diameter = r * 2.0 current_diameter = r * 2.0
diameter, ok = QInputDialog.getDouble( diameter, ok = QInputDialog.getDouble(
self, "Diameter", "Diameter (mm):", self, "Diameter", "Diameter (mm):", current_diameter, 0, 10000, 2
current_diameter, 0, 10000, 2
) )
if ok and self._sketch: if ok and self._sketch:
# Save state before adding constraint # Save state before adding constraint
@@ -2515,9 +2828,7 @@ class Sketch2DWidget(QWidget):
self._circles[i] = (ent, new_radius) self._circles[i] = (ent, new_radius)
break break
# Record constraint for undo/redo # Record constraint for undo/redo
self._sketch._record_constraint( self._sketch._record_constraint("diameter", (c_ent.id,), (diameter,))
"diameter", (c_ent.id,), (diameter,)
)
self._solve_and_sync() self._solve_and_sync()
logger.info(f"Diameter constraint: {diameter:.2f}mm") logger.info(f"Diameter constraint: {diameter:.2f}mm")
self._selected_entities = [] self._selected_entities = []
@@ -2585,8 +2896,8 @@ class Sketch2DWidget(QWidget):
# regardless of zoom. When zoomed out too far to show 10mm lines # regardless of zoom. When zoomed out too far to show 10mm lines
# clearly, only the 100mm major grid is drawn. # clearly, only the 100mm major grid is drawn.
MIN_PX_SPACING = 6.0 # skip a grid level if screen spacing is below this MIN_PX_SPACING = 6.0 # skip a grid level if screen spacing is below this
grid_10 = 10 # 10mm minor grid grid_10 = 10 # 10mm minor grid
grid_100 = 100 # 100mm major grid grid_100 = 100 # 100mm major grid
# Compute screen-space spacing # Compute screen-space spacing
px_10 = grid_10 * self._zoom px_10 = grid_10 * self._zoom
@@ -2635,6 +2946,27 @@ class Sketch2DWidget(QWidget):
painter.drawLine(0, sy, self.width(), sy) painter.drawLine(0, sy, self.width(), sy)
wy += grid_10 wy += grid_10
# ── Draw 1mm fine grid (only at high zoom for precision work) ──
grid_1 = 1 # 1mm fine grid
px_1 = grid_1 * self._zoom
if px_1 >= MIN_PX_SPACING:
pen_fine = QPen(QColor("#353548"), 0.3)
start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_1) * grid_1
end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_1) * grid_1
start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_1) * grid_1
end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_1) * grid_1
painter.setPen(pen_fine)
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_1
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_1
# ── Centerlines (X and Y reference axes through origin) ── # ── Centerlines (X and Y reference axes through origin) ──
# X centerline = horizontal (red dashed), Y centerline = vertical (green dashed). # X centerline = horizontal (red dashed), Y centerline = vertical (green dashed).
# Both span the full viewport so they are always visible as reference guides. # Both span the full viewport so they are always visible as reference guides.
@@ -2660,10 +2992,12 @@ class Sketch2DWidget(QWidget):
# underlay on top of the entity-based lines. # underlay on top of the entity-based lines.
if self._source_underlay_uv and self._underlay_visible: if self._source_underlay_uv and self._underlay_visible:
if self._source_underlay_uv[0] and len(self._source_underlay_uv[0]) >= 3: if self._source_underlay_uv[0] and len(self._source_underlay_uv[0]) >= 3:
fill_poly = QPolygonF([ fill_poly = QPolygonF(
self._world_to_screen(QPoint(int(round(u)), int(round(v)))) [
for (u, v) in self._source_underlay_uv[0] 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.setBrush(QBrush(QColor(250, 179, 135, 28)))
painter.setPen(Qt.NoPen) painter.setPen(Qt.NoPen)
painter.drawPolygon(fill_poly) painter.drawPolygon(fill_poly)
@@ -2713,9 +3047,7 @@ class Sketch2DWidget(QWidget):
continue continue
if entity.geometry: if entity.geometry:
x, y = entity.geometry x, y = entity.geometry
screen_pos = self._world_to_screen( screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
QPoint(int(round(x)), int(round(y)))
)
painter.setPen(QPen(QColor("#fab387"), 1)) painter.setPen(QPen(QColor("#fab387"), 1))
painter.setBrush(QBrush(QColor("#fab387"))) painter.setBrush(QBrush(QColor("#fab387")))
painter.drawEllipse(screen_pos, 4, 4) painter.drawEllipse(screen_pos, 4, 4)
@@ -2736,7 +3068,11 @@ class Sketch2DWidget(QWidget):
sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1)))) sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2)))) 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) 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: if is_construction:
painter.setPen(QPen(QColor("#6c7086"), 1, Qt.DashLine)) painter.setPen(QPen(QColor("#6c7086"), 1, Qt.DashLine))
else: else:
@@ -2811,8 +3147,10 @@ class Sketch2DWidget(QWidget):
QPoint(int(round(preview[i][0])), int(round(preview[i][1]))) QPoint(int(round(preview[i][0])), int(round(preview[i][1])))
) )
p2 = self._world_to_screen( p2 = self._world_to_screen(
QPoint(int(round(preview[(i + 1) % len(preview)][0])), QPoint(
int(round(preview[(i + 1) % len(preview)][1]))) int(round(preview[(i + 1) % len(preview)][0])),
int(round(preview[(i + 1) % len(preview)][1])),
)
) )
painter.drawLine(p1, p2) painter.drawLine(p1, p2)
@@ -2827,8 +3165,42 @@ class Sketch2DWidget(QWidget):
p1 = self._world_to_screen(self._draw_buffer[0]) p1 = self._world_to_screen(self._draw_buffer[0])
p2 = self._world_to_screen(self._dynamic_line_end) p2 = self._world_to_screen(self._dynamic_line_end)
painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine)) painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
painter.drawRect(min(p1.x(), p2.x()), min(p1.y(), p2.y()), painter.drawRect(
abs(p2.x() - p1.x()), abs(p2.y() - p1.y())) min(p1.x(), p2.x()), min(p1.y(), p2.y()), abs(p2.x() - p1.x()), abs(p2.y() - p1.y())
)
# ── Dimension labels for rectangle preview ──
w1 = self._draw_buffer[0]
w2 = self._dynamic_line_end
width_mm = abs(w2.x() - w1.x())
height_mm = abs(w2.y() - w1.y())
rx = min(p1.x(), p2.x())
ry = min(p1.y(), p2.y())
rw = abs(p2.x() - p1.x())
rh = abs(p2.y() - p1.y())
painter.save()
painter.setPen(QPen(QColor("#f9e2af"), 1))
font = painter.font()
font.setPointSize(9)
painter.setFont(font)
# Horizontal label (top edge, centered)
if rw > 20:
h_label = f"{width_mm:.1f} mm"
h_rect = painter.fontMetrics().boundingRect(h_label)
h_x = rx + rw / 2 - h_rect.width() / 2
h_y = ry - 8 if ry > 20 else ry + rh + 16
painter.drawText(int(h_x), int(h_y), h_label)
# Vertical label (right edge, centered)
if rh > 20:
v_label = f"{height_mm:.1f} mm"
v_rect = painter.fontMetrics().boundingRect(v_label)
v_x = (
rx + rw + 8
if rx + rw + v_rect.width() + 16 < self.width()
else rx - v_rect.width() - 8
)
v_y = ry + rh / 2 + v_rect.height() / 2
painter.drawText(int(v_x), int(v_y), v_label)
painter.restore()
if self._draw_buffer and self._dynamic_line_end and self._mode == "circle": if self._draw_buffer and self._dynamic_line_end and self._mode == "circle":
center = self._world_to_screen(self._draw_buffer[0]) center = self._world_to_screen(self._draw_buffer[0])
@@ -2849,8 +3221,8 @@ class Sketch2DWidget(QWidget):
painter.drawEllipse(center, int(r), int(r)) painter.drawEllipse(center, int(r), int(r))
elif len(self._draw_buffer) == 2: elif len(self._draw_buffer) == 2:
# Click 2 done: show arc from start to cursor # Click 2 done: show arc from start to cursor
cw = self._draw_buffer[0] # center world cw = self._draw_buffer[0] # center world
sw = self._draw_buffer[1] # start world sw = self._draw_buffer[1] # start world
ew = self._dynamic_line_end # end world (mouse) ew = self._dynamic_line_end # end world (mouse)
cx_f, cy_f = cw.x(), cw.y() cx_f, cy_f = cw.x(), cw.y()
@@ -2897,8 +3269,7 @@ class Sketch2DWidget(QWidget):
dy_c = c2.y() - c1.y() dy_c = c2.y() - c1.y()
L = math.sqrt(dx_c * dx_c + dy_c * dy_c) L = math.sqrt(dx_c * dx_c + dy_c * dy_c)
if L > 0: if L > 0:
r = abs((cursor.x() - c1.x()) * dy_c r = abs((cursor.x() - c1.x()) * dy_c - (cursor.y() - c1.y()) * dx_c) / L
- (cursor.y() - c1.y()) * dx_c) / L
if r < 0.5: if r < 0.5:
r = 0.5 r = 0.5
perp_x = -dy_c / L perp_x = -dy_c / L
@@ -2983,9 +3354,11 @@ class Sketch2DWidget(QWidget):
outer = face["outer"] outer = face["outer"]
if outer["type"] == "polygon": if outer["type"] == "polygon":
pts = outer["points"] pts = outer["points"]
sp0 = self._world_to_screen(QPoint(int(round(pts[0][0])), int(round(pts[0][1])))) sp0 = self._world_to_screen(
QPoint(int(round(pts[0][0])), int(round(pts[0][1])))
)
path.moveTo(sp0.x(), sp0.y()) path.moveTo(sp0.x(), sp0.y())
for (px, py) in pts[1:]: for px, py in pts[1:]:
sp = self._world_to_screen(QPoint(int(round(px)), int(round(py)))) sp = self._world_to_screen(QPoint(int(round(px)), int(round(py))))
path.lineTo(sp.x(), sp.y()) path.lineTo(sp.x(), sp.y())
path.closeSubpath() path.closeSubpath()
@@ -2998,9 +3371,11 @@ class Sketch2DWidget(QWidget):
for hole in face["holes"]: for hole in face["holes"]:
if hole["type"] == "polygon": if hole["type"] == "polygon":
pts = hole["points"] pts = hole["points"]
sp0 = self._world_to_screen(QPoint(int(round(pts[0][0])), int(round(pts[0][1])))) sp0 = self._world_to_screen(
QPoint(int(round(pts[0][0])), int(round(pts[0][1])))
)
path.moveTo(sp0.x(), sp0.y()) path.moveTo(sp0.x(), sp0.y())
for (px, py) in pts[1:]: for px, py in pts[1:]:
sp = self._world_to_screen(QPoint(int(round(px)), int(round(py)))) sp = self._world_to_screen(QPoint(int(round(px)), int(round(py))))
path.lineTo(sp.x(), sp.y()) path.lineTo(sp.x(), sp.y())
path.closeSubpath() path.closeSubpath()