- arc improvements, fillets, operations, bodys

This commit is contained in:
bklronin
2026-08-05 18:53:48 +02:00
parent 1e9b0cab1c
commit 7072dcee08
5 changed files with 336 additions and 72 deletions
+10 -12
View File
@@ -7,19 +7,9 @@
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" /> <change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/sketch_solver.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/sketch_solver.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/tests/test_project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/tests/test_project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -482,7 +472,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1785915859137</updated> <updated>1785915859137</updated>
</task> </task>
<option name="localTasksCounter" value="45" /> <task id="LOCAL-00045" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1785941246695</created>
<option name="number" value="00045" />
<option name="presentableId" value="LOCAL-00045" />
<option name="project" value="LOCAL" />
<updated>1785941246695</updated>
</task>
<option name="localTasksCounter" value="46" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
+30 -30
View File
@@ -1170,41 +1170,27 @@
<string>Modify</string> <string>Modify</string>
</property> </property>
<layout class="QGridLayout" name="gridLayout_3"> <layout class="QGridLayout" name="gridLayout_3">
<item row="1" column="0"> <item row="2" column="1">
<widget class="QPushButton" name="pb_combop"> <widget class="QPushButton" name="pb_chamfer_op">
<property name="text"> <property name="text">
<string>Comb</string> <string>Chamfer</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="0"> <item row="2" column="0">
<widget class="QPushButton" name="pb_arrayop">
<property name="text">
<string>Array</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="pb_moveop">
<property name="text">
<string>Mve</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="pb_revop">
<property name="text">
<string>Rev</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="pb_fillet_op"> <widget class="QPushButton" name="pb_fillet_op">
<property name="text"> <property name="text">
<string>Fillet</string> <string>Fillet</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="6" column="0">
<widget class="QPushButton" name="pb_thread">
<property name="text">
<string>Thread</string>
</property>
</widget>
</item>
<item row="0" column="0"> <item row="0" column="0">
<widget class="QPushButton" name="pb_extrdop"> <widget class="QPushButton" name="pb_extrdop">
<property name="text"> <property name="text">
@@ -1212,6 +1198,13 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="4" column="1">
<widget class="QPushButton" name="pb_revop">
<property name="text">
<string>Rev</string>
</property>
</widget>
</item>
<item row="0" column="1"> <item row="0" column="1">
<widget class="QPushButton" name="pb_cutop"> <widget class="QPushButton" name="pb_cutop">
<property name="text"> <property name="text">
@@ -1219,17 +1212,24 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="3" column="1"> <item row="4" column="0">
<widget class="QPushButton" name="pb_chamfer"> <widget class="QPushButton" name="pb_arrayop">
<property name="text"> <property name="text">
<string>Chamfer</string> <string>Arry</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="4" column="0"> <item row="1" column="0">
<widget class="QPushButton" name="pb_thread"> <widget class="QPushButton" name="pb_combop">
<property name="text"> <property name="text">
<string>Thread</string> <string>Comb</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="pb_moveop">
<property name="text">
<string>Mve</string>
</property> </property>
</widget> </widget>
</item> </item>
+89 -3
View File
@@ -952,6 +952,16 @@ class OCCSketch(SketchInterface):
elif ctype == "distance": elif ctype == "distance":
if h(ids[0]) is None or h(ids[1]) is None: if h(ids[0]) is None or h(ids[1]) is None:
return False return False
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) self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
elif ctype == "angle": elif ctype == "angle":
if h(ids[0]) is None or h(ids[1]) is None: if h(ids[0]) is None or h(ids[1]) is None:
@@ -1175,16 +1185,92 @@ class OCCSketch(SketchInterface):
entity.constraints.append("vrt") entity.constraints.append("vrt")
return True 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( def constrain_distance(
self, entity1: SketchEntity, entity2: SketchEntity, distance: float self, entity1: SketchEntity, entity2: SketchEntity, distance: float
) -> bool: ) -> 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) e1 = self._entities.get(entity1.id)
e2 = self._entities.get(entity2.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: if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False return False
self._solver.distance(e1.handle, e2.handle, distance, self._wp) if e1 is e2 and e1.entity_type == "point" and distance != 0.0:
self._record_constraint("distance", (entity1.id, entity2.id), (distance,)) 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 return True
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool: def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
+17 -7
View File
@@ -187,19 +187,29 @@ class SolverSketch(SolverSystem):
def constrain_distance( def constrain_distance(
self, entity_a, entity_b, distance: float self, entity_a, entity_b, distance: float
) -> bool: ) -> 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: try:
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle 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): if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
self.distance(handle_a, handle_b, distance, self.wp) 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): elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
self.distance(handle_a, handle_b, distance, self.wp) 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: else:
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}") logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
return False return False
+185 -15
View File
@@ -1246,7 +1246,18 @@ class Sketch2DWidget(QWidget):
External (underlay) points are pickable when the underlay is visible 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 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. 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: for entity in self._points:
if self._is_external(entity) and not self._underlay_visible: if self._is_external(entity) and not self._underlay_visible:
continue continue
@@ -1255,9 +1266,17 @@ class Sketch2DWidget(QWidget):
continue continue
x, y = xy x, y = xy
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 < self._pick_tolerance_world(10): if dist >= tolerance:
return entity continue
return None 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( def _get_line_entity_at(
self, world_pos: QPoint self, world_pos: QPoint
@@ -1273,7 +1292,16 @@ class Sketch2DWidget(QWidget):
uses perpendicular distance to the infinite line (no segment uses perpendicular distance to the infinite line (no segment
clamping) with a zoom-adjusted tolerance so they are pickable at clamping) with a zoom-adjusted tolerance so they are pickable at
any zoom level. 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: for p1_ent, p2_ent in self._lines:
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent) 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)) 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). # perpendicular distance directly (no segment clamping).
tol = self._pick_tolerance_world(12) tol = self._pick_tolerance_world(12)
if perp_dist < tol: 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: else:
# Regular lines: clamp the projection to the segment # Regular lines: clamp the projection to the segment
# and check distance to that clamped point. # 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) self._pick_tolerance_world(8) if is_ext else self._pick_tolerance_world(14)
) )
if seg_dist < tol: if seg_dist < tol:
return (p1_ent, p2_ent) cand = (p1_ent, p2_ent)
return None # 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( def _find_line_sketch_entity(
self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
@@ -1399,6 +1436,43 @@ class Sketch2DWidget(QWidget):
x1, y1 = s_ent.geometry x1, y1 = s_ent.geometry
x2, y2 = e_ent.geometry x2, y2 = e_ent.geometry
return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2))) 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]: def _point_world(self, pid: int) -> Optional[QPoint]:
"""World-space position of the point entity with the given id. """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]) anchor = self._line_world_mid(ids[1])
label = "mid" 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. For a
# point-on-line coincident surfaces as a coincident entry; # point-to-line distance the dimension line must run from
# a future point-to-line distance would do the same). # the selected point to its perpendicular foot on the line
# Use _entity_anchor so a line id routes to the line # (i.e. at 90° to the line) — NOT to the line's midpoint,
# midpoint instead of crashing on round(<tuple>). # 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(<tuple>).
a = self._entity_anchor(ids[0]) a = self._entity_anchor(ids[0])
b = self._entity_anchor(ids[1]) 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. # 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)
@@ -1599,7 +1692,10 @@ class Sketch2DWidget(QWidget):
if tag_p1 is not None and tag_p2 is not None: if tag_p1 is not None and tag_p2 is not None:
tag_entry["p1_world"] = tag_p1 tag_entry["p1_world"] = tag_p1
tag_entry["p2_world"] = tag_p2 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) tags.append(tag_entry)
except Exception as exc: except Exception as exc:
# Catch any failure while building this one tag (bad # Catch any failure while building this one tag (bad
@@ -3330,6 +3426,12 @@ class Sketch2DWidget(QWidget):
def _handle_constraint_distance(self, world_pos: QPoint): def _handle_constraint_distance(self, world_pos: QPoint):
point_ent = self._get_point_entity_at(world_pos) point_ent = self._get_point_entity_at(world_pos)
if point_ent: 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. # Point clicked: collect points; constraint applied after 2nd point.
self._selected_entities.append(point_ent) self._selected_entities.append(point_ent)
else: else:
@@ -3338,7 +3440,44 @@ class Sketch2DWidget(QWidget):
p1_ent, p2_ent = line_hit p1_ent, p2_ent = line_hit
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). 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( dist, ok = QInputDialog.getDouble(
self, self,
"Distance", "Distance",
@@ -4026,6 +4165,32 @@ class Sketch2DWidget(QWidget):
painter.setPen(QPen(QColor("#cdd6f4"), 2)) painter.setPen(QPen(QColor("#cdd6f4"), 2))
painter.drawLine(sp1, sp2) 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) ── # ── Constraint tags (log-driven; drawn upright in screen space) ──
# Tags are recomputed here so paint stays in sync with the latest solve. # 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 # While a drawing or constraint tool is active the badges are hidden
@@ -4382,9 +4547,14 @@ class Sketch2DWidget(QWidget):
painter.drawPath(path) painter.drawPath(path)
# ── Selected entities ── # ── 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: for entity in self._selected_entities:
if entity.geometry: xy = self._flat_xy(entity.geometry)
x, y = entity.geometry if xy is None:
continue
x, y = xy
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("#f9e2af"), 2)) painter.setPen(QPen(QColor("#f9e2af"), 2))
painter.setBrush(Qt.NoBrush) painter.setBrush(Qt.NoBrush)