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:
2026-10-18 14:17:04 +03:00
co-authored by Claude Opus 5.5
parent 3d9db54956
commit 0c69f18046
2 changed files with 290 additions and 76 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser
import os
VERSION = "0.0.0.175"
VERSION = "0.0.0.176"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+285 -71
View File
@@ -352,18 +352,32 @@ def 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.
Every run keeps `clear` from each box: `end` above and below, `side` left and
right. Only the two stubs pinned to the slots -- `side` out of the source and
`arrive` into the target -- are held merely out from under the boxes, since they
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)
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):
level = abs(y1 - y2) < LINK_EPS
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 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,
pins=None):
pins=None, accept=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 `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.
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 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
if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS:
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 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,
min_offset=0, pins=None):
"""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)
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.
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
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)
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:
lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear,
max_steps, pins)
max_steps, pins, accept)
if lane is None:
continue
candidate = [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
candidate = path_through(lane)
if not path_is_clear(candidate, boxes, clear):
continue
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
# 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.
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:
standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side)
attempts = (
lambda: straight_through(8, uncrossed=True), # own lane, crossing nothing
lambda: straight_through(1), # hugging, own lane
lambda: around_boxes(clear.hug), # hugging, routed around
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)
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)))
# Links between nearer columns claim lanes first, and straight ones before bent ones.
# 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.
# 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.
roomy = grid_clearance()
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:
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)
if job.points is None:
deferred.add(group)
for clear in (roomy, tight_clearance()):
for job in jobs:
if job.points is None:
@@ -1450,22 +1529,34 @@ def column_gap_for(run_count):
+ 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.
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
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,
dst_slot). The router turns such a wire down beside its source, so it crosses every
column in between level with its target slot; a box in its way is pushed down below
it instead of making the wire detour. Columns are filled right to left for that,
since a wire's height is set by its target, which always lies further right.
Column x positions come from widths and wire lanes alone. Heights are then settled
to make the wires run as straight as they can: each box is offered the height that
lines its slot up with the slot at the other end of its wires (the median, when it
has several), and each column is re-sorted by those heights, which also untangles
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)
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 = []
@@ -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,
scale)
top_of = {} # id(item) -> pixel y its box was given
placed = {} # id(item) -> (step_x, step_y)
for index in reversed(range(len(column_keys))):
order = [list(columns[key]) for key in 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(
top_of[id(dst_item)] + dst_item["slot_offsets"].get(
dst_slot, dst_item["height"] / 2)
for src_col, dst_col, dst_item, dst_slot in spans
top[id(dst)] + offset(dst, d_slot)
for src_col, dst_col, _, _, dst, d_slot in wires
if src_col < index < dst_col
)
cursor_y = margin
for item in columns[column_keys[index]]: # already in top-to-bottom order
top = cursor_y
# Step below every passing wire the box would sit across, keeping the
# clearance the router wants between a horizontal run and a box edge.
floor_y = margin
above = None
for item in column:
if above is not None:
floor_y = above_bottom + gap(above, item)
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
while moved:
moved = False
for row in rows:
if top - clear_end < row < top + item["height"] + clear_end:
top = snap_up(row + clear_end, scale)
if spot - clear_end < row < spot + item["height"] + clear_end:
spot = snap_up(row + clear_end, scale)
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_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:
clamped += 1
placed[id(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]
]
placements.append((item, step_x, step_y))
return placements, clamped
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
pairs = []
if root is None:
@@ -1533,10 +1715,12 @@ def sheet_link_pairs(components):
from_ref, to_ref = link.get("from", ""), link.get("to", "")
if "." not in from_ref or "." not in to_ref:
continue
src = comp_by_path.get(from_ref.rsplit(".", 1)[0])
dst = comp_by_path.get(to_ref.rsplit(".", 1)[0])
src_path, src_slot = from_ref.rsplit(".", 1)
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:
pairs.append((src, dst))
pairs.append((src, src_slot, dst, dst_slot))
return pairs
@@ -1555,7 +1739,7 @@ def flow_columns(items, pairs):
item_of = {item["comp"]: 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])]:
succs[id(item_of[src])].append(item_of[dst])
for out in succs.values():
@@ -1606,6 +1790,32 @@ def flow_columns(items, pairs):
if not preds[id(node)] and forward[id(node)]:
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)]
for item in sorted(items, key=lambda i: (i["grid_y"], i["grid_x"])):
columns[layer[id(item)]].append(item)
@@ -1633,7 +1843,7 @@ def flow_columns(items, pairs):
def build_layout_items(components):
"""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
sheet -- unlinked, or linked only off-sheet -- goes to the bottom of the first
column, alphabetically by name.
@@ -1643,7 +1853,7 @@ def build_layout_items(components):
fonts = sheet_fonts()
linked_slot_index = build_linked_slot_index()
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 = []
loose = []
@@ -1668,6 +1878,8 @@ def build_layout_items(components):
if loose:
loose.sort(key=lambda i: (i["comp"].get("name", "").casefold(),
i["comp"].get("name", "")))
for item in loose:
item["loose"] = True # plan_columns keeps these packed at the bottom
columns.setdefault(0, []).extend(loose)
column_keys = sorted(columns)
@@ -1677,19 +1889,20 @@ def build_layout_items(components):
for item in columns[key]
}
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):
"""Wires running left to right past at least one column, for plan_columns.
def forward_wires(columns, column_of_comp):
"""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
links_elem = root.find("links") if root is not None else None
if links_elem is None:
return spans
return wires
item_by_path = {
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", "")
if "." not in from_ref or "." not in to_ref:
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)
src_item = item_by_path.get(src_path)
dst_item = item_by_path.get(dst_path)
if src_item is None or dst_item is None:
continue
src_col = column_of_comp[src_item["comp"]]
dst_col = column_of_comp[dst_item["comp"]]
if dst_col - src_col >= 2:
spans.append((src_col, dst_col, dst_item, dst_slot))
return spans
if dst_col > src_col:
wires.append((src_col, dst_col, src_item, src_slot, dst_item, dst_slot))
return wires
def sheet_meta_scale(components):
@@ -1723,13 +1937,13 @@ def sheet_meta_scale(components):
if not components:
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:
return grid_size
for multiplier in range(1, META_SCALE_MAX + 1):
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier,
spans)
wires)
if not clamped:
return grid_size * multiplier
return grid_size * META_SCALE_MAX
@@ -1751,12 +1965,12 @@ def tidy_layout():
return 0, 0
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:
return 0, 0
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 = [
(item["comp"], item["meta"], step_x, step_y)