Add observable AppConfig (grid/snap/arc-on-crossing/animation/theme), pure-geometry orthogonal router with grid-snapped bends and perpendicular crossing detection for arc hops, and a mock flow-distribution solver that pushes supply to demand along shortest paths and records signed per-edge flow and direction. Covered by routing and flow-solver tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
3.8 KiB
Python
126 lines
3.8 KiB
Python
"""Pure-geometry orthogonal routing and crossing detection.
|
|
|
|
Kept free of Qt imports so it can be unit-tested independently. Points are
|
|
``(x, y)`` float tuples; segments are ``(p0, p1)`` axis-aligned pairs.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Iterable
|
|
|
|
Point = tuple[float, float]
|
|
Segment = tuple[Point, Point]
|
|
|
|
|
|
def _snap(v: float, grid: int) -> float:
|
|
if grid <= 0:
|
|
return v
|
|
return round(v / grid) * grid
|
|
|
|
|
|
def orthogonal_route(
|
|
start: Point,
|
|
start_normal: Point,
|
|
end: Point,
|
|
end_normal: Point,
|
|
*,
|
|
grid: int = 20,
|
|
stub: int | None = None,
|
|
) -> list[Point]:
|
|
"""Compute an axis-aligned poly-line from ``start`` to ``end``.
|
|
|
|
The path leaves ``start`` along ``start_normal`` and arrives at ``end``
|
|
against ``end_normal``, turning through a mid-line. Endpoints are exact;
|
|
intermediate bends are snapped to ``grid``.
|
|
"""
|
|
if stub is None:
|
|
stub = grid
|
|
sx, sy = start
|
|
ex, ey = end
|
|
snx, sny = start_normal
|
|
enx, eny = end_normal
|
|
|
|
s1 = (sx + snx * stub, sy + sny * stub)
|
|
e1 = (ex + enx * stub, ey + eny * stub)
|
|
|
|
pts: list[Point] = [(sx, sy), s1]
|
|
if snx != 0: # start exits horizontally -> pivot on a vertical mid-line
|
|
midx = _snap((s1[0] + e1[0]) / 2.0, grid)
|
|
pts += [(midx, s1[1]), (midx, e1[1])]
|
|
else: # start exits vertically -> pivot on a horizontal mid-line
|
|
midy = _snap((s1[1] + e1[1]) / 2.0, grid)
|
|
pts += [(s1[0], midy), (e1[0], midy)]
|
|
pts += [e1, (ex, ey)]
|
|
|
|
return _cleanup(pts)
|
|
|
|
|
|
def _cleanup(pts: list[Point], eps: float = 1e-6) -> list[Point]:
|
|
"""Drop duplicate and collinear intermediate points."""
|
|
out: list[Point] = []
|
|
for p in pts:
|
|
if out and abs(p[0] - out[-1][0]) < eps and abs(p[1] - out[-1][1]) < eps:
|
|
continue
|
|
out.append(p)
|
|
# remove collinear middles
|
|
cleaned: list[Point] = []
|
|
for i, p in enumerate(out):
|
|
if 0 < i < len(out) - 1:
|
|
a, b = out[i - 1], out[i + 1]
|
|
# collinear if all three share an x or all share a y
|
|
if (abs(a[0] - p[0]) < eps and abs(p[0] - b[0]) < eps) or \
|
|
(abs(a[1] - p[1]) < eps and abs(p[1] - b[1]) < eps):
|
|
continue
|
|
cleaned.append(p)
|
|
return cleaned
|
|
|
|
|
|
def polyline_segments(points: Iterable[Point]) -> list[Segment]:
|
|
pts = list(points)
|
|
return [(pts[i], pts[i + 1]) for i in range(len(pts) - 1)]
|
|
|
|
|
|
def _is_horizontal(seg: Segment, eps: float = 1e-6) -> bool:
|
|
return abs(seg[0][1] - seg[1][1]) < eps
|
|
|
|
|
|
def _is_vertical(seg: Segment, eps: float = 1e-6) -> bool:
|
|
return abs(seg[0][0] - seg[1][0]) < eps
|
|
|
|
|
|
def segment_crossings(
|
|
path: list[Segment],
|
|
others: list[Segment],
|
|
*,
|
|
eps: float = 1e-6,
|
|
) -> list[Point]:
|
|
"""Return true perpendicular crossing points between ``path`` and ``others``.
|
|
|
|
Only counts a horizontal segment crossing a vertical one (or vice versa)
|
|
strictly in the interior of both — shared endpoints and overlaps are ignored.
|
|
Used to render arc "hops" where pipes cross.
|
|
"""
|
|
crossings: list[Point] = []
|
|
for a in path:
|
|
for b in others:
|
|
pt = _perpendicular_crossing(a, b, eps)
|
|
if pt is not None:
|
|
crossings.append(pt)
|
|
return crossings
|
|
|
|
|
|
def _perpendicular_crossing(a: Segment, b: Segment, eps: float) -> Point | None:
|
|
if _is_horizontal(a) and _is_vertical(b):
|
|
h, v = a, b
|
|
elif _is_vertical(a) and _is_horizontal(b):
|
|
v, h = a, b
|
|
else:
|
|
return None
|
|
hy = h[0][1]
|
|
vx = v[0][0]
|
|
hx0, hx1 = sorted((h[0][0], h[1][0]))
|
|
vy0, vy1 = sorted((v[0][1], v[1][1]))
|
|
# strictly interior on both segments (avoid endpoints / T-joins)
|
|
if hx0 + eps < vx < hx1 - eps and vy0 + eps < hy < vy1 - eps:
|
|
return (vx, hy)
|
|
return None
|