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:
+351
-215
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user