- 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">
<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.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/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.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/sketch_solver.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/sketch_solver.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>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -482,7 +472,15 @@
<option name="project" value="LOCAL" />
<updated>1785915859137</updated>
</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 />
</component>
<component name="TypeScriptGeneratedFilesManager">
+30 -30
View File
@@ -1170,41 +1170,27 @@
<string>Modify</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="1" column="0">
<widget class="QPushButton" name="pb_combop">
<item row="2" column="1">
<widget class="QPushButton" name="pb_chamfer_op">
<property name="text">
<string>Comb</string>
<string>Chamfer</string>
</property>
</widget>
</item>
<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">
<property name="text">
<string>Fillet</string>
</property>
</widget>
</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">
<widget class="QPushButton" name="pb_extrdop">
<property name="text">
@@ -1212,6 +1198,13 @@
</property>
</widget>
</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">
<widget class="QPushButton" name="pb_cutop">
<property name="text">
@@ -1219,17 +1212,24 @@
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="pb_chamfer">
<item row="4" column="0">
<widget class="QPushButton" name="pb_arrayop">
<property name="text">
<string>Chamfer</string>
<string>Arry</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QPushButton" name="pb_thread">
<item row="1" column="0">
<widget class="QPushButton" name="pb_combop">
<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>
</widget>
</item>
+89 -3
View File
@@ -952,6 +952,16 @@ class OCCSketch(SketchInterface):
elif ctype == "distance":
if h(ids[0]) is None or h(ids[1]) is None:
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)
elif ctype == "angle":
if h(ids[0]) is None or h(ids[1]) is None:
@@ -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:
+17 -7
View File
@@ -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
+185 -15
View File
@@ -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(<tuple>).
# 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(<tuple>).
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,9 +4547,14 @@ 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
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)