- added contrain context menu
- improved line pickability.
This commit is contained in:
+493
-118
@@ -6,11 +6,10 @@ 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.QtCore import Qt, Signal, QPoint, QPointF, QRect
|
||||
from PySide6.QtGui import (
|
||||
QBrush,
|
||||
QColor,
|
||||
QCursor,
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QPainter,
|
||||
@@ -20,6 +19,8 @@ from PySide6.QtGui import (
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QInputDialog,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
@@ -31,7 +32,9 @@ 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]],
|
||||
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.
|
||||
|
||||
@@ -48,9 +51,9 @@ def _project_face_to_uv(
|
||||
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
|
||||
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)
|
||||
@@ -107,17 +110,25 @@ class Sketch2DWidget(QWidget):
|
||||
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)
|
||||
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_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._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
|
||||
@@ -254,11 +265,7 @@ class Sketch2DWidget(QWidget):
|
||||
# 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
|
||||
):
|
||||
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
|
||||
@@ -379,9 +386,7 @@ class Sketch2DWidget(QWidget):
|
||||
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
|
||||
)
|
||||
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()
|
||||
@@ -654,6 +659,15 @@ class Sketch2DWidget(QWidget):
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
|
||||
@@ -674,7 +688,9 @@ class Sketch2DWidget(QWidget):
|
||||
nearest = point
|
||||
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.
|
||||
|
||||
External (underlay) points are pickable when the underlay is visible
|
||||
@@ -877,11 +893,13 @@ class Sketch2DWidget(QWidget):
|
||||
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
|
||||
if dist < self._pick_tolerance_world(10):
|
||||
return entity
|
||||
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.
|
||||
|
||||
External (underlay) lines are pickable when the underlay is visible
|
||||
@@ -910,15 +928,15 @@ class Sketch2DWidget(QWidget):
|
||||
# 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)
|
||||
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))
|
||||
tol = self._pick_tolerance_world(12)
|
||||
if perp_dist < tol:
|
||||
return (p1_ent, p2_ent)
|
||||
else:
|
||||
@@ -928,8 +946,15 @@ class Sketch2DWidget(QWidget):
|
||||
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
|
||||
seg_dist = math.sqrt(
|
||||
(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 None
|
||||
|
||||
@@ -965,7 +990,9 @@ class Sketch2DWidget(QWidget):
|
||||
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]:
|
||||
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
|
||||
@@ -974,15 +1001,25 @@ class Sketch2DWidget(QWidget):
|
||||
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):
|
||||
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):
|
||||
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]:
|
||||
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))
|
||||
|
||||
@@ -1040,7 +1077,9 @@ class Sketch2DWidget(QWidget):
|
||||
# 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,
|
||||
pid,
|
||||
x,
|
||||
y,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1091,46 +1130,57 @@ class Sketch2DWidget(QWidget):
|
||||
label = ""
|
||||
|
||||
if ctype == "horizontal":
|
||||
anchor = self._line_world_mid(ids[0]); label = "hrz"
|
||||
anchor = self._line_world_mid(ids[0])
|
||||
label = "hrz"
|
||||
elif ctype == "vertical":
|
||||
anchor = self._line_world_mid(ids[0]); label = "vrt"
|
||||
anchor = self._line_world_mid(ids[0])
|
||||
label = "vrt"
|
||||
elif ctype == "midpoint":
|
||||
anchor = self._line_world_mid(ids[1]); label = "mid"
|
||||
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])
|
||||
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"
|
||||
anchor = self._line_world_mid(ids[0])
|
||||
label = "par"
|
||||
elif ctype == "perpendicular":
|
||||
anchor = self._line_world_mid(ids[0]); label = "perp"
|
||||
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"
|
||||
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])
|
||||
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"
|
||||
anchor = self._line_world_mid(ids[2])
|
||||
label = "sym"
|
||||
elif ctype == "fixed":
|
||||
anchor = self._point_world(ids[0]); label = "fix"
|
||||
anchor = self._point_world(ids[0])
|
||||
label = "fix"
|
||||
elif ctype == "equal_radius":
|
||||
anchor = self._point_world(ids[0]); label = "eqr"
|
||||
anchor = self._point_world(ids[0])
|
||||
label = "eqr"
|
||||
else:
|
||||
continue
|
||||
|
||||
@@ -1157,7 +1207,10 @@ class Sketch2DWidget(QWidget):
|
||||
# other tags as normal.
|
||||
logger.debug(
|
||||
"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
|
||||
return tags
|
||||
@@ -1242,7 +1295,7 @@ class Sketch2DWidget(QWidget):
|
||||
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:
|
||||
if abs(d - r) < self._pick_tolerance_world(16):
|
||||
return c_ent, QPoint(int(round(cx)), int(round(cy)))
|
||||
return None
|
||||
|
||||
@@ -1271,6 +1324,162 @@ class Sketch2DWidget(QWidget):
|
||||
self.update()
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
@@ -1283,14 +1492,16 @@ class Sketch2DWidget(QWidget):
|
||||
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()
|
||||
# Only clear drawing-state, preserve any point selection for the
|
||||
# context menu (contextMenuEvent is invoked by Qt after this).
|
||||
if self._mode in ("line", "rectangle", "circle", "arc", "slot"):
|
||||
self._mode = None
|
||||
self._draw_buffer = []
|
||||
self._dynamic_line_end = None
|
||||
self._arc_accum_sweep = 0.0
|
||||
self._arc_prev_angle = None
|
||||
self.constrain_done.emit()
|
||||
# Do NOT return — let Qt deliver the contextMenuEvent.
|
||||
return
|
||||
|
||||
if event.button() == Qt.LeftButton:
|
||||
@@ -1298,9 +1509,12 @@ class Sketch2DWidget(QWidget):
|
||||
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)):
|
||||
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]] = {}
|
||||
@@ -1324,9 +1538,11 @@ class Sketch2DWidget(QWidget):
|
||||
# selection zone. External (underlay) entities are fixed
|
||||
# references and can't be dragged.
|
||||
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])):
|
||||
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]] = {}
|
||||
@@ -1362,7 +1578,8 @@ class Sketch2DWidget(QWidget):
|
||||
return
|
||||
|
||||
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 = (
|
||||
self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
|
||||
@@ -1410,12 +1627,14 @@ class Sketch2DWidget(QWidget):
|
||||
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:
|
||||
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
|
||||
)
|
||||
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()
|
||||
@@ -1442,9 +1661,11 @@ class Sketch2DWidget(QWidget):
|
||||
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
|
||||
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)
|
||||
@@ -1541,7 +1762,7 @@ class Sketch2DWidget(QWidget):
|
||||
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:
|
||||
if abs(d - r) < self._pick_tolerance_world(16):
|
||||
over_circle = True
|
||||
break
|
||||
self._hovered_face = None
|
||||
@@ -1595,7 +1816,9 @@ class Sketch2DWidget(QWidget):
|
||||
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._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.
|
||||
@@ -1614,7 +1837,7 @@ class Sketch2DWidget(QWidget):
|
||||
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._zoom = max(0.05, min(200.0, self._zoom))
|
||||
self.update()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
@@ -1632,8 +1855,9 @@ class Sketch2DWidget(QWidget):
|
||||
return
|
||||
|
||||
# Redo: Ctrl+Shift+Z or Ctrl+Y
|
||||
if (event.key() == Qt.Key_Z and event.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier)) or \
|
||||
(event.key() == Qt.Key_Y and event.modifiers() & Qt.ControlModifier):
|
||||
if (
|
||||
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:
|
||||
self._undo_manager.redo()
|
||||
self._rebuild_from_sketch()
|
||||
@@ -1648,7 +1872,11 @@ class Sketch2DWidget(QWidget):
|
||||
# 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._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()
|
||||
@@ -1664,7 +1892,11 @@ class Sketch2DWidget(QWidget):
|
||||
|
||||
# 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._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()
|
||||
@@ -1672,6 +1904,83 @@ class Sketch2DWidget(QWidget):
|
||||
|
||||
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):
|
||||
"""Delete the hovered constraint (by log index) and recompute the rest."""
|
||||
idx = self._hovered_constraint_idx
|
||||
@@ -1779,9 +2088,7 @@ class Sketch2DWidget(QWidget):
|
||||
# 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}"
|
||||
)
|
||||
logger.info(f"Toggled line {line_ent.id} construction -> {new_val}")
|
||||
self._hovered_line = None
|
||||
self._hovered_line_entity = None
|
||||
self._solve_and_sync()
|
||||
@@ -2080,6 +2387,7 @@ class Sketch2DWidget(QWidget):
|
||||
# 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)
|
||||
@@ -2093,8 +2401,8 @@ class Sketch2DWidget(QWidget):
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
|
||||
@@ -2113,9 +2421,9 @@ class Sketch2DWidget(QWidget):
|
||||
|
||||
# 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
|
||||
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
|
||||
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
|
||||
@@ -2137,9 +2445,7 @@ class Sketch2DWidget(QWidget):
|
||||
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)))
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -2322,8 +2628,15 @@ class Sketch2DWidget(QWidget):
|
||||
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)
|
||||
dist, ok = QInputDialog.getDouble(
|
||||
self,
|
||||
"Distance",
|
||||
"Distance (mm):",
|
||||
self._constraint_distance_value,
|
||||
0,
|
||||
10000,
|
||||
2,
|
||||
)
|
||||
if ok and self._sketch:
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
@@ -2339,8 +2652,9 @@ class Sketch2DWidget(QWidget):
|
||||
|
||||
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)
|
||||
dist, ok = QInputDialog.getDouble(
|
||||
self, "Distance", "Distance (mm):", self._constraint_distance_value, 0, 10000, 2
|
||||
)
|
||||
if ok and self._sketch:
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
@@ -2495,12 +2809,11 @@ class Sketch2DWidget(QWidget):
|
||||
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: # Clicked on circle circumference
|
||||
if abs(d - r) < self._pick_tolerance_world(16): # Clicked on circle circumference
|
||||
# Prompt for diameter value
|
||||
current_diameter = r * 2.0
|
||||
diameter, ok = QInputDialog.getDouble(
|
||||
self, "Diameter", "Diameter (mm):",
|
||||
current_diameter, 0, 10000, 2
|
||||
self, "Diameter", "Diameter (mm):", current_diameter, 0, 10000, 2
|
||||
)
|
||||
if ok and self._sketch:
|
||||
# Save state before adding constraint
|
||||
@@ -2515,9 +2828,7 @@ class Sketch2DWidget(QWidget):
|
||||
self._circles[i] = (ent, new_radius)
|
||||
break
|
||||
# Record constraint for undo/redo
|
||||
self._sketch._record_constraint(
|
||||
"diameter", (c_ent.id,), (diameter,)
|
||||
)
|
||||
self._sketch._record_constraint("diameter", (c_ent.id,), (diameter,))
|
||||
self._solve_and_sync()
|
||||
logger.info(f"Diameter constraint: {diameter:.2f}mm")
|
||||
self._selected_entities = []
|
||||
@@ -2585,8 +2896,8 @@ class Sketch2DWidget(QWidget):
|
||||
# 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
|
||||
grid_10 = 10 # 10mm minor grid
|
||||
grid_100 = 100 # 100mm major grid
|
||||
|
||||
# Compute screen-space spacing
|
||||
px_10 = grid_10 * self._zoom
|
||||
@@ -2635,6 +2946,27 @@ class Sketch2DWidget(QWidget):
|
||||
painter.drawLine(0, sy, self.width(), sy)
|
||||
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) ──
|
||||
# X centerline = horizontal (red dashed), Y centerline = vertical (green dashed).
|
||||
# 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.
|
||||
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]
|
||||
])
|
||||
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)
|
||||
@@ -2713,9 +3047,7 @@ class Sketch2DWidget(QWidget):
|
||||
continue
|
||||
if entity.geometry:
|
||||
x, y = entity.geometry
|
||||
screen_pos = self._world_to_screen(
|
||||
QPoint(int(round(x)), int(round(y)))
|
||||
)
|
||||
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)
|
||||
@@ -2736,7 +3068,11 @@ class Sketch2DWidget(QWidget):
|
||||
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)
|
||||
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:
|
||||
@@ -2811,8 +3147,10 @@ class Sketch2DWidget(QWidget):
|
||||
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])))
|
||||
QPoint(
|
||||
int(round(preview[(i + 1) % len(preview)][0])),
|
||||
int(round(preview[(i + 1) % len(preview)][1])),
|
||||
)
|
||||
)
|
||||
painter.drawLine(p1, p2)
|
||||
|
||||
@@ -2827,8 +3165,42 @@ class Sketch2DWidget(QWidget):
|
||||
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()))
|
||||
painter.drawRect(
|
||||
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":
|
||||
center = self._world_to_screen(self._draw_buffer[0])
|
||||
@@ -2849,8 +3221,8 @@ class Sketch2DWidget(QWidget):
|
||||
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
|
||||
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()
|
||||
@@ -2897,8 +3269,7 @@ class Sketch2DWidget(QWidget):
|
||||
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
|
||||
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
|
||||
@@ -2983,9 +3354,11 @@ class Sketch2DWidget(QWidget):
|
||||
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]))))
|
||||
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:]:
|
||||
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()
|
||||
@@ -2998,9 +3371,11 @@ class Sketch2DWidget(QWidget):
|
||||
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]))))
|
||||
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:]:
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user