Wires now round their bends, hop over the wires they cross, and show a dot where wires from one output split. Wires sharing a pin may share a trunk. Routes keep one grid square from a box's top and bottom and two from its sides, never turn closer than two squares beside a box, and keep half a square from other wires. The runs pinned to every slot are claimed before any wire is routed, so an early wire no longer lays its run along a row a later slot has to leave by. A wire that cannot fit the grid spacing falls back to the old tighter one rather than turning into knobs. The styling geometry lives in wire_style.py; draw_links is split into collecting, routing and painting around a WireJob dataclass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
6.9 KiB
Python
200 lines
6.9 KiB
Python
"""How a routed wire looks: rounded bends, hops where wires cross, dots where they split.
|
|
|
|
The router in wiresheet.py hands over orthogonal polylines and nothing else. Everything
|
|
here is pure geometry on those point lists -- no canvas, no app state -- so the styling
|
|
can change without touching how wires find their way around the boxes.
|
|
"""
|
|
|
|
import math
|
|
|
|
CORNER_RADIUS = 6 # bend radius, shrunk on short segments so two bends never overlap
|
|
CORNER_STEPS = 5 # points per bend; enough to read as a curve at every zoom level
|
|
HOP_HALF_WIDTH = 4 # a hop spans this far either side of the wire it jumps over
|
|
HOP_HEIGHT = 4
|
|
HOP_STEPS = 6
|
|
HOP_MERGE_GAP = 2 # hops closer than this run together into one wider hop
|
|
HOP_END_MARGIN = 10 # no hop this close to a target, where the arrowhead sits
|
|
JUNCTION_RADIUS = 3
|
|
|
|
EPS = 0.5
|
|
|
|
|
|
def simplify(points):
|
|
"""Drops repeated points and the middle of any straight run.
|
|
|
|
The router emits a fixed number of points per route shape, so a path often carries a
|
|
"bend" that does not turn at all. Rounding one of those would put a kink in a
|
|
straight line.
|
|
"""
|
|
out = []
|
|
for point in points:
|
|
if out and _same(out[-1], point):
|
|
continue
|
|
if len(out) >= 2 and _collinear(out[-2], out[-1], point):
|
|
out[-1] = point
|
|
else:
|
|
out.append(point)
|
|
return out
|
|
|
|
|
|
def find_hops(paths):
|
|
"""Where each wire has to hop: one dict per path, segment index -> crossing xs.
|
|
|
|
A horizontal run hops over a vertical run of a different wire it crosses. Only true
|
|
crossings count: a wire touching another at a bend or an end is a junction, and a
|
|
hop there would hide it.
|
|
"""
|
|
horizontals, verticals = [], []
|
|
for path_index, points in enumerate(paths):
|
|
for seg_index, (a, b) in enumerate(zip(points, points[1:])):
|
|
if abs(a[1] - b[1]) < EPS:
|
|
horizontals.append((path_index, seg_index, a[1],
|
|
min(a[0], b[0]), max(a[0], b[0])))
|
|
else:
|
|
verticals.append((path_index, a[0], min(a[1], b[1]), max(a[1], b[1])))
|
|
|
|
hops = [{} for _ in paths]
|
|
for path_index, seg_index, y, x1, x2 in horizontals:
|
|
for other, x, y1, y2 in verticals:
|
|
if other == path_index:
|
|
continue
|
|
if x1 + EPS < x < x2 - EPS and y1 + EPS < y < y2 - EPS:
|
|
hops[path_index].setdefault(seg_index, []).append(x)
|
|
return hops
|
|
|
|
|
|
def fan_out_junctions(paths_by_owner):
|
|
"""Points where wires leaving one output pin go their separate ways.
|
|
|
|
Every pair of wires from the same pin shares a run out of it, and some share a trunk
|
|
further on. Where the two stop running together is a branch, and a branch without a
|
|
dot reads as two wires merely crossing.
|
|
"""
|
|
points = set()
|
|
for paths in paths_by_owner.values():
|
|
for i, first in enumerate(paths):
|
|
for second in paths[i + 1:]:
|
|
split = _divergence(first, second)
|
|
if split is None:
|
|
continue
|
|
if any(_same(split, end) for end in (first[0], first[-1], second[-1])):
|
|
continue
|
|
points.add((round(split[0], 1), round(split[1], 1)))
|
|
return points
|
|
|
|
|
|
def styled_points(points, hops=None):
|
|
"""The points to draw for a simplified orthogonal path, bends rounded and hops in."""
|
|
hops = hops or {}
|
|
count = len(points)
|
|
if count < 3:
|
|
return list(points)
|
|
|
|
lengths = [_length(a, b) for a, b in zip(points, points[1:])]
|
|
radii = [0.0] * count
|
|
for i in range(1, count - 1):
|
|
radii[i] = min(CORNER_RADIUS, lengths[i - 1] / 2, lengths[i] / 2)
|
|
|
|
out = [points[0]]
|
|
for i in range(count - 1):
|
|
a, b = points[i], points[i + 1]
|
|
ux, uy = _unit(a, b)
|
|
start = (a[0] + ux * radii[i], a[1] + uy * radii[i])
|
|
end = (b[0] - ux * radii[i + 1], b[1] - uy * radii[i + 1])
|
|
|
|
if i in hops:
|
|
end_margin = HOP_END_MARGIN if i == count - 2 else 0
|
|
out.extend(_hop_points(start, end, hops[i], end_margin))
|
|
out.append(end)
|
|
|
|
if i + 1 < count - 1:
|
|
out.extend(_corner_points(end, b, points[i + 2], radii[i + 1]))
|
|
return out
|
|
|
|
|
|
# --- helpers ---
|
|
|
|
def _same(p, q):
|
|
return abs(p[0] - q[0]) < EPS and abs(p[1] - q[1]) < EPS
|
|
|
|
|
|
def _collinear(p, q, r):
|
|
return ((abs(p[0] - q[0]) < EPS and abs(q[0] - r[0]) < EPS)
|
|
or (abs(p[1] - q[1]) < EPS and abs(q[1] - r[1]) < EPS))
|
|
|
|
|
|
def _length(a, b):
|
|
return abs(b[0] - a[0]) + abs(b[1] - a[1]) # orthogonal, so one term is zero
|
|
|
|
|
|
def _unit(a, b):
|
|
length = _length(a, b)
|
|
if length < EPS:
|
|
return 0.0, 0.0
|
|
return (b[0] - a[0]) / length, (b[1] - a[1]) / length
|
|
|
|
|
|
def _corner_points(entry, corner, after, radius):
|
|
"""A bend from `entry` round `corner` towards `after`, as a quadratic curve."""
|
|
if radius < EPS:
|
|
return []
|
|
vx, vy = _unit(corner, after)
|
|
leave = (corner[0] + vx * radius, corner[1] + vy * radius)
|
|
out = []
|
|
for step in range(1, CORNER_STEPS + 1):
|
|
t = step / CORNER_STEPS
|
|
s = 1 - t
|
|
out.append((s * s * entry[0] + 2 * s * t * corner[0] + t * t * leave[0],
|
|
s * s * entry[1] + 2 * s * t * corner[1] + t * t * leave[1]))
|
|
return out
|
|
|
|
|
|
def _hop_points(start, end, xs, end_margin):
|
|
"""The bumps along a horizontal run from `start` to `end`, in the order travelled."""
|
|
y = start[1]
|
|
direction = 1 if end[0] >= start[0] else -1
|
|
lo = min(start[0], end[0]) + HOP_HALF_WIDTH + 1
|
|
hi = max(start[0], end[0]) - HOP_HALF_WIDTH - 1
|
|
if direction > 0:
|
|
hi -= end_margin
|
|
else:
|
|
lo += end_margin
|
|
|
|
ordered = sorted((x for x in xs if lo <= x <= hi), key=lambda x: x * direction)
|
|
groups = []
|
|
for x in ordered:
|
|
if groups and abs(x - groups[-1][-1]) < 2 * HOP_HALF_WIDTH + HOP_MERGE_GAP:
|
|
groups[-1].append(x)
|
|
else:
|
|
groups.append([x])
|
|
|
|
out = []
|
|
for group in groups:
|
|
x_from = group[0] - direction * HOP_HALF_WIDTH
|
|
x_to = group[-1] + direction * HOP_HALF_WIDTH
|
|
for step in range(HOP_STEPS + 1):
|
|
t = step / HOP_STEPS
|
|
out.append((x_from + (x_to - x_from) * t, y - HOP_HEIGHT * math.sin(math.pi * t)))
|
|
return out
|
|
|
|
|
|
def _divergence(first, second):
|
|
"""The last point two paths from the same start share before they part, or None."""
|
|
if not _same(first[0], second[0]):
|
|
return None
|
|
pos = first[0]
|
|
i = j = 0
|
|
while i < len(first) - 1 and j < len(second) - 1:
|
|
d1, d2 = _unit(pos, first[i + 1]), _unit(pos, second[j + 1])
|
|
if _same(pos, first[i + 1]):
|
|
i += 1
|
|
continue
|
|
if _same(pos, second[j + 1]):
|
|
j += 1
|
|
continue
|
|
if not _same(d1, d2):
|
|
return pos
|
|
step = min(_length(pos, first[i + 1]), _length(pos, second[j + 1]))
|
|
pos = (pos[0] + d1[0] * step, pos[1] + d1[1] * step)
|
|
return pos
|