CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
139 lines
6.1 KiB
Python
139 lines
6.1 KiB
Python
"""Hình học thuần cho canvas Co4E — tách khỏi ``ui/co4e_canvas.py``.
|
|
|
|
Vấn đề đang có: ``ui/co4e_canvas.py`` dài hơn 2000 dòng, gộp chung widget Qt
|
|
(``QGraphicsItem``, vẽ, sự kiện chuột) với các hàm hình học thuần (khoảng cách,
|
|
nội suy điểm, dựng đường bo góc, né vật cản, cắt chuỗi). Các hàm hình học này
|
|
không cần ``QApplication``, không vẽ, không đọc kích thước widget — chúng chỉ
|
|
dùng ``QPointF``/``QRectF``/``QPainterPath`` như kiểu giá trị thuần. Gộp chung
|
|
vào một file khiến file đó khó đọc và khó kiểm tra theo giới hạn CASAN (≤400
|
|
dòng mỗi file production).
|
|
|
|
Cách làm: dời nguyên 8 hàm này sang đây, không đổi tên/tham số/giá trị mặc
|
|
định/hành vi — kể cả các "quirk" đã bị characterization test đóng đinh (xem
|
|
``tests/characterization/test_co4e_canvas_geometry.py``), ví dụ ``_route`` có
|
|
thể "bỏ cuộc" và trả về elbow va chạm nếu bị vật cản bao kín hoàn toàn, hoặc
|
|
``_elide(text, 0)`` trả về ``"…"`` chứ không phải chuỗi rỗng do cách slicing
|
|
``text[: n - 1]``. Đừng "sửa" các quirk này ở đây — chúng đã có test khoá lại,
|
|
sửa sai chỗ này sẽ làm vỡ hợp đồng mà nơi khác đang phụ thuộc.
|
|
|
|
``ui/co4e_canvas.py`` import lại các tên này (không alias) để giữ nguyên đường
|
|
import public mà các test/character khác đang dùng.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import QPointF, QRectF
|
|
from PySide6.QtGui import QPainterPath
|
|
|
|
_CORNER_R = 12 # edge elbow corner radius
|
|
|
|
|
|
def _dist(a: QPointF, b: QPointF) -> float:
|
|
"""Khoảng cách Euclid giữa hai điểm."""
|
|
return ((a.x() - b.x()) ** 2 + (a.y() - b.y()) ** 2) ** 0.5
|
|
|
|
|
|
def _towards(a: QPointF, b: QPointF, d: float) -> QPointF:
|
|
"""Điểm cách ``a`` một đoạn ``d`` theo hướng đi về ``b``.
|
|
|
|
Hai điểm trùng nhau thì trả về chính ``a``, tránh chia cho 0.
|
|
"""
|
|
dist = _dist(a, b)
|
|
if dist < 1e-6:
|
|
return QPointF(a)
|
|
t = d / dist
|
|
return QPointF(a.x() + (b.x() - a.x()) * t, a.y() + (b.y() - a.y()) * t)
|
|
|
|
|
|
def _rounded_path(points, r: float = _CORNER_R) -> QPainterPath:
|
|
"""Build a path through axis-aligned ``points`` with rounded corners at each
|
|
bend ("vuông bo cong ở góc")."""
|
|
if not points:
|
|
return QPainterPath()
|
|
path = QPainterPath(points[0])
|
|
if len(points) == 1:
|
|
return path
|
|
for i in range(1, len(points) - 1):
|
|
prev, cur, nxt = points[i - 1], points[i], points[i + 1]
|
|
rr = min(r, _dist(prev, cur) / 2.0, _dist(cur, nxt) / 2.0)
|
|
path.lineTo(_towards(cur, prev, rr))
|
|
path.quadTo(cur, _towards(cur, nxt, rr))
|
|
path.lineTo(points[-1])
|
|
return path
|
|
|
|
|
|
def _seg_hits_rect(p1: QPointF, p2: QPointF, rect: QRectF) -> bool:
|
|
"""Axis-aligned segment vs rectangle overlap (all routed segments are H or V)."""
|
|
x1, y1, x2, y2 = p1.x(), p1.y(), p2.x(), p2.y()
|
|
if abs(y1 - y2) < 0.5: # horizontal
|
|
if rect.top() <= y1 <= rect.bottom():
|
|
lo, hi = sorted((x1, x2))
|
|
return not (hi < rect.left() or lo > rect.right())
|
|
return False
|
|
if abs(x1 - x2) < 0.5: # vertical
|
|
if rect.left() <= x1 <= rect.right():
|
|
lo, hi = sorted((y1, y2))
|
|
return not (hi < rect.top() or lo > rect.bottom())
|
|
return False
|
|
box = QRectF(QPointF(min(x1, x2), min(y1, y2)), QPointF(max(x1, x2), max(y1, y2)))
|
|
return rect.intersects(box)
|
|
|
|
|
|
def _hits(points, obstacles) -> bool:
|
|
"""Đường gấp khúc này có cắt qua hình chữ nhật vật cản nào không.
|
|
|
|
Dùng để định tuyến đường nối lách qua các node khác.
|
|
"""
|
|
for i in range(len(points) - 1):
|
|
for r in obstacles:
|
|
if _seg_hits_rect(points[i], points[i + 1], r):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _route(src: QPointF, dst: QPointF, obstacles=None):
|
|
"""Waypoints for a LEFT→RIGHT orthogonal edge from ``src`` (a node's right
|
|
output) to ``dst`` (the next node's left input) that AVOIDS the other node
|
|
rectangles: try the straight elbow, then a clear vertical band, then a
|
|
top/bottom detour — so a connector never overlaps or hides behind a step."""
|
|
obstacles = list(obstacles or [])
|
|
if abs(src.y() - dst.y()) < 1.5:
|
|
cand = [src, dst]
|
|
if not _hits(cand, obstacles):
|
|
return cand
|
|
mid_x = (src.x() + dst.x()) / 2.0
|
|
base = [src, QPointF(mid_x, src.y()), QPointF(mid_x, dst.y()), dst]
|
|
if not _hits(base, obstacles):
|
|
return base
|
|
# 1) slide the vertical run to a clear band between the two columns
|
|
lo, hi = min(src.x(), dst.x()) + 6, max(src.x(), dst.x()) - 6
|
|
if hi > lo:
|
|
for frac in (0.5, 0.35, 0.65, 0.2, 0.8):
|
|
x = lo + (hi - lo) * frac
|
|
cand = [src, QPointF(x, src.y()), QPointF(x, dst.y()), dst]
|
|
if not _hits(cand, obstacles):
|
|
return cand
|
|
# 2) detour above/below every obstacle, then back in
|
|
margin = 44.0
|
|
ys = [src.y(), dst.y()] + [r.top() for r in obstacles] + [r.bottom() for r in obstacles]
|
|
out_x, in_x = src.x() + 34, dst.x() - 34 # short stubs out of the side ports
|
|
for side_y in (min(ys) - margin, max(ys) + margin):
|
|
cand = [src, QPointF(out_x, src.y()), QPointF(out_x, side_y),
|
|
QPointF(in_x, side_y), QPointF(in_x, dst.y()), dst]
|
|
if not _hits(cand, obstacles):
|
|
return cand
|
|
return base
|
|
|
|
|
|
def _ortho_path(src: QPointF, dst: QPointF, r: float = _CORNER_R) -> QPainterPath:
|
|
"""Rounded orthogonal elbow (no obstacle avoidance) — used for the transient
|
|
drag-to-connect line and by callers that pass no obstacles."""
|
|
return _rounded_path(_route(src, dst), r)
|
|
|
|
|
|
def _elide(text: str, n: int) -> str:
|
|
"""Cắt chuỗi cho vừa ``n`` ký tự và thay xuống dòng bằng dấu cách — nhãn trên
|
|
khung vẽ chỉ có một dòng.
|
|
"""
|
|
text = (text or "").replace("\n", " ")
|
|
return text if len(text) <= n else text[: n - 1] + "…"
|