Tidy Layout straightens wires; router avoids crossings and box edges
Tidy Layout - Box heights are settled in left-to-right and right-to-left sweeps so each box lines its slot up with the other end of its wires; gaps above the minimum are free, and columns are re-sorted to untangle crossings. - A wire that skips columns takes a placeholder in each column it crosses, so boxes are ordered and spaced around it. - A box nothing feeds is held back far enough left that wires from further left always enter higher slots of the same target. Wire routing - Only the short stubs at a wire's two slots may pass close to a box; the rest of every run keeps its clearance, so no wire grazes a box edge. A link with no clear route is shown as knobs. - Lanes that cross no other wire are tried first; climbing wires route top source first, falling ones bottom first, and a deferred wire defers the rest of its gap so lanes keep that order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
|||||||
import configparser
|
import configparser
|
||||||
import os
|
import os
|
||||||
|
|
||||||
VERSION = "0.0.0.175"
|
VERSION = "0.0.0.176"
|
||||||
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||||
DEFAULT_GRID_SIZE = 10
|
DEFAULT_GRID_SIZE = 10
|
||||||
DEFAULT_ADD_TIMESTAMP = True
|
DEFAULT_ADD_TIMESTAMP = True
|
||||||
|
|||||||
+285
-71
@@ -352,18 +352,32 @@ def segment_hits_boxes(x1, y1, x2, y2, boxes):
|
|||||||
def path_is_clear(points, boxes, clear):
|
def path_is_clear(points, boxes, clear):
|
||||||
"""True if no part of the path runs under a box or crowds one.
|
"""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`
|
Every run keeps `clear` from each box: `end` above and below, `side` left and
|
||||||
above and below, `side` left and right. The two runs pinned to the slots are only
|
right. Only the two stubs pinned to the slots -- `side` out of the source and
|
||||||
held out from under the boxes, since they have to reach their pins wherever the
|
`arrive` into the target -- are held merely out from under the boxes, since they
|
||||||
rows happen to be.
|
have to reach their pins wherever the rows happen to be. A run that carries on
|
||||||
|
level past its stub is still a run, so it may not graze a box edge on the way.
|
||||||
"""
|
"""
|
||||||
points = wire_style.simplify(points)
|
points = wire_style.simplify(points)
|
||||||
padded = [(x1 - clear.side, y1 - clear.end, x2 + clear.side, y2 + clear.end)
|
padded = [(x1 - clear.side, y1 - clear.end, x2 + clear.side, y2 + clear.end)
|
||||||
for x1, y1, x2, y2 in boxes]
|
for x1, y1, x2, y2 in boxes]
|
||||||
last = len(points) - 2
|
last = len(points) - 2
|
||||||
for idx, ((x1, y1), (x2, y2)) in enumerate(zip(points, points[1:])):
|
for idx, ((x1, y1), (x2, y2)) in enumerate(zip(points, points[1:])):
|
||||||
pinned = idx in (0, last)
|
level = abs(y1 - y2) < LINK_EPS
|
||||||
if segment_hits_boxes(x1, y1, x2, y2, boxes if pinned else padded):
|
lo, hi = x1, x2 # the part of the run that must keep its distance
|
||||||
|
if level and idx == 0:
|
||||||
|
lo = x1 + math.copysign(min(clear.side, abs(x2 - x1)), x2 - x1)
|
||||||
|
if level and idx == last:
|
||||||
|
hi = x2 - math.copysign(min(clear.arrive, abs(x2 - lo)), x2 - lo)
|
||||||
|
if idx in (0, last) and level:
|
||||||
|
if segment_hits_boxes(x1, y1, x2, y2, boxes):
|
||||||
|
return False
|
||||||
|
if abs(hi - lo) > LINK_EPS and segment_hits_boxes(lo, y1, hi, y2, padded):
|
||||||
|
return False
|
||||||
|
elif idx in (0, last):
|
||||||
|
if segment_hits_boxes(x1, y1, x2, y2, boxes):
|
||||||
|
return False
|
||||||
|
elif segment_hits_boxes(x1, y1, x2, y2, padded):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -448,14 +462,15 @@ def lane_is_crowded(lane, run_lo, run_hi, claimed, pins, apart):
|
|||||||
|
|
||||||
|
|
||||||
def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, clear, max_steps=8,
|
def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, clear, max_steps=8,
|
||||||
pins=None):
|
pins=None, accept=None):
|
||||||
"""Nudges cx outwards in `clear.lane` steps until the run has a lane of its own.
|
"""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
|
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
|
would run alongside an already placed run that overlaps it vertically -- two runs
|
||||||
passing through the same gap have to stand `clear.lane` 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
|
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.
|
how the caller asks for the exact hug position and nothing else. `accept`, when
|
||||||
|
given, can turn down a lane that is otherwise free, and the search carries on.
|
||||||
"""
|
"""
|
||||||
for step in range(0, max_steps):
|
for step in range(0, max_steps):
|
||||||
for sign in ((0,) if step == 0 else (1, -1)):
|
for sign in ((0,) if step == 0 else (1, -1)):
|
||||||
@@ -464,11 +479,38 @@ def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, clear, max_steps=8,
|
|||||||
continue
|
continue
|
||||||
if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS:
|
if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS:
|
||||||
continue
|
continue
|
||||||
if not lane_is_crowded(lane, run_lo, run_hi, taken, pins, clear.lane):
|
if lane_is_crowded(lane, run_lo, run_hi, taken, pins, clear.lane):
|
||||||
|
continue
|
||||||
|
if accept is None or accept(lane):
|
||||||
return lane
|
return lane
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def crosses_wires(points, ledger, pins):
|
||||||
|
"""True if the path would cross a run another wire has already laid down.
|
||||||
|
|
||||||
|
A crossing gets a hop, so it is allowed, but a lane that avoids one is worth
|
||||||
|
looking for first. Wires sharing a pin are one signal and never count.
|
||||||
|
"""
|
||||||
|
points = wire_style.simplify(points)
|
||||||
|
for (x1, y1), (x2, y2) in zip(points, points[1:]):
|
||||||
|
if abs(x1 - x2) < LINK_EPS: # vertical: against other wires' rows
|
||||||
|
lo, hi = min(y1, y2), max(y1, y2)
|
||||||
|
for ry, rx1, rx2, row_pins in ledger["rows"]:
|
||||||
|
if (row_pins != pins and not same_signal(pins, row_pins)
|
||||||
|
and rx1 + LINK_EPS < x1 < rx2 - LINK_EPS
|
||||||
|
and lo + LINK_EPS < ry < hi - LINK_EPS):
|
||||||
|
return True
|
||||||
|
else: # horizontal: against their lanes
|
||||||
|
lo, hi = min(x1, x2), max(x1, x2)
|
||||||
|
for cx, c_lo, c_hi, col_pins in ledger["cols"]:
|
||||||
|
if (col_pins != pins and not same_signal(pins, col_pins)
|
||||||
|
and lo + LINK_EPS < cx < hi - LINK_EPS
|
||||||
|
and c_lo + LINK_EPS < y1 < c_hi - LINK_EPS):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, clear, max_offset=0,
|
def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, clear, max_offset=0,
|
||||||
min_offset=0, pins=None):
|
min_offset=0, pins=None):
|
||||||
"""Lane for a run pinned to one box edge, starting `clear.side` out from it.
|
"""Lane for a run pinned to one box edge, starting `clear.side` out from it.
|
||||||
@@ -565,20 +607,36 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None,
|
|||||||
return any(row_is_stacked(y, x1, x2, claimed_rows, pins, clear.row)
|
return any(row_is_stacked(y, x1, x2, claimed_rows, pins, clear.row)
|
||||||
for y, x1, x2, _ in rows_of(candidate))
|
for y, x1, x2, _ in rows_of(candidate))
|
||||||
|
|
||||||
def straight_through(max_steps, allow_stack=False):
|
def path_through(lane):
|
||||||
|
return [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
|
||||||
|
|
||||||
|
def straight_through(max_steps, allow_stack=False, uncrossed=False, hug_only=False):
|
||||||
"""A single vertical channel in the gap between the two boxes.
|
"""A single vertical channel in the gap between the two boxes.
|
||||||
|
|
||||||
The two horizontal runs are pinned to their slots, so unless `allow_stack`
|
The two horizontal runs are pinned to their slots, so unless `allow_stack`
|
||||||
they are refused when they would hide under another wire: the wire is better
|
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.
|
off detouring, which puts it on a lane it can actually move. With `uncrossed`
|
||||||
|
only a lane that crosses no other wire will do; with `hug_only` only the lane
|
||||||
|
beside its own source is tried (beside either end for a level wire), not ones
|
||||||
|
beside the boxes in between.
|
||||||
"""
|
"""
|
||||||
|
accept = None
|
||||||
|
if uncrossed:
|
||||||
|
accept = lambda lane: not crosses_wires(path_through(lane), ledger, pins)
|
||||||
candidates = channel_candidates(src_box, dst_box, boxes, low, high, clear)
|
candidates = channel_candidates(src_box, dst_box, boxes, low, high, clear)
|
||||||
|
if hug_only:
|
||||||
|
# A bent wire hugs its source only: turning at the far end instead would
|
||||||
|
# take the lane the wires below it need to turn outside it.
|
||||||
|
hugs = (src_box[2] + clear.hug,)
|
||||||
|
if abs(src_y - dst_y) < LINK_EPS:
|
||||||
|
hugs += (dst_box[0] - clear.arrive,)
|
||||||
|
candidates = [cx for cx in candidates if cx in hugs]
|
||||||
for cx in candidates:
|
for cx in candidates:
|
||||||
lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear,
|
lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear,
|
||||||
max_steps, pins)
|
max_steps, pins, accept)
|
||||||
if lane is None:
|
if lane is None:
|
||||||
continue
|
continue
|
||||||
candidate = [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
|
candidate = path_through(lane)
|
||||||
if not path_is_clear(candidate, boxes, clear):
|
if not path_is_clear(candidate, boxes, clear):
|
||||||
continue
|
continue
|
||||||
if not allow_stack and stacked(candidate):
|
if not allow_stack and stacked(candidate):
|
||||||
@@ -666,10 +724,15 @@ 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
|
# 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
|
# 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.
|
# detouring wire from parking its movable lane on top of one of them.
|
||||||
attempts = (lambda: straight_through(1),)
|
# Only an uncrossed one, too: a wire that would cross another waits for the
|
||||||
|
# second pass, where it can look further for a lane that does not.
|
||||||
|
# A wire whose hug lane is taken waits as well, rather than jumping to a lane far
|
||||||
|
# across the gap whose long run would then shut out the wires routed after it.
|
||||||
|
attempts = (lambda: straight_through(1, uncrossed=True, hug_only=True),)
|
||||||
else:
|
else:
|
||||||
standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side)
|
standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side)
|
||||||
attempts = (
|
attempts = (
|
||||||
|
lambda: straight_through(8, uncrossed=True), # own lane, crossing nothing
|
||||||
lambda: straight_through(1), # hugging, own lane
|
lambda: straight_through(1), # hugging, own lane
|
||||||
lambda: around_boxes(clear.hug), # hugging, routed around
|
lambda: around_boxes(clear.hug), # hugging, routed around
|
||||||
lambda: straight_through(8), # lane nudged out of the band
|
lambda: straight_through(8), # lane nudged out of the band
|
||||||
@@ -891,8 +954,16 @@ def draw_links():
|
|||||||
jobs = collect_link_jobs(links_elem, path_to_tag, want_knob)
|
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]
|
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.
|
# Links between nearer columns claim lanes first, and straight ones before bent ones.
|
||||||
jobs.sort(key=lambda j: (abs(j.dst_box[0] - j.src_box[2]), abs(j.dst_y - j.src_y)))
|
# Among bent wires leaving one column, the first routed hugs its source closest, so
|
||||||
|
# climbing wires go top source first and falling ones bottom source first: each
|
||||||
|
# later wire then turns outside the earlier ones instead of crossing them.
|
||||||
|
def routing_order(job):
|
||||||
|
bent = abs(job.dst_y - job.src_y) > LINK_EPS
|
||||||
|
climbing = job.dst_y < job.src_y
|
||||||
|
return (round(job.dst_box[0] - job.src_box[0]), bent, not climbing,
|
||||||
|
job.src_y if climbing else -job.src_y)
|
||||||
|
jobs.sort(key=routing_order)
|
||||||
|
|
||||||
# What the wires placed so far have claimed, shared across the whole sheet.
|
# 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
|
# Routed in two passes: a wire that can run straight has its horizontal runs pinned
|
||||||
@@ -902,8 +973,16 @@ def draw_links():
|
|||||||
# placed closer than the grid rule allows still get a wire, just a tighter one.
|
# placed closer than the grid rule allows still get a wire, just a tighter one.
|
||||||
roomy = grid_clearance()
|
roomy = grid_clearance()
|
||||||
ledger = {"cols": [], "rows": [row for job in jobs for row in job.stub_rows(roomy)]}
|
ledger = {"cols": [], "rows": [row for job in jobs for row in job.stub_rows(roomy)]}
|
||||||
|
# A wire that cannot go direct defers the bent wires after it across the same gap
|
||||||
|
# in the same direction too, so they still take their lanes in routing order.
|
||||||
|
deferred = set()
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
|
group = (round(job.src_box[0]), round(job.dst_box[0]), job.dst_y < job.src_y)
|
||||||
|
if group in deferred and abs(job.dst_y - job.src_y) > LINK_EPS:
|
||||||
|
continue
|
||||||
job.route(boxes, ledger, roomy, direct_only=True)
|
job.route(boxes, ledger, roomy, direct_only=True)
|
||||||
|
if job.points is None:
|
||||||
|
deferred.add(group)
|
||||||
for clear in (roomy, tight_clearance()):
|
for clear in (roomy, tight_clearance()):
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
if job.points is None:
|
if job.points is None:
|
||||||
@@ -1450,22 +1529,34 @@ def column_gap_for(run_count):
|
|||||||
+ max(0, run_count - 1) * clear.lane)
|
+ max(0, run_count - 1) * clear.lane)
|
||||||
|
|
||||||
|
|
||||||
def plan_columns(columns, column_keys, demand, scale, spans=()):
|
LAYOUT_SWEEPS = 4 # down-and-up passes plan_columns makes to straighten the wires
|
||||||
|
|
||||||
|
|
||||||
|
def plan_columns(columns, column_keys, demand, scale, wires=()):
|
||||||
"""Walks the columns at `scale` px per meta step, without touching the DOM.
|
"""Walks the columns at `scale` px per meta step, without touching the DOM.
|
||||||
|
|
||||||
Returns (placements, clamped) where a placement is (item, step_x, step_y). Boxes are
|
Returns (placements, clamped) where a placement is (item, step_x, step_y). Boxes are
|
||||||
positioned in pixels exactly as they will be painted, then divided down into meta
|
positioned in pixels exactly as they will be painted, then divided down into meta
|
||||||
steps, so a coarser scale reaches further across the sheet for the same 0..255 range.
|
steps, so a coarser scale reaches further across the sheet for the same 0..255 range.
|
||||||
|
|
||||||
`spans` are the wires that skip columns, as (src_column, dst_column, dst_item,
|
Column x positions come from widths and wire lanes alone. Heights are then settled
|
||||||
dst_slot). The router turns such a wire down beside its source, so it crosses every
|
to make the wires run as straight as they can: each box is offered the height that
|
||||||
column in between level with its target slot; a box in its way is pushed down below
|
lines its slot up with the slot at the other end of its wires (the median, when it
|
||||||
it instead of making the wire detour. Columns are filled right to left for that,
|
has several), and each column is re-sorted by those heights, which also untangles
|
||||||
since a wire's height is set by its target, which always lies further right.
|
crossings. Boxes keep at least TIDY_GAP apart, but any gap above that is free -- a
|
||||||
|
box drops as far as its wires want. Sweeps run left to right, lining boxes up with
|
||||||
|
what feeds them, and right to left, lining them up with what they feed.
|
||||||
|
|
||||||
|
`wires` are (src_column, dst_column, src_item, src_slot, dst_item, dst_slot), left to
|
||||||
|
right only. One that skips columns stands in each column it crosses as a placeholder
|
||||||
|
box one wire tall, so it is sorted and spaced like the boxes there. The router turns
|
||||||
|
such a wire down beside its source, crossing the columns in between level with its
|
||||||
|
target slot, so a box still in its way is pushed down below it. The last sweep runs
|
||||||
|
right to left so those heights are final when that is checked.
|
||||||
"""
|
"""
|
||||||
margin = snap_up(TIDY_MARGIN, scale)
|
margin = snap_up(TIDY_MARGIN, scale)
|
||||||
clear_end = grid_clearance().end
|
clear_end = grid_clearance().end
|
||||||
clamped = 0
|
count = len(column_keys)
|
||||||
|
|
||||||
# Column x positions depend only on widths and wire lanes, so they come first.
|
# Column x positions depend only on widths and wire lanes, so they come first.
|
||||||
column_x = []
|
column_x = []
|
||||||
@@ -1476,50 +1567,141 @@ def plan_columns(columns, column_keys, demand, scale, spans=()):
|
|||||||
cursor_x = snap_up(cursor_x + max(i["width"] for i in columns[grid_x]) + gap,
|
cursor_x = snap_up(cursor_x + max(i["width"] for i in columns[grid_x]) + gap,
|
||||||
scale)
|
scale)
|
||||||
|
|
||||||
top_of = {} # id(item) -> pixel y its box was given
|
order = [list(columns[key]) for key in column_keys]
|
||||||
placed = {} # id(item) -> (step_x, step_y)
|
|
||||||
for index in reversed(range(len(column_keys))):
|
# A wire skipping columns gets a placeholder in each column it crosses, as tall as a
|
||||||
|
# wire, so it is sorted and spaced among that column's boxes like one of them. That
|
||||||
|
# is what keeps a box from being placed on the wrong side of a wire it then has to
|
||||||
|
# cross. The wire is modelled as a chain through its placeholders, so every link of
|
||||||
|
# the chain joins neighbouring columns.
|
||||||
|
segments = [] # (a, a_slot, b, b_slot), a one column left of b
|
||||||
|
for src_col, dst_col, src, s_slot, dst, d_slot in wires:
|
||||||
|
previous, previous_slot = src, s_slot
|
||||||
|
for index in range(src_col + 1, dst_col):
|
||||||
|
placeholder = {"wire": True, "height": 0, "slot_offsets": {}}
|
||||||
|
order[index].append(placeholder)
|
||||||
|
segments.append((previous, previous_slot, placeholder, None))
|
||||||
|
previous, previous_slot = placeholder, None
|
||||||
|
segments.append((previous, previous_slot, dst, d_slot))
|
||||||
|
|
||||||
|
incoming = {id(item): [] for column in order for item in column}
|
||||||
|
outgoing = {id(item): [] for column in order for item in column}
|
||||||
|
for segment in segments:
|
||||||
|
outgoing[id(segment[0])].append(segment)
|
||||||
|
incoming[id(segment[2])].append(segment)
|
||||||
|
|
||||||
|
def offset(item, slot):
|
||||||
|
if item.get("wire"):
|
||||||
|
return 0
|
||||||
|
return item["slot_offsets"].get(slot, item["height"] / 2)
|
||||||
|
|
||||||
|
def gap(above, below):
|
||||||
|
"""Clear space between two neighbours in a column, boxes or wires."""
|
||||||
|
if above.get("wire") and below.get("wire"):
|
||||||
|
return grid_clearance().row
|
||||||
|
if above.get("wire") or below.get("wire"):
|
||||||
|
return clear_end
|
||||||
|
return TIDY_GAP
|
||||||
|
|
||||||
|
top = {}
|
||||||
|
for column in order: # starting point: packed from the top
|
||||||
|
cursor_y = margin
|
||||||
|
for item in column:
|
||||||
|
top[id(item)] = cursor_y
|
||||||
|
cursor_y = snap_up(cursor_y + item["height"] + TIDY_GAP, scale)
|
||||||
|
|
||||||
|
def wanted_from_left(item):
|
||||||
|
return [(top[id(a)] + offset(a, a_slot) - offset(item, b_slot), offset(item, b_slot))
|
||||||
|
for a, a_slot, _, b_slot in incoming[id(item)]]
|
||||||
|
|
||||||
|
def wanted_from_right(item):
|
||||||
|
return [(top[id(b)] + offset(b, b_slot) - offset(item, a_slot), offset(item, a_slot))
|
||||||
|
for _, a_slot, b, b_slot in outgoing[id(item)]]
|
||||||
|
|
||||||
|
def place(index, primary, fallback):
|
||||||
|
column = order[index]
|
||||||
|
wanted = {}
|
||||||
|
anchor = {}
|
||||||
|
for item in column:
|
||||||
|
if item.get("loose"):
|
||||||
|
continue
|
||||||
|
heights = sorted(primary(item) or fallback(item))
|
||||||
|
if heights:
|
||||||
|
spot, slot_offset = heights[(len(heights) - 1) // 2]
|
||||||
|
else:
|
||||||
|
spot, slot_offset = top[id(item)], 0
|
||||||
|
wanted[id(item)] = spot
|
||||||
|
anchor[id(item)] = spot + slot_offset # the wire height it lines up with
|
||||||
|
# Sorted by the wire each box lines up with, so neighbours' wires do not cross;
|
||||||
|
# unlinked boxes stay packed below everything else, in their own order.
|
||||||
|
column.sort(key=lambda i: (bool(i.get("loose")), anchor.get(id(i), 0)))
|
||||||
|
|
||||||
rows = sorted(
|
rows = sorted(
|
||||||
top_of[id(dst_item)] + dst_item["slot_offsets"].get(
|
top[id(dst)] + offset(dst, d_slot)
|
||||||
dst_slot, dst_item["height"] / 2)
|
for src_col, dst_col, _, _, dst, d_slot in wires
|
||||||
for src_col, dst_col, dst_item, dst_slot in spans
|
|
||||||
if src_col < index < dst_col
|
if src_col < index < dst_col
|
||||||
)
|
)
|
||||||
cursor_y = margin
|
floor_y = margin
|
||||||
|
above = None
|
||||||
for item in columns[column_keys[index]]: # already in top-to-bottom order
|
for item in column:
|
||||||
top = cursor_y
|
if above is not None:
|
||||||
# Step below every passing wire the box would sit across, keeping the
|
floor_y = above_bottom + gap(above, item)
|
||||||
# clearance the router wants between a horizontal run and a box edge.
|
if item.get("loose"):
|
||||||
|
spot = snap_up(floor_y, scale)
|
||||||
|
elif item.get("wire"):
|
||||||
|
spot = max(floor_y, wanted[id(item)]) # never stored, so not snapped
|
||||||
|
else:
|
||||||
|
spot = max(snap_up(floor_y, scale), round(wanted[id(item)] / scale) * scale)
|
||||||
|
if not item.get("wire"):
|
||||||
|
# Step below every passing wire the box would still sit across, keeping
|
||||||
|
# the clearance the router wants between a horizontal run and a box.
|
||||||
moved = True
|
moved = True
|
||||||
while moved:
|
while moved:
|
||||||
moved = False
|
moved = False
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if top - clear_end < row < top + item["height"] + clear_end:
|
if spot - clear_end < row < spot + item["height"] + clear_end:
|
||||||
top = snap_up(row + clear_end, scale)
|
spot = snap_up(row + clear_end, scale)
|
||||||
moved = True
|
moved = True
|
||||||
|
top[id(item)] = spot
|
||||||
|
above, above_bottom = item, spot + item["height"]
|
||||||
|
|
||||||
top_of[id(item)] = top
|
def lift():
|
||||||
|
"""Shifts the whole sheet up until its top-most box sits on the margin again.
|
||||||
|
|
||||||
|
Boxes only ever settle downwards, so without this the sweeps would carry the
|
||||||
|
whole picture further down the sheet each time round.
|
||||||
|
"""
|
||||||
|
tops = [top[id(i)] for column in order for i in column
|
||||||
|
if not i.get("loose") and not i.get("wire")]
|
||||||
|
shift = (min(tops, default=margin) - margin) // scale * scale
|
||||||
|
if shift > 0:
|
||||||
|
for key in top:
|
||||||
|
top[key] -= shift
|
||||||
|
|
||||||
|
for _ in range(LAYOUT_SWEEPS):
|
||||||
|
for index in range(count):
|
||||||
|
place(index, wanted_from_left, wanted_from_right)
|
||||||
|
lift()
|
||||||
|
for index in reversed(range(count)):
|
||||||
|
place(index, wanted_from_right, wanted_from_left)
|
||||||
|
lift()
|
||||||
|
|
||||||
|
placements = []
|
||||||
|
clamped = 0
|
||||||
|
for index, column in enumerate(order):
|
||||||
|
for item in column:
|
||||||
|
if item.get("wire"):
|
||||||
|
continue
|
||||||
step_x = min(META_COORD_MAX, round(column_x[index] / scale))
|
step_x = min(META_COORD_MAX, round(column_x[index] / scale))
|
||||||
step_y = min(META_COORD_MAX, round(top / scale))
|
step_y = min(META_COORD_MAX, round(top[id(item)] / scale))
|
||||||
if step_x == META_COORD_MAX or step_y == META_COORD_MAX:
|
if step_x == META_COORD_MAX or step_y == META_COORD_MAX:
|
||||||
clamped += 1
|
clamped += 1
|
||||||
placed[id(item)] = (step_x, step_y)
|
placements.append((item, step_x, step_y))
|
||||||
|
|
||||||
# Clear the box just placed by at least TIDY_GAP, then step to the next
|
|
||||||
# whole meta step so the box still lands on a storable position.
|
|
||||||
cursor_y = snap_up(top + item["height"] + TIDY_GAP, scale)
|
|
||||||
|
|
||||||
placements = [
|
|
||||||
(item, *placed[id(item)])
|
|
||||||
for grid_x in column_keys
|
|
||||||
for item in columns[grid_x]
|
|
||||||
]
|
|
||||||
return placements, clamped
|
return placements, clamped
|
||||||
|
|
||||||
|
|
||||||
def sheet_link_pairs(components):
|
def sheet_link_pairs(components):
|
||||||
"""(source, target) for each link between two different components on this sheet."""
|
"""(source, src_slot, target, dst_slot) for each link between two components here."""
|
||||||
root = app_state.xml_root_element
|
root = app_state.xml_root_element
|
||||||
pairs = []
|
pairs = []
|
||||||
if root is None:
|
if root is None:
|
||||||
@@ -1533,10 +1715,12 @@ def sheet_link_pairs(components):
|
|||||||
from_ref, to_ref = link.get("from", ""), link.get("to", "")
|
from_ref, to_ref = link.get("from", ""), link.get("to", "")
|
||||||
if "." not in from_ref or "." not in to_ref:
|
if "." not in from_ref or "." not in to_ref:
|
||||||
continue
|
continue
|
||||||
src = comp_by_path.get(from_ref.rsplit(".", 1)[0])
|
src_path, src_slot = from_ref.rsplit(".", 1)
|
||||||
dst = comp_by_path.get(to_ref.rsplit(".", 1)[0])
|
dst_path, dst_slot = to_ref.rsplit(".", 1)
|
||||||
|
src = comp_by_path.get(src_path)
|
||||||
|
dst = comp_by_path.get(dst_path)
|
||||||
if src is not None and dst is not None and src is not dst:
|
if src is not None and dst is not None and src is not dst:
|
||||||
pairs.append((src, dst))
|
pairs.append((src, src_slot, dst, dst_slot))
|
||||||
return pairs
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
@@ -1555,7 +1739,7 @@ def flow_columns(items, pairs):
|
|||||||
|
|
||||||
item_of = {item["comp"]: item for item in items}
|
item_of = {item["comp"]: item for item in items}
|
||||||
succs = {id(item): [] for item in items}
|
succs = {id(item): [] for item in items}
|
||||||
for src, dst in pairs:
|
for src, _, dst, _ in pairs:
|
||||||
if item_of[dst] not in succs[id(item_of[src])]:
|
if item_of[dst] not in succs[id(item_of[src])]:
|
||||||
succs[id(item_of[src])].append(item_of[dst])
|
succs[id(item_of[src])].append(item_of[dst])
|
||||||
for out in succs.values():
|
for out in succs.values():
|
||||||
@@ -1606,6 +1790,32 @@ def flow_columns(items, pairs):
|
|||||||
if not preds[id(node)] and forward[id(node)]:
|
if not preds[id(node)] and forward[id(node)]:
|
||||||
layer[id(node)] = min(layer[id(n)] for n in forward[id(node)]) - 1
|
layer[id(node)] = min(layer[id(n)] for n in forward[id(node)]) - 1
|
||||||
|
|
||||||
|
# Into any one box, wires from further left must arrive at higher slots than wires
|
||||||
|
# from nearer columns. A long wire crosses the columns in between level with its
|
||||||
|
# slot, so a nearer box feeding a slot above it would have to cross it on the way.
|
||||||
|
# Only boxes nothing feeds are free to move, so they are held back far enough left.
|
||||||
|
feeders = {} # id(target) -> [(slot height, source)]
|
||||||
|
for src, _, dst, d_slot in pairs:
|
||||||
|
src_item, dst_item = item_of[src], item_of[dst]
|
||||||
|
if dst_item in forward[id(src_item)]:
|
||||||
|
feeders.setdefault(id(dst_item), []).append(
|
||||||
|
(dst_item["slot_offsets"].get(d_slot, 0), src_item))
|
||||||
|
sources = [n for n in order if not preds[id(n)] and forward[id(n)]]
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for node in sources:
|
||||||
|
limit = layer[id(node)]
|
||||||
|
for target_id, fed in feeders.items():
|
||||||
|
own = [height for height, src in fed if src is node]
|
||||||
|
for height, other in fed:
|
||||||
|
if other is not node and own and height > min(own):
|
||||||
|
limit = min(limit, layer[id(other)])
|
||||||
|
limit = max(0, limit)
|
||||||
|
if limit < layer[id(node)]:
|
||||||
|
layer[id(node)] = limit
|
||||||
|
changed = True
|
||||||
|
|
||||||
columns = [[] for _ in range(max(layer.values(), default=-1) + 1)]
|
columns = [[] for _ in range(max(layer.values(), default=-1) + 1)]
|
||||||
for item in sorted(items, key=lambda i: (i["grid_y"], i["grid_x"])):
|
for item in sorted(items, key=lambda i: (i["grid_y"], i["grid_x"])):
|
||||||
columns[layer[id(item)]].append(item)
|
columns[layer[id(item)]].append(item)
|
||||||
@@ -1633,7 +1843,7 @@ def flow_columns(items, pairs):
|
|||||||
def build_layout_items(components):
|
def build_layout_items(components):
|
||||||
"""Column-grouped layout items for a sheet, measured rather than read off the canvas.
|
"""Column-grouped layout items for a sheet, measured rather than read off the canvas.
|
||||||
|
|
||||||
Returns (columns, column_keys, demand, spans), each column listed top to bottom.
|
Returns (columns, column_keys, demand, wires), each column listed top to bottom.
|
||||||
Linked boxes are arranged by flow_columns. A box with no link to another box on this
|
Linked boxes are arranged by flow_columns. A box with no link to another box on this
|
||||||
sheet -- unlinked, or linked only off-sheet -- goes to the bottom of the first
|
sheet -- unlinked, or linked only off-sheet -- goes to the bottom of the first
|
||||||
column, alphabetically by name.
|
column, alphabetically by name.
|
||||||
@@ -1643,7 +1853,7 @@ def build_layout_items(components):
|
|||||||
fonts = sheet_fonts()
|
fonts = sheet_fonts()
|
||||||
linked_slot_index = build_linked_slot_index()
|
linked_slot_index = build_linked_slot_index()
|
||||||
pairs = sheet_link_pairs(components)
|
pairs = sheet_link_pairs(components)
|
||||||
on_sheet = {comp for pair in pairs for comp in pair}
|
on_sheet = {comp for src, _, dst, _ in pairs for comp in (src, dst)}
|
||||||
|
|
||||||
linked = []
|
linked = []
|
||||||
loose = []
|
loose = []
|
||||||
@@ -1668,6 +1878,8 @@ def build_layout_items(components):
|
|||||||
if loose:
|
if loose:
|
||||||
loose.sort(key=lambda i: (i["comp"].get("name", "").casefold(),
|
loose.sort(key=lambda i: (i["comp"].get("name", "").casefold(),
|
||||||
i["comp"].get("name", "")))
|
i["comp"].get("name", "")))
|
||||||
|
for item in loose:
|
||||||
|
item["loose"] = True # plan_columns keeps these packed at the bottom
|
||||||
columns.setdefault(0, []).extend(loose)
|
columns.setdefault(0, []).extend(loose)
|
||||||
|
|
||||||
column_keys = sorted(columns)
|
column_keys = sorted(columns)
|
||||||
@@ -1677,19 +1889,20 @@ def build_layout_items(components):
|
|||||||
for item in columns[key]
|
for item in columns[key]
|
||||||
}
|
}
|
||||||
return (columns, column_keys, wire_demand_by_boundary(column_of_comp),
|
return (columns, column_keys, wire_demand_by_boundary(column_of_comp),
|
||||||
column_skipping_spans(columns, column_of_comp))
|
forward_wires(columns, column_of_comp))
|
||||||
|
|
||||||
|
|
||||||
def column_skipping_spans(columns, column_of_comp):
|
def forward_wires(columns, column_of_comp):
|
||||||
"""Wires running left to right past at least one column, for plan_columns.
|
"""Every wire running left to right between two columns, for plan_columns.
|
||||||
|
|
||||||
Each is (src_column, dst_column, dst_item, dst_slot).
|
Each is (src_column, dst_column, src_item, src_slot, dst_item, dst_slot). Feedback
|
||||||
|
wires, running right to left, are left out: they go over or under the boxes anyway.
|
||||||
"""
|
"""
|
||||||
spans = []
|
wires = []
|
||||||
root = app_state.xml_root_element
|
root = app_state.xml_root_element
|
||||||
links_elem = root.find("links") if root is not None else None
|
links_elem = root.find("links") if root is not None else None
|
||||||
if links_elem is None:
|
if links_elem is None:
|
||||||
return spans
|
return wires
|
||||||
|
|
||||||
item_by_path = {
|
item_by_path = {
|
||||||
get_component_path(item["comp"], root): item
|
get_component_path(item["comp"], root): item
|
||||||
@@ -1700,16 +1913,17 @@ def column_skipping_spans(columns, column_of_comp):
|
|||||||
from_ref, to_ref = link.get("from", ""), link.get("to", "")
|
from_ref, to_ref = link.get("from", ""), link.get("to", "")
|
||||||
if "." not in from_ref or "." not in to_ref:
|
if "." not in from_ref or "." not in to_ref:
|
||||||
continue
|
continue
|
||||||
src_item = item_by_path.get(from_ref.rsplit(".", 1)[0])
|
src_path, src_slot = from_ref.rsplit(".", 1)
|
||||||
dst_path, dst_slot = to_ref.rsplit(".", 1)
|
dst_path, dst_slot = to_ref.rsplit(".", 1)
|
||||||
|
src_item = item_by_path.get(src_path)
|
||||||
dst_item = item_by_path.get(dst_path)
|
dst_item = item_by_path.get(dst_path)
|
||||||
if src_item is None or dst_item is None:
|
if src_item is None or dst_item is None:
|
||||||
continue
|
continue
|
||||||
src_col = column_of_comp[src_item["comp"]]
|
src_col = column_of_comp[src_item["comp"]]
|
||||||
dst_col = column_of_comp[dst_item["comp"]]
|
dst_col = column_of_comp[dst_item["comp"]]
|
||||||
if dst_col - src_col >= 2:
|
if dst_col > src_col:
|
||||||
spans.append((src_col, dst_col, dst_item, dst_slot))
|
wires.append((src_col, dst_col, src_item, src_slot, dst_item, dst_slot))
|
||||||
return spans
|
return wires
|
||||||
|
|
||||||
|
|
||||||
def sheet_meta_scale(components):
|
def sheet_meta_scale(components):
|
||||||
@@ -1723,13 +1937,13 @@ def sheet_meta_scale(components):
|
|||||||
if not components:
|
if not components:
|
||||||
return grid_size
|
return grid_size
|
||||||
|
|
||||||
columns, column_keys, demand, spans = build_layout_items(components)
|
columns, column_keys, demand, wires = build_layout_items(components)
|
||||||
if not column_keys:
|
if not column_keys:
|
||||||
return grid_size
|
return grid_size
|
||||||
|
|
||||||
for multiplier in range(1, META_SCALE_MAX + 1):
|
for multiplier in range(1, META_SCALE_MAX + 1):
|
||||||
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier,
|
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier,
|
||||||
spans)
|
wires)
|
||||||
if not clamped:
|
if not clamped:
|
||||||
return grid_size * multiplier
|
return grid_size * multiplier
|
||||||
return grid_size * META_SCALE_MAX
|
return grid_size * META_SCALE_MAX
|
||||||
@@ -1751,12 +1965,12 @@ def tidy_layout():
|
|||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
components = list(app_state.canvas_comp_map.values())
|
components = list(app_state.canvas_comp_map.values())
|
||||||
columns, column_keys, demand, spans = build_layout_items(components)
|
columns, column_keys, demand, wires = build_layout_items(components)
|
||||||
if not column_keys:
|
if not column_keys:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
scale = sheet_meta_scale(components)
|
scale = sheet_meta_scale(components)
|
||||||
placements, clamped = plan_columns(columns, column_keys, demand, scale, spans)
|
placements, clamped = plan_columns(columns, column_keys, demand, scale, wires)
|
||||||
|
|
||||||
changes = [
|
changes = [
|
||||||
(item["comp"], item["meta"], step_x, step_y)
|
(item["comp"], item["meta"], step_x, step_y)
|
||||||
|
|||||||
Reference in New Issue
Block a user