- added contrain context menu
- improved line pickability.
This commit is contained in:
+474
-99
@@ -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.
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
# 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._mode = None
|
||||||
self._draw_buffer = []
|
self._draw_buffer = []
|
||||||
self._dynamic_line_end = None
|
self._dynamic_line_end = None
|
||||||
self._selected_entities = []
|
|
||||||
self._arc_accum_sweep = 0.0
|
self._arc_accum_sweep = 0.0
|
||||||
self._arc_prev_angle = None
|
self._arc_prev_angle = None
|
||||||
self.constrain_done.emit()
|
self.constrain_done.emit()
|
||||||
self.update()
|
# 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 (
|
||||||
|
tight_ent is not None
|
||||||
|
and tight_ent.geometry
|
||||||
and not self._is_external(tight_ent)
|
and not self._is_external(tight_ent)
|
||||||
and not self._is_centerline(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 (
|
||||||
|
target is not None
|
||||||
and not self._is_external(target[0])
|
and not self._is_external(target[0])
|
||||||
and not self._is_centerline(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
|
||||||
|
|
||||||
@@ -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 = []
|
||||||
@@ -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))))
|
self._world_to_screen(QPoint(int(round(u)), int(round(v))))
|
||||||
for (u, v) in self._source_underlay_uv[0]
|
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])
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user