diff --git a/app_state.py b/app_state.py index 93959c9..af0e186 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.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 diff --git a/wiresheet.py b/wiresheet.py index 1cec58c..250fbbb 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -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 + 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 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"] - 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. - 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) - moved = True + def lift(): + """Shifts the whole sheet up until its top-most box sits on the margin again. - top_of[id(item)] = top + 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)