Ilya 58f3c891ee feat(calc,routing): config, orthogonal routing, and mock flow solver
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>
2026-07-02 23:15:35 +02:00

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