From 8a0051380adbbf3b8594c5adc1ae2de3138f4690 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Wed, 7 Oct 2026 09:37:35 +0300 Subject: [PATCH] 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 --- app_state.py | 2 +- wire_style.py | 199 ++++++++++++++++++ wiresheet.py | 566 +++++++++++++++++++++++++++++++------------------- 3 files changed, 551 insertions(+), 216 deletions(-) create mode 100644 wire_style.py diff --git a/app_state.py b/app_state.py index c934321..4c1fdc1 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.160" +VERSION = "0.0.0.161" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/wire_style.py b/wire_style.py new file mode 100644 index 0000000..02a3b32 --- /dev/null +++ b/wire_style.py @@ -0,0 +1,199 @@ +"""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 diff --git a/wiresheet.py b/wiresheet.py index 3b6e0f4..da6b0c8 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -5,27 +5,39 @@ import os import sys import tkinter as tk import tkinter.font as tkfont +from dataclasses import dataclass import app_state import live_hooks import schema import undo +import wire_style from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value, update_meta_property) -LINK_STUB = 8 # straight run leaving a slot before the wire is allowed to turn -LINK_CLEAR = 5 # gap kept between a horizontal wire lane and any box above or below LINK_EPS = 0.5 # tolerance so a wire touching its own box edge is not a collision -# Vertical wire runs. A run hugs the box it leaves or enters instead of drifting down the -# middle of the gap, but may not crowd it: WIRE_HUG is where it sits, WIRE_MIN_CLEAR is -# the closest it is ever allowed to get, and two runs sharing one gap stand -# WIRE_LANE_GAP apart. A gap therefore has to be WIRE_HUG either side plus one -# WIRE_LANE_GAP per extra run, which is why TIDY_GAP below is exactly 2 * WIRE_HUG. +# How far a wire keeps from the boxes it passes, in grid squares. A wire stays one +# square clear of a box's top and bottom and two clear of its sides, and it never turns +# closer than two squares to the side of a box -- including the turn just after leaving +# a slot and the last one before arriving at one. Two wires running side by side, either +# way, stand at least half a square apart. +CLEAR_SIDE_GRIDS = 2 +CLEAR_END_GRIDS = 1 +CLEAR_WIRE_GRIDS = 0.5 + +# The tight spacing, used only for a wire the grid spacing above cannot place -- boxes +# laid out closer together than it needs. A vertical run sits WIRE_HUG from the box it +# leaves or enters and never nearer than WIRE_MIN_CLEAR; a horizontal one keeps +# LINK_CLEAR from a box above or below; the last turn is ARRIVE_TURN_GRIDS back. Side by +# side, vertical runs stand WIRE_LANE_GAP apart and horizontal ones WIRE_ROW_GAP. WIRE_HUG = 10 WIRE_MIN_CLEAR = 6 +LINK_CLEAR = 5 +ARRIVE_TURN_GRIDS = 1.5 WIRE_LANE_GAP = 6 +WIRE_ROW_GAP = 3 # No wire may pass under a component, and that outranks hugging: when the hug band is # already taken, a run may stand off this far instead so the wire can still be drawn @@ -33,15 +45,45 @@ WIRE_LANE_GAP = 6 # knobs, the same way a link leaving the sheet is. WIRE_STANDOFF_MAX = 60 -# The last turn before a target slot is kept this many grid squares back from the box, -# so the arrowhead always sits on a straight run of its own instead of on the bend. -ARRIVE_TURN_GRIDS = 1.5 -# Two different wires running horizontally at the same height are indistinguishable, so -# a free horizontal lane is nudged until it clears any other wire's by this much. The -# short stubs joining a slot cannot move -- they have to meet their pin -- so a fan-out -# from one output pin still shares the run right beside the pin. -WIRE_ROW_GAP = 3 +@dataclass(frozen=True) +class Clearance: + """The spacing a route keeps from boxes, at 1:1. + + side nearest a vertical run may come to a box's left or right edge + hug where a vertical run beside the box it leaves or enters prefers to sit + end nearest a horizontal run may come to a box's top or bottom edge + arrive how far in front of the target box the last turn is made + lane nearest two different wires' vertical runs may stand side by side + row nearest two different wires' horizontal runs may stand side by side + """ + side: float + hug: float + end: float + arrive: float + lane: float + row: float + + +def grid_clearance(): + """The spacing every wire is routed with first, measured in grid squares.""" + grid = app_state.GRID_SIZE + side = CLEAR_SIDE_GRIDS * grid + apart = CLEAR_WIRE_GRIDS * grid + return Clearance(side=side, hug=side, end=CLEAR_END_GRIDS * grid, arrive=side, + lane=apart, row=apart) + + +def tight_clearance(): + """The older, tighter spacing, kept for a wire that cannot be routed on the grid one. + + Boxes laid out before the grid rule may stand closer than it needs, and a wire that + brushes a box is still far better than a link reduced to knobs. + """ + return Clearance(side=WIRE_MIN_CLEAR, hug=WIRE_HUG, end=LINK_CLEAR, + arrive=max(WIRE_HUG, ARRIVE_TURN_GRIDS * app_state.GRID_SIZE), + lane=WIRE_LANE_GAP, row=WIRE_ROW_GAP) + LINK_COLOR = "#2f6fba" KNOB_OFFSHEET_COLOR = "#9aa0a6" # a link whose far end is on another wiresheet KNOB_RADIUS = 4 @@ -292,10 +334,21 @@ def segment_hits_boxes(x1, y1, x2, y2, boxes): return False -def path_is_clear(points, boxes): - for idx in range(len(points) - 1): - (x1, y1), (x2, y2) = points[idx], points[idx + 1] - if segment_hits_boxes(x1, y1, x2, y2, boxes): +def path_is_clear(points, boxes, clear): + """True if no part of the path runs under a box or crowds one. + + Every run between the first turn and the last keeps `clear` from each box: `end` + above and below, `side` left and right. The two runs pinned to the slots are only + held out from under the boxes, since they have to reach their pins wherever the + rows happen to be. + """ + points = wire_style.simplify(points) + padded = [(x1 - clear.side, y1 - clear.end, x2 + clear.side, y2 + clear.end) + for x1, y1, x2, y2 in boxes] + last = len(points) - 2 + for idx, ((x1, y1), (x2, y2)) in enumerate(zip(points, points[1:])): + pinned = idx in (0, last) + if segment_hits_boxes(x1, y1, x2, y2, boxes if pinned else padded): return False return True @@ -319,22 +372,17 @@ def vertical_run_clearance(cx, y1, y2, boxes): return nearest -def arrive_turn(): - """How far in front of a target box the wire makes its last turn, at 1:1.""" - return max(WIRE_HUG, ARRIVE_TURN_GRIDS * app_state.GRID_SIZE) - - -def channel_candidates(src_box, dst_box, boxes, low, high): +def channel_candidates(src_box, dst_box, boxes, low, high, clear): """Vertical channel positions to try, best first. - A run hugs the box it leaves or the box it enters, WIRE_HUG away -- that is the rule - that a vertical line stays within WIRE_HUG of a component. The midpoint of the gap - is kept only as a last resort, for when every hug position is blocked. + A run hugs the box it leaves or the box it enters, `clear.hug` away -- that is the + rule that a vertical line stays close to a component. The midpoint of the gap is + kept only as a last resort, for when every hug position is blocked. """ - ordered = [src_box[2] + WIRE_HUG, dst_box[0] - arrive_turn()] + ordered = [src_box[2] + clear.hug, dst_box[0] - clear.arrive] for bx1, _, bx2, _ in boxes: - ordered.append(bx2 + WIRE_HUG) - ordered.append(bx1 - WIRE_HUG) + ordered.append(bx2 + clear.hug) + ordered.append(bx1 - clear.hug) ordered.append((low + high) / 2) seen, out = set(), [] @@ -345,96 +393,123 @@ def channel_candidates(src_box, dst_box, boxes, low, high): return out -def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, max_steps=8): - """Nudges cx outwards in WIRE_LANE_GAP steps until the run has a lane of its own. +def same_signal(pins, other_pins): + """True if two wires may lie exactly on top of each other. - A lane is rejected while it crowds a box (closer than WIRE_MIN_CLEAR) or while it + A wire's `pins` are the slots at its two ends. Wires leaving one output carry the + same signal, and wires arriving at one input merge into it, so where two of them + share a pin they may share a run too -- that is a branch or a merge, not a clash. + """ + return bool(pins and other_pins and pins & other_pins) + + +def runs_clash(at, lo, hi, other_at, other_lo, other_hi, pins, other_pins, apart): + """True if a run would sit too close beside, on top of, or end to end with another. + + `at` is the run's fixed coordinate (x for a vertical run, y for a horizontal one) + and lo..hi its extent along the other axis. Two runs closer than `apart` side by + side clash where they overlap, and so do two on one line that stop less than + `apart` short of each other, which would read as one continuous wire. A wire never + clashes with itself: its own runs meet at its bends. + """ + if pins is not None and pins == other_pins: + return False + if hi < other_lo - apart + LINK_EPS or lo > other_hi + apart - LINK_EPS: + return False + gap = abs(at - other_at) + if gap >= apart - LINK_EPS: + return False + return not (gap < LINK_EPS and same_signal(pins, other_pins)) + + +def lane_is_crowded(lane, run_lo, run_hi, claimed, pins, apart): + """True if a vertical run at `lane` would crowd one already placed. + + Wires sharing a pin may share a trunk exactly -- that is a branch, and gets a + junction dot. Running alongside it a few pixels off is refused for every wire alike. + """ + return any(runs_clash(lane, run_lo, run_hi, tx, t_lo, t_hi, pins, t_pins, apart) + for tx, t_lo, t_hi, t_pins in claimed) + + +def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, clear, max_steps=8, + pins=None): + """Nudges cx outwards in `clear.lane` steps until the run has a lane of its own. + + A lane is rejected while it crowds a box (closer than `clear.side`) or while it would run alongside an already placed run that overlaps it vertically -- two runs - passing through the same gap have to stand WIRE_LANE_GAP apart. Returns None when + passing through the same gap have to stand `clear.lane` apart. Returns None when no lane inside the gap works. `max_steps` of 1 permits no nudging at all, which is how the caller asks for the exact hug position and nothing else. """ for step in range(0, max_steps): for sign in ((0,) if step == 0 else (1, -1)): - lane = cx + sign * step * WIRE_LANE_GAP + lane = cx + sign * step * clear.lane if not (low <= lane <= high): continue - if vertical_run_clearance(lane, run_lo, run_hi, boxes) < WIRE_MIN_CLEAR - LINK_EPS: + if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS: continue - crowded = any( - abs(lane - tx) < WIRE_LANE_GAP - LINK_EPS - and not (run_hi < t_lo - LINK_EPS or run_lo > t_hi + LINK_EPS) - for tx, t_lo, t_hi in taken - ) - if not crowded: + if not lane_is_crowded(lane, run_lo, run_hi, taken, pins, clear.lane): return lane return None -def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, max_offset=WIRE_HUG, - min_offset=WIRE_MIN_CLEAR): - """Lane for a run pinned to one box edge, starting WIRE_MIN_CLEAR out from it. +def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, clear, max_offset=0, + min_offset=0, pins=None): + """Lane for a run pinned to one box edge, starting `clear.side` out from it. `side` is +1 for a run leaving a box's right edge, -1 for one entering its left edge. Offsets inside the hug band are offered first, furthest out of the band first so the run keeps clear of its own box; only when every one of those is taken - does it reach past WIRE_HUG, out to `max_offset`. Returns None when nothing up to + does it reach past `clear.hug`, out to `max_offset`. Returns None when nothing up to that limit clears both the boxes and the runs already placed. `min_offset` raises the floor, and the hug position with it, for a run that must stand further off. """ - floor = max(WIRE_MIN_CLEAR, int(math.ceil(min_offset))) - hug = max(WIRE_HUG, floor) + floor = int(math.ceil(max(clear.side, min_offset))) + hug = max(int(math.ceil(clear.hug)), floor) offsets = list(range(hug, floor - 1, -1)) if max_offset > hug: offsets += list(range(hug + 1, int(max_offset) + 1)) for offset in offsets: lane = edge_x + side * offset - if vertical_run_clearance(lane, run_lo, run_hi, boxes) < WIRE_MIN_CLEAR - LINK_EPS: + if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS: continue - if any( - abs(lane - tx) < WIRE_LANE_GAP - LINK_EPS - and not (run_hi < t_lo - LINK_EPS or run_lo > t_hi + LINK_EPS) - for tx, t_lo, t_hi in claimed - ): + if lane_is_crowded(lane, run_lo, run_hi, claimed, pins, clear.lane): continue return lane return None -def row_is_stacked(y, x_lo, x_hi, rows, owner): - """True if a horizontal run here would hide under another wire's. +def row_is_stacked(y, x_lo, x_hi, rows, pins, apart): + """True if a horizontal run here would hide under, or blur into, another wire's. - Runs belonging to the same source pin are ignored: several wires leaving one output - are the same signal, and sharing the run out of the pin is how a fan-out should look. + A wire sharing a pin may lie exactly on top: several wires leaving one output are the + same signal, and sharing the run out of the pin is how a fan-out should look. """ - return any( - row_owner != owner - and abs(y - ry) < WIRE_ROW_GAP - LINK_EPS - and not (x_hi < rx1 - LINK_EPS or x_lo > rx2 + LINK_EPS) - for ry, rx1, rx2, row_owner in rows - ) + return any(runs_clash(y, x_lo, x_hi, ry, rx1, rx2, pins, row_pins, apart) + for ry, rx1, rx2, row_pins in rows) -def free_rows(cy, x_lo, x_hi, rows, owner, max_steps=8): +def free_rows(cy, x_lo, x_hi, rows, pins, apart, max_steps=8): """Heights near cy, closest first, where no other wire is already running. - A row is skipped while another wire's horizontal run sits within WIRE_ROW_GAP of - it and overlaps it along x -- that is the case where one line hides under another. + A row is skipped while another wire's horizontal run sits within `apart` of it and + overlaps it along x -- that is the case where one line hides under another. Every clear row is offered rather than only the nearest, because the nearest may still turn out to cross a box once the whole path is assembled. """ out = [] for step in range(0, max_steps): for sign in ((0,) if step == 0 else (1, -1)): - row = cy + sign * step * WIRE_ROW_GAP - if not row_is_stacked(row, x_lo, x_hi, rows, owner): + row = cy + sign * step * apart + if not row_is_stacked(row, x_lo, x_hi, rows, pins, apart): out.append(row) return out def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, - direct_only=False, owner=None): + direct_only=False, pins=None, clear=None): """Routes an orthogonal wire from a source slot to a target slot around every box. Returns None when no path can reach the target without passing under a component. @@ -442,16 +517,19 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, exactly as it shows one whose far end lives on another sheet. `taken` is the sheet's ledger of what other wires have already claimed: - {"cols": [(x, y1, y2), ...], "rows": [(y, x1, x2), ...]}. Each new wire claims a - vertical lane and a horizontal lane of its own against it; pass None to route in - isolation. The search relaxes in tiers: a wire that can only be drawn with its run - standing off further than WIRE_HUG is still far better than no wire at all. + {"cols": [(x, y1, y2, pins), ...], "rows": [(y, x1, x2, pins), ...]}. Each new wire + claims a vertical lane and a horizontal lane of its own against it; pass None to + route in isolation. `pins` are the slots at the wire's two ends (see same_signal). The search relaxes in tiers: a wire that can only be drawn with its run + standing off further than it would like is still far better than no wire at all. + `clear` is the spacing kept from the boxes, grid_clearance() unless given. """ + clear = clear or grid_clearance() start = (src_box[2], src_y) end = (dst_box[0], dst_y) - leave = (start[0] + LINK_STUB, src_y) - # The last turn stands arrive_turn() in front of the target, never closer. - arrive = (end[0] - max(LINK_STUB, arrive_turn()), dst_y) + # The first turn stands clear.side out from the source, the last clear.arrive in + # front of the target, never closer. + leave = (start[0] + clear.side, src_y) + arrive = (end[0] - clear.arrive, dst_y) low, high = min(leave[0], arrive[0]), max(leave[0], arrive[0]) run_lo, run_hi = min(src_y, dst_y), max(src_y, dst_y) @@ -461,12 +539,17 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, def rows_of(points): """The horizontal runs a finished path lays down, for the ledger.""" + points = wire_style.simplify(points) return [ - (y1, min(x1, x2), max(x1, x2), owner) + (y1, min(x1, x2), max(x1, x2), pins) for (x1, y1), (x2, y2) in zip(points, points[1:]) if abs(y1 - y2) < LINK_EPS and abs(x1 - x2) > LINK_EPS ] + def stacked(candidate): + return any(row_is_stacked(y, x1, x2, claimed_rows, pins, clear.row) + for y, x1, x2, _ in rows_of(candidate)) + def straight_through(max_steps, allow_stack=False): """A single vertical channel in the gap between the two boxes. @@ -474,33 +557,31 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, they are refused when they would hide under another wire: the wire is better off detouring, which puts it on a lane it can actually move. """ - candidates = channel_candidates(src_box, dst_box, boxes, low, high) + candidates = channel_candidates(src_box, dst_box, boxes, low, high, clear) for cx in candidates: - lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, max_steps) + lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear, + max_steps, pins) if lane is None: continue candidate = [start, leave, (lane, src_y), (lane, dst_y), arrive, end] - if not path_is_clear(candidate, boxes): + if not path_is_clear(candidate, boxes, clear): continue - if not allow_stack and any( - row_is_stacked(y, x1, x2, claimed_rows, owner) - for y, x1, x2, _ in rows_of(candidate) - ): + if not allow_stack and stacked(candidate): continue - return candidate, [(lane, run_lo, run_hi)] + return candidate, [(lane, run_lo, run_hi, pins)] return None, None def around_boxes(max_offset, allow_stack=False): """Over or under everything, hugging each end. Covers right-to-left feedback. - With `allow_stack` the long run may settle on a row another wire already uses, - which is a last resort: a wire sharing a row is still far better than a link - shown only as knobs. + With `allow_stack` a run may settle on a row another wire already uses, which is + a last resort: a wire sharing a row is still far better than a link shown only + as knobs. """ lanes = set() for _, by1, _, by2 in boxes: - lanes.add(by1 - LINK_CLEAR) - lanes.add(by2 + LINK_CLEAR) + lanes.add(by1 - clear.end) + lanes.add(by2 + clear.end) # Also offer the clear air above and below the whole sheet, and the middle of # each horizontal band between one row of boxes and the next. A lane pinned to @@ -508,23 +589,25 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, # middle of a band usually crosses the sheet unobstructed. tops = sorted({b[1] for b in boxes}) bottoms = sorted({b[3] for b in boxes}) - lanes.add(min(tops) - LINK_CLEAR) - lanes.add(max(bottoms) + LINK_CLEAR) + lanes.add(min(tops) - clear.end) + lanes.add(max(bottoms) + clear.end) for bottom in bottoms: - nearest_below = [t for t in tops if t > bottom + 2 * LINK_CLEAR] + nearest_below = [t for t in tops if t > bottom + 2 * clear.end] if nearest_below: lanes.add((bottom + min(nearest_below)) / 2) midline = (src_y + dst_y) / 2 for base_cy in sorted(lanes, key=lambda c: abs(c - midline)): out_x = hug_lane(src_box[2], 1, min(src_y, base_cy), max(src_y, base_cy), - boxes, claimed, max_offset) + boxes, claimed, clear, max_offset, pins=pins) if out_x is None: continue + # Checked against its own outward run too, but as another wire's: the two + # ends of one wire must never fold back onto each other. in_x = hug_lane(dst_box[0], -1, min(base_cy, dst_y), max(base_cy, dst_y), boxes, claimed + [(out_x, min(src_y, base_cy), - max(src_y, base_cy))], max_offset, - min_offset=arrive_turn()) + max(src_y, base_cy), None)], clear, + max_offset, min_offset=clear.arrive, pins=pins) if in_x is None: continue @@ -532,7 +615,7 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, # off any other wire already crossing at that height. Each clear height is # tried in turn, since a height clear of wires can still cross a box. span_lo, span_hi = min(out_x, in_x), max(out_x, in_x) - options = free_rows(base_cy, span_lo, span_hi, claimed_rows, owner) + options = free_rows(base_cy, span_lo, span_hi, claimed_rows, pins, clear.row) if allow_stack and base_cy not in options: options.append(base_cy) # last resort: share the row rather than drop the wire @@ -544,16 +627,22 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, # row lengthens both vertical runs, so their clearance is checked # again over the spans they actually cover. if (vertical_run_clearance(out_x, out_lo, out_hi, boxes) - < WIRE_MIN_CLEAR - LINK_EPS): + < clear.side - LINK_EPS): continue if (vertical_run_clearance(in_x, in_lo, in_hi, boxes) - < WIRE_MIN_CLEAR - LINK_EPS): + < clear.side - LINK_EPS): continue candidate = [start, (out_x, src_y), (out_x, cy), (in_x, cy), (in_x, dst_y), end] - if path_is_clear(candidate, boxes): - return candidate, [(out_x, out_lo, out_hi), (in_x, in_lo, in_hi)] + if not path_is_clear(candidate, boxes, clear): + continue + # The runs out of and into the slots are pinned to their rows, so they + # are checked here: the long run was already kept clear by free_rows. + if not allow_stack and stacked(candidate): + continue + return candidate, [(out_x, out_lo, out_hi, pins), + (in_x, in_lo, in_hi, pins)] return None, None # Tier by how much of the spacing guidance has to give way. Crossing a box is never @@ -562,26 +651,22 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, # First pass: only the straight route, whose horizontal runs are pinned to their # slots and cannot be moved later. Letting these claim their rows first stops a # detouring wire from parking its movable lane on top of one of them. - attempt_names = ("straight(1)",) attempts = (lambda: straight_through(1),) else: - attempt_names = ( - "straight(1)", "around(hug)", "straight(8)", "around(standoff)", - "straight(1,stack)", "straight(8,stack)", "around(standoff,stack)", - ) + standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side) attempts = ( lambda: straight_through(1), # hugging, own lane - lambda: around_boxes(WIRE_HUG), # hugging, routed around + lambda: around_boxes(clear.hug), # hugging, routed around lambda: straight_through(8), # lane nudged out of the band - lambda: around_boxes(WIRE_STANDOFF_MAX), # standing off, routed around + lambda: around_boxes(standoff), # standing off, routed around # Last resorts, in order. A wire sharing a row with another is still far # better than a link reduced to knobs, so stacking is only reached once # every route that keeps its own row has been ruled out. lambda: straight_through(1, allow_stack=True), lambda: straight_through(8, allow_stack=True), - lambda: around_boxes(WIRE_STANDOFF_MAX, allow_stack=True), + lambda: around_boxes(standoff, allow_stack=True), ) - for name, attempt in zip(attempt_names, attempts): + for attempt in attempts: candidate, runs = attempt() if candidate is not None: if taken is not None: @@ -646,50 +731,47 @@ LINK_SELECT_WIDTH = 2 LINK_HINT_DASH = (5, 3) # dashes of the line shown between a selected pair of knobs -def draw_links(): - """Paints wires between slots on this sheet and knobs for links leaving it.""" - canvas = app_state.canvas - canvas.delete("link") - app_state.canvas_link_map.clear() - app_state.knob_link_ends.clear() - app_state.selected_link_tag = None - if app_state.xml_root_element is None: - return +@dataclass +class WireJob: + """One link with both ends on this sheet, on its way to becoming a wire.""" + tag: str + src_box: tuple + src_y: float + dst_box: tuple + dst_y: float + owner: str # the source pin; its wires branch with a junction dot + target: str # the target pin + points: list = None # the routed path, or None while unrouted / unroutable - links_elem = app_state.xml_root_element.find("links") - if links_elem is None: - return + @property + def pins(self): + """Both end slots, told apart by side since one slot can be either end.""" + return frozenset((("out", self.owner), ("in", self.target))) - path_to_tag = {} - for comp_element, comp_tag in app_state.canvas_tag_by_element.items(): - path_to_tag[get_component_path(comp_element, app_state.xml_root_element)] = comp_tag + def stub_rows(self, clear): + """The two runs pinned to the slots, before the first turn and after the last. - if not path_to_tag: - return + Whatever route the wire takes, these are where it has to be, so they are + claimed for every wire before any is routed. Otherwise an early wire can lay + its long run along a row a later wire's slot is obliged to leave by. + """ + return [(self.src_y, self.src_box[2], self.src_box[2] + clear.side, self.pins), + (self.dst_y, self.dst_box[0] - clear.arrive, self.dst_box[0], self.pins)] - boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b] + def route(self, boxes, ledger, clear, direct_only=False): + self.points = route_link_path(self.src_box, self.src_y, self.dst_box, self.dst_y, + boxes, ledger, direct_only=direct_only, + pins=self.pins, clear=clear) - # What the wires drawn so far have claimed, shared across the whole sheet: vertical - # runs as (x, y1, y2) so two through one gap end up WIRE_LANE_GAP apart, and - # horizontal runs as (y, x1, x2) so no two cross the sheet at the same height. - claimed_runs = {"cols": [], "rows": []} - jobs = [] # [wire_tag, src_box, src_y, dst_box, dst_y, points, owner] - unroutable = 0 - link_index = 0 + def knob_ends(self): + """Where the two knobs of an unroutable link sit: (x1, y1, x2, y2).""" + return (self.src_box[2] + KNOB_RADIUS, self.src_y, + self.dst_box[0] - KNOB_RADIUS, self.dst_y) - # Knobs are gathered per slot rather than drawn as they come up, because a slot - # carrying both an off-sheet link and an unreachable one on this sheet is drawn as - # a single half-and-half marker. - knobs = {} # (x, y) -> {"on_sheet": bool, "off_sheet": bool, "tags": set} - def want_knob(x, y, kind, wire_tag=None): - entry = knobs.setdefault( - (round(x, 1), round(y, 1)), - {"on_sheet": False, "off_sheet": False, "tags": set()}, - ) - entry[kind] = True - if wire_tag: - entry["tags"].add(wire_tag) +def collect_link_jobs(links_elem, path_to_tag, want_knob): + """Turns the sheet's s into wire jobs, and off-sheet ones into knob requests.""" + jobs = [] for link in links_elem.findall("link"): from_ref = link.get("from", "") to_ref = link.get("to", "") @@ -698,91 +780,144 @@ def draw_links(): from_path, from_slot = from_ref.rsplit(".", 1) to_path, to_slot = to_ref.rsplit(".", 1) - src_tag = path_to_tag.get(from_path) dst_tag = path_to_tag.get(to_path) + if not (src_tag or dst_tag): + continue + wire_tag = f"link_wire_{len(app_state.canvas_link_map)}" if src_tag and dst_tag: src_box, src_y = resolve_slot_anchor(src_tag, from_slot) dst_box, dst_y = resolve_slot_anchor(dst_tag, to_slot) if src_box is None or dst_box is None: continue - - wire_tag = f"link_wire_{link_index}" - link_index += 1 app_state.canvas_link_map[wire_tag] = link - jobs.append([wire_tag, src_box, src_y, dst_box, dst_y, None, from_ref]) - elif src_tag or dst_tag: - # One end is on another sheet. The knob still stands for a real link, so - # it is tagged and registered like a wire and can be picked and deleted. - if src_tag: - box, slot_y = resolve_slot_anchor(src_tag, from_slot) - knob_x = box[2] + KNOB_RADIUS if box is not None else None - else: - box, slot_y = resolve_slot_anchor(dst_tag, to_slot) - knob_x = box[0] - KNOB_RADIUS if box is not None else None - if box is None: - continue - - wire_tag = f"link_wire_{link_index}" - link_index += 1 - app_state.canvas_link_map[wire_tag] = link - want_knob(knob_x, slot_y, "off_sheet", wire_tag) - - # Sort by horizontal gap distance so shorter, simpler links claim lanes first. - # Ties are broken by vertical span, keeping the cheapest wires at the front. - jobs.sort(key=lambda j: (abs(j[3][0] - j[1][2]), abs(j[4] - j[2]))) - - # Route in two passes. A wire that can run straight has its horizontal segments - # pinned to its two slots, so it gets first claim on those rows; wires that have to - # detour then pick lanes that keep clear of them. Routing in one pass lets a detour - # settle on a row a later straight wire is obliged to use. - for job in jobs: - wire_tag, src_box, src_y, dst_box, dst_y, _, owner = job - job[5] = route_link_path(src_box, src_y, dst_box, dst_y, boxes, - claimed_runs, direct_only=True, owner=owner) - for job in jobs: - if job[5] is None: - wire_tag, src_box, src_y, dst_box, dst_y, _, owner = job - job[5] = route_link_path(src_box, src_y, dst_box, dst_y, boxes, - claimed_runs, owner=owner) - - for wire_tag, src_box, src_y, dst_box, dst_y, points, _ in jobs: - if points is None: - # No route that stays out from under the boxes. Rather than hide a wire - # beneath a component, show the link as knobs on both slots -- the same - # marker a link to another sheet gets. Tagged so it stays selectable. - want_knob(src_box[2] + KNOB_RADIUS, src_y, "on_sheet", wire_tag) - want_knob(dst_box[0] - KNOB_RADIUS, dst_y, "on_sheet", wire_tag) - # Both ends are on this sheet, so selecting the link can show where it - # actually goes with a dashed line between the two knobs. - app_state.knob_link_ends[wire_tag] = ( - src_box[2] + KNOB_RADIUS, src_y, dst_box[0] - KNOB_RADIUS, dst_y - ) - unroutable += 1 + jobs.append(WireJob(wire_tag, src_box, src_y, dst_box, dst_y, from_ref, to_ref)) continue - flat = [coord for point in points for coord in point] + # One end is on another sheet. The knob still stands for a real link, so it is + # tagged and registered like a wire and can be picked and deleted. + if src_tag: + box, slot_y = resolve_slot_anchor(src_tag, from_slot) + knob_x = box[2] + KNOB_RADIUS if box is not None else None + else: + box, slot_y = resolve_slot_anchor(dst_tag, to_slot) + knob_x = box[0] - KNOB_RADIUS if box is not None else None + if box is None: + continue + app_state.canvas_link_map[wire_tag] = link + want_knob(knob_x, slot_y, "off_sheet", wire_tag) + return jobs + + +def paint_wires(canvas, jobs): + """Draws routed wires: rounded bends, hops over crossings, dots where a fan-out splits. + + Junction dots go down first so the wires sit on top of them; a click near a branch + then still lands on a wire rather than on an untagged dot. + """ + for job in jobs: + job.points = wire_style.simplify(job.points) + + by_owner = {} + for job in jobs: + by_owner.setdefault(job.owner, []).append(job.points) + r = wire_style.JUNCTION_RADIUS + for x, y in wire_style.fan_out_junctions(by_owner): + canvas.create_oval(x - r, y - r, x + r, y + r, fill=LINK_COLOR, outline="", + tags=("link", "link_junction")) + + hops = wire_style.find_hops([job.points for job in jobs]) + for job, job_hops in zip(jobs, hops): + flat = [c for point in wire_style.styled_points(job.points, job_hops) for c in point] canvas.create_line( - *flat, fill=LINK_COLOR, width=1, - arrow=tk.LAST, arrowshape=(8, 9, 3), tags=("link", wire_tag) + *flat, fill=LINK_COLOR, width=1, joinstyle=tk.ROUND, + arrow=tk.LAST, arrowshape=(8, 9, 3), tags=("link", job.tag) ) + +def draw_links(): + """Paints wires between slots on this sheet and knobs for links leaving it.""" + canvas = app_state.canvas + canvas.delete("link") + app_state.canvas_link_map.clear() + app_state.knob_link_ends.clear() + app_state.selected_link_tag = None + app_state.unroutable_link_count = 0 + if app_state.xml_root_element is None: + return + + links_elem = app_state.xml_root_element.find("links") + if links_elem is None: + return + + path_to_tag = { + get_component_path(comp_element, app_state.xml_root_element): comp_tag + for comp_element, comp_tag in app_state.canvas_tag_by_element.items() + } + if not path_to_tag: + return + + # Knobs are gathered per slot rather than drawn as they come up, because a slot + # carrying both an off-sheet link and an unreachable one on this sheet is drawn as + # a single half-and-half marker. + knobs = {} # (x, y) -> {"on_sheet": bool, "off_sheet": bool, "tags": set} + + def want_knob(x, y, kind, wire_tag): + entry = knobs.setdefault( + (round(x, 1), round(y, 1)), + {"on_sheet": False, "off_sheet": False, "tags": set()}, + ) + entry[kind] = True + entry["tags"].add(wire_tag) + + jobs = collect_link_jobs(links_elem, path_to_tag, want_knob) + boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b] + + # Shorter, simpler links claim lanes first; ties go to the smaller vertical span. + jobs.sort(key=lambda j: (abs(j.dst_box[0] - j.src_box[2]), abs(j.dst_y - j.src_y))) + + # What the wires placed so far have claimed, shared across the whole sheet. + # Routed in two passes: a wire that can run straight has its horizontal runs pinned + # to its two slots, so it gets first claim on those rows; wires that have to detour + # then pick lanes that keep clear of them. + # Every wire is offered the full grid spacing before any wire settles for less: boxes + # placed closer than the grid rule allows still get a wire, just a tighter one. + roomy = grid_clearance() + ledger = {"cols": [], "rows": [row for job in jobs for row in job.stub_rows(roomy)]} + for job in jobs: + job.route(boxes, ledger, roomy, direct_only=True) + for clear in (roomy, tight_clearance()): + for job in jobs: + if job.points is None: + job.route(boxes, ledger, clear) + + routed = [job for job in jobs if job.points is not None] + paint_wires(canvas, routed) + + # No route stays out from under the boxes: rather than hide a wire beneath a + # component, show the link as knobs on both slots -- the marker a link to another + # sheet gets -- and remember the ends so selecting it can draw a dashed hint. + for job in jobs: + if job.points is None: + x1, y1, x2, y2 = job.knob_ends() + want_knob(x1, y1, "on_sheet", job.tag) + want_knob(x2, y2, "on_sheet", job.tag) + app_state.knob_link_ends[job.tag] = job.knob_ends() + for (x, y), entry in knobs.items(): draw_knob(canvas, x, y, entry["on_sheet"], entry["off_sheet"], tuple(entry["tags"])) # Wires sit behind the boxes so they never paint over a neighbouring component. - # Nothing should actually be back there now, since a wire that cannot avoid a box is - # drawn as knobs instead. Knobs themselves are raised back above: they sit just - # outside their own box edge, and while they are underneath it a click on one lands - # on the box instead of selecting the link. + # Knobs are raised back above: they sit just outside their own box edge, and while + # underneath it a click on one lands on the box instead of selecting the link. if canvas.find_withtag("component"): canvas.tag_lower("link", "component") if canvas.find_withtag("link_knob"): canvas.tag_raise("link_knob", "component") - app_state.unroutable_link_count = unroutable + app_state.unroutable_link_count = len(jobs) - len(routed) _selected_link_restore = [] # [(item, fill, outline, width)] captured when a link is selected @@ -1221,12 +1356,13 @@ def wire_demand_by_boundary(column_of_comp): def column_gap_for(run_count): """Clear space a column gap needs to carry `run_count` vertical wire runs. - Lanes start WIRE_MIN_CLEAR out from the source box and arrive_turn() in front of - the target box, and stand WIRE_LANE_GAP apart, so n of them span - WIRE_MIN_CLEAR + arrive_turn() + (n - 1) * WIRE_LANE_GAP. + Lanes start clear.side out from the source box and clear.arrive in front of the + target box, and stand clear.lane apart, so n of them span + clear.side + clear.arrive + (n - 1) * clear.lane. """ - return max(TIDY_GAP, WIRE_MIN_CLEAR + arrive_turn() - + max(0, run_count - 1) * WIRE_LANE_GAP) + clear = grid_clearance() + return max(TIDY_GAP, clear.side + clear.arrive + + max(0, run_count - 1) * clear.lane) def plan_columns(columns, column_keys, demand, scale):