Files
sedona_editor/wire_style.py
T
ArdaandClaude Opus 5.5 8a0051380a Wiresheet wires: rounded bends, hops, junction dots and grid spacing
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>
2026-10-07 09:37:35 +03:00

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