diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index d71f00d..c52df94 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -7,19 +7,9 @@
-
-
-
-
-
-
-
-
-
-
+
-
@@ -482,7 +472,15 @@
1785915859137
-
+
+
+ 1785941246695
+
+
+
+ 1785941246695
+
+
diff --git a/gui.ui b/gui.ui
index 54dd469..227fb96 100644
--- a/gui.ui
+++ b/gui.ui
@@ -1170,41 +1170,27 @@
Modify
-
-
+
+
- Comb
+ Chamfer
-
-
- Array
-
-
-
-
-
-
- Mve
-
-
-
-
-
-
- Rev
-
-
-
- Fillet
+
+
+
+ Thread
+
+
+
@@ -1212,6 +1198,13 @@
+
+
+
+ Rev
+
+
+
@@ -1219,17 +1212,24 @@
-
-
+
+
- Chamfer
+ Arry
-
-
+
+
- Thread
+ Comb
+
+
+
+
+
+
+ Mve
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index 7f31489..9300bf1 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -952,7 +952,17 @@ class OCCSketch(SketchInterface):
elif ctype == "distance":
if h(ids[0]) is None or h(ids[1]) is None:
return False
- self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
+ ent0 = self._entities.get(ids[0])
+ ent1 = self._entities.get(ids[1])
+ # Normalise (line, point) -> (point, line) like constrain_distance
+ # does, and drop legacy line-line entries the solver can't hold.
+ if ent0 is not None and ent1 is not None:
+ if ent0.entity_type == "line" and ent1.entity_type == "line":
+ return False
+ if ent0.entity_type == "line" and ent1.entity_type == "point":
+ self._solver.distance(h(ids[1]), h(ids[0]), params[0], self._wp)
+ else:
+ self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
elif ctype == "angle":
if h(ids[0]) is None or h(ids[1]) is None:
return False
@@ -1175,16 +1185,92 @@ class OCCSketch(SketchInterface):
entity.constraints.append("vrt")
return True
+ def _point_line_signed_offset(
+ self, point_ent: OCCSketchEntity, line_ent: OCCSketchEntity
+ ) -> float:
+ """Signed perpendicular offset of *point_ent* from *line_ent*, in the
+ solver's sign convention — i.e. the ``valA`` that pins the point on
+ its current side of the line.
+
+ Mirrors SolveSpace's ``PT_LINE_DISTANCE`` equation exactly
+ (a = line start, b = line end, d = a - b):
+
+ proj = dv·(ua − u) − du·(va − v), m = |d|
+ offset = proj / m
+
+ Returns 0.0 when the line is degenerate or the point lies on it
+ (the side is then undefined — callers fall back to the unsigned
+ value).
+ """
+ if line_ent.id not in self._lines:
+ return 0.0
+ sid, eid2 = self._lines[line_ent.id]
+ s_ent = self._entities.get(sid)
+ e_ent = self._entities.get(eid2)
+ if s_ent is None or e_ent is None or not s_ent.geometry or not e_ent.geometry:
+ return 0.0
+ ua, va = s_ent.geometry
+ ub, vb = e_ent.geometry
+ u, v = point_ent.geometry
+ du = ua - ub
+ dv = va - vb
+ m = math.hypot(du, dv)
+ if m < 1e-12:
+ return 0.0
+ return (dv * (ua - u) - du * (va - v)) / m
+
def constrain_distance(
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
) -> bool:
- """Constrain distance between two entities."""
+ """Constrain distance between two entities.
+
+ python-solvespace's ``distance`` accepts point-point and point-line
+ (in that order) only, so line-point pairs are normalised and line-line
+ pairs are rejected with a warning instead of raising TypeError.
+
+ A point constrained to itself with a non-zero value is always
+ inconsistent — reject it up front so the UI never creates a constraint
+ the solver cannot satisfy.
+ """
e1 = self._entities.get(entity1.id)
e2 = self._entities.get(entity2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
- self._solver.distance(e1.handle, e2.handle, distance, self._wp)
- self._record_constraint("distance", (entity1.id, entity2.id), (distance,))
+ if e1 is e2 and e1.entity_type == "point" and distance != 0.0:
+ logger.warning("distance: refusing point-to-itself constraint")
+ return False
+
+ t1, t2 = e1.entity_type, e2.entity_type
+ if t1 == "line" and t2 == "line":
+ logger.warning(
+ "distance: line-to-line distance is not supported by the solver "
+ "(select a point and a line instead)"
+ )
+ return False
+
+ # python-solvespace only accepts (point, line) ordering.
+ if t1 == "line" and t2 == "point":
+ e1, e2 = e2, e1
+ entity1, entity2 = entity2, entity1
+
+ # Point-to-line distance is SIGNED in SolveSpace: the constraint
+ # equation is (signed perpendicular offset) = valA, so a positive
+ # valA always drives the point onto one fixed side of the line
+ # (for a vertical line, always +x). Pass a value whose sign
+ # matches the side the point is currently on so the constraint
+ # pins it there instead of flipping it across the line. The sign
+ # is recorded with the value so a solver rebuild reproduces the
+ # same side.
+ solver_value = distance
+ if e2.entity_type == "line" and e1.entity_type == "point" and distance != 0.0:
+ signed = self._point_line_signed_offset(e1, e2)
+ if abs(signed) > 1e-9:
+ solver_value = math.copysign(distance, signed)
+
+ self._solver.distance(e1.handle, e2.handle, solver_value, self._wp)
+ # Record in the normalised (point-first) order so replayed / legacy
+ # constraint logs are consistent.
+ self._record_constraint("distance", (entity1.id, entity2.id), (solver_value,))
return True
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
diff --git a/src/fluency/sketch_solver.py b/src/fluency/sketch_solver.py
index 316e650..60e0f52 100644
--- a/src/fluency/sketch_solver.py
+++ b/src/fluency/sketch_solver.py
@@ -187,19 +187,29 @@ class SolverSketch(SolverSystem):
def constrain_distance(
self, entity_a, entity_b, distance: float
) -> bool:
- """Constrain distance between point-point or point-line."""
+ """Constrain distance between point-point or point-line.
+
+ python-solvespace's ``distance`` accepts (point, line) ordering only,
+ so line-point pairs are normalised; line-line pairs and a point
+ constrained to itself are rejected (the solver cannot hold them).
+ """
try:
- handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle
- handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle
+ if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
+ logger.warning("distance: line-to-line distance is not supported")
+ return False
+ if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
+ # Normalise to (point, line) ordering.
+ entity_a, entity_b = entity_b, entity_a
+ if entity_a is entity_b and distance != 0.0:
+ logger.warning("distance: refusing point-to-itself constraint")
+ return False
+ handle_a = entity_a.handle
+ handle_b = entity_b.handle
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
self.distance(handle_a, handle_b, distance, self.wp)
- elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
- self.distance(handle_b, handle_a, distance, self.wp)
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
self.distance(handle_a, handle_b, distance, self.wp)
- elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
- self.distance(handle_a, handle_b, distance, self.wp)
else:
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
return False
diff --git a/src/fluency/ui/sketch_widget.py b/src/fluency/ui/sketch_widget.py
index 29367a7..f0570dc 100644
--- a/src/fluency/ui/sketch_widget.py
+++ b/src/fluency/ui/sketch_widget.py
@@ -1246,7 +1246,18 @@ class Sketch2DWidget(QWidget):
External (underlay) points are pickable when the underlay is visible
so the user can use them as constraint anchors (e.g. the corner of a
projected face); they're skipped when the underlay is hidden.
+
+ When several points are within tolerance (e.g. a user-drawn corner
+ sitting exactly on the centerline origin), user-drawn points win
+ over centerline / external reference points — otherwise a click on
+ your own corner would silently grab the reference point underneath
+ it and the distance would apply to the wrong entity.
"""
+ best: Optional[OCCSketchEntity] = None
+ best_dist = float("inf")
+ ref_best: Optional[OCCSketchEntity] = None
+ ref_best_dist = float("inf")
+ tolerance = self._pick_tolerance_world(10)
for entity in self._points:
if self._is_external(entity) and not self._underlay_visible:
continue
@@ -1255,9 +1266,17 @@ class Sketch2DWidget(QWidget):
continue
x, y = xy
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
- if dist < self._pick_tolerance_world(10):
- return entity
- return None
+ if dist >= tolerance:
+ continue
+ if self._is_external(entity) or self._is_centerline(entity):
+ # Reference geometry — fallback only, never preferred.
+ if dist < ref_best_dist:
+ ref_best, ref_best_dist = entity, dist
+ else:
+ # User-drawn point — preferred over reference geometry.
+ if dist < best_dist:
+ best, best_dist = entity, dist
+ return best if best is not None else ref_best
def _get_line_entity_at(
self, world_pos: QPoint
@@ -1273,7 +1292,16 @@ class Sketch2DWidget(QWidget):
uses perpendicular distance to the infinite line (no segment
clamping) with a zoom-adjusted tolerance so they are pickable at
any zoom level.
+
+ When several lines are within tolerance (e.g. a user-drawn edge
+ lying exactly on the X axis), user-drawn lines win over centerline
+ / external reference lines — otherwise a click on your own edge
+ would silently grab the reference axis underneath it.
"""
+ best: Optional[Tuple[OCCSketchEntity, OCCSketchEntity]] = None
+ best_dist = float("inf")
+ ref_best: Optional[Tuple[OCCSketchEntity, OCCSketchEntity]] = None
+ ref_best_dist = float("inf")
for p1_ent, p2_ent in self._lines:
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
is_ext = bool(line_ent is not None and self._is_external(line_ent))
@@ -1300,7 +1328,9 @@ class Sketch2DWidget(QWidget):
# perpendicular distance directly (no segment clamping).
tol = self._pick_tolerance_world(12)
if perp_dist < tol:
- return (p1_ent, p2_ent)
+ cand = (p1_ent, p2_ent)
+ if perp_dist < ref_best_dist:
+ ref_best, ref_best_dist = cand, perp_dist
else:
# Regular lines: clamp the projection to the segment
# and check distance to that clamped point.
@@ -1317,8 +1347,15 @@ class Sketch2DWidget(QWidget):
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
+ cand = (p1_ent, p2_ent)
+ # Prefer user-drawn lines over reference geometry.
+ if is_ext:
+ if seg_dist < ref_best_dist:
+ ref_best, ref_best_dist = cand, seg_dist
+ else:
+ if seg_dist < best_dist:
+ best, best_dist = cand, seg_dist
+ return best if best is not None else ref_best
def _find_line_sketch_entity(
self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
@@ -1399,6 +1436,43 @@ class Sketch2DWidget(QWidget):
x1, y1 = s_ent.geometry
x2, y2 = e_ent.geometry
return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
+ def _line_world_endpoints(
+ self, line_id: int
+ ) -> Optional[Tuple[Tuple[float, float], Tuple[float, float]]]:
+ """World-space endpoint coordinates of the line with the given id."""
+ if not self._sketch or line_id not in self._sketch._lines:
+ return None
+ sid, eid2 = self._sketch._lines[line_id]
+ s_ent = self._sketch._entities.get(sid)
+ e_ent = self._sketch._entities.get(eid2)
+ if not s_ent or not e_ent or not s_ent.geometry or not e_ent.geometry:
+ return None
+ x1, y1 = s_ent.geometry
+ x2, y2 = e_ent.geometry
+ if not all(isinstance(v, (int, float)) for v in (x1, y1, x2, y2)):
+ return None
+ return (float(x1), float(y1)), (float(x2), float(y2))
+
+ def _perpendicular_foot_world(self, point: QPoint, line_id: int) -> Optional[QPoint]:
+ """Perpendicular foot of *point* projected onto the line, world-space.
+
+ The foot lies on the *infinite* line through the two endpoints — when
+ the point projects beyond the segment the dimension line legitimately
+ extends past the endpoint. Returns None if the line geometry is
+ unavailable.
+ """
+ ends = self._line_world_endpoints(line_id)
+ if ends is None:
+ return None
+ (x1, y1), (x2, y2) = ends
+ dx = x2 - x1
+ dy = y2 - y1
+ denom = dx * dx + dy * dy
+ if denom < 1e-12:
+ # Degenerate (zero-length) line — fall back to its start point.
+ return QPoint(int(round(x1)), int(round(y1)))
+ t = ((point.x() - x1) * dx + (point.y() - y1) * dy) / denom
+ return QPoint(int(round(x1 + t * dx)), int(round(y1 + t * dy)))
def _point_world(self, pid: int) -> Optional[QPoint]:
"""World-space position of the point entity with the given id.
@@ -1508,13 +1582,32 @@ class Sketch2DWidget(QWidget):
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().
+ # Distance may be point-to-point OR point-to-line. For a
+ # point-to-line distance the dimension line must run from
+ # the selected point to its perpendicular foot on the line
+ # (i.e. at 90° to the line) — NOT to the line's midpoint,
+ # which would render the measured distance at an arbitrary
+ # angle. Use _entity_anchor so a line id routes to the
+ # line midpoint as a fallback instead of crashing on
+ # round().
a = self._entity_anchor(ids[0])
b = self._entity_anchor(ids[1])
+ ent_a = self._sketch._entities.get(ids[0])
+ ent_b = self._sketch._entities.get(ids[1])
+ ta = ent_a.entity_type if ent_a is not None else None
+ tb = ent_b.entity_type if ent_b is not None else None
+ if ta == "point" and tb == "line":
+ if a is not None:
+ foot = self._perpendicular_foot_world(a, ids[1])
+ if foot is not None:
+ b = foot
+ elif ta == "line" and tb == "point":
+ # Legacy (line, point) log ordering — same geometry,
+ # mirrored.
+ if b is not None:
+ foot = self._perpendicular_foot_world(b, ids[0])
+ if foot is not None:
+ a = foot
# 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)
@@ -1599,7 +1692,10 @@ class Sketch2DWidget(QWidget):
if tag_p1 is not None and tag_p2 is not None:
tag_entry["p1_world"] = tag_p1
tag_entry["p2_world"] = tag_p2
- tag_entry["distance"] = params[0] if params else 0.0
+ # The stored value carries the side-sign (negative
+ # when the point is on the "negative" side of the
+ # line) — the dimension text shows the magnitude.
+ tag_entry["distance"] = abs(params[0]) if params else 0.0
tags.append(tag_entry)
except Exception as exc:
# Catch any failure while building this one tag (bad
@@ -3330,6 +3426,12 @@ class Sketch2DWidget(QWidget):
def _handle_constraint_distance(self, world_pos: QPoint):
point_ent = self._get_point_entity_at(world_pos)
if point_ent:
+ # Clicking the same point twice would create a point-to-itself
+ # distance which the solver can never satisfy for a non-zero
+ # value — ignore the repeat so the user can reposition instead.
+ if point_ent in self._selected_entities:
+ self.update()
+ return
# Point clicked: collect points; constraint applied after 2nd point.
self._selected_entities.append(point_ent)
else:
@@ -3338,7 +3440,44 @@ class Sketch2DWidget(QWidget):
p1_ent, p2_ent = line_hit
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
if line_ent is not None:
- # Line clicked: constrain its length (endpoint distance).
+ if self._selected_entities:
+ # A point is already selected: this line is the
+ # SECOND entity of a point-to-line distance.
+ prev_ent = self._selected_entities[0]
+ dist, ok = QInputDialog.getDouble(
+ self,
+ "Point-to-line distance",
+ "Distance (mm):",
+ self._constraint_distance_value,
+ 0,
+ 10000,
+ 2,
+ )
+ if ok and self._sketch:
+ # Save state before adding constraint
+ if self._undo_manager:
+ self._undo_manager.save_state()
+ self._sketch.constrain_distance(prev_ent, line_ent, dist)
+ self._solve_and_sync()
+ logger.info(f"Point-line distance {dist:.2f}mm")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+ return
+ # No selection: a lone line click sets its length
+ # (distance between its endpoint points). Reference
+ # lines (centerlines / underlay projections) are fixed
+ # and have no meaningful "length" — skip them; the
+ # user must pick a point first to constrain against
+ # them (point-to-line distance).
+ if self._is_centerline(line_ent) or self._is_external(line_ent):
+ logger.info(
+ "distance: select a point first, then this reference line "
+ "for a point-to-line distance"
+ )
+ self.update()
+ return
dist, ok = QInputDialog.getDouble(
self,
"Distance",
@@ -4026,6 +4165,32 @@ class Sketch2DWidget(QWidget):
painter.setPen(QPen(QColor("#cdd6f4"), 2))
painter.drawLine(sp1, sp2)
+ # ── Selected-entity highlight (multi-click constraint tools) ──
+ # While collecting entities for a distance constraint the first pick
+ # gets highlighted so the user can see it registered before clicking
+ # the second entity. Points get a bright ring, lines a bright
+ # overlay stroke.
+ if self._selected_entities and self._is_drawing_tool_active():
+ for ent in self._selected_entities:
+ if ent.entity_type == "point":
+ xy = self._flat_xy(ent.geometry)
+ if xy is None:
+ continue
+ x, y = xy
+ sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ painter.setPen(QPen(QColor("#f9e2af"), 2))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawEllipse(sp, 9, 9)
+ elif ent.entity_type == "line":
+ p1e, p2e = self._get_line_endpoints(ent)
+ if p1e and p2e and p1e.geometry and p2e.geometry:
+ x1, y1 = p1e.geometry
+ x2, y2 = p2e.geometry
+ sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
+ sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
+ painter.setPen(QPen(QColor("#f9e2af"), 3))
+ painter.drawLine(sp1, sp2)
+
# ── Constraint tags (log-driven; drawn upright in screen space) ──
# Tags are recomputed here so paint stays in sync with the latest solve.
# While a drawing or constraint tool is active the badges are hidden
@@ -4382,13 +4547,18 @@ class Sketch2DWidget(QWidget):
painter.drawPath(path)
# ── Selected entities ──
+ # Point entities only (rings); line selection is highlighted by the
+ # dedicated constraint-selection block above. Guard with _flat_xy so
+ # a line entity (tuple-of-tuples geometry) can never reach round().
for entity in self._selected_entities:
- if entity.geometry:
- x, y = entity.geometry
- screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
- painter.setPen(QPen(QColor("#f9e2af"), 2))
- painter.setBrush(Qt.NoBrush)
- painter.drawEllipse(screen_pos, 12, 12)
+ xy = self._flat_xy(entity.geometry)
+ if xy is None:
+ continue
+ x, y = xy
+ screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ painter.setPen(QPen(QColor("#f9e2af"), 2))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawEllipse(screen_pos, 12, 12)
# ── DOF display ──
if self._sketch: