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>
This commit is contained in:
2026-10-07 09:37:35 +03:00
co-authored by Claude Opus 5.5
parent 0c846fcc96
commit 8a0051380a
3 changed files with 551 additions and 216 deletions
+351 -215
View File
@@ -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 <link>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):