From e2cd13b9a08bc1a8ed7777627fc3405c5f30b67e Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Fri, 4 Sep 2026 19:05:58 +0300 Subject: [PATCH] Wiresheet: grid-snapped layout, visible wire routing, knob legend Layout rules for tidy and for dragging: - Boxes snap to whole meta steps live while dragged, not only on release. - The left-most column and top row clear the canvas edge by at least 30px, and neighbours by at least 30px, each rounded up to the next step. - Box widths round up to the grid so a right edge lands on a grid line. - Column gaps widen only where wires need lanes, so most stay at exactly 30px. - A sheet too wide for meta's 0..255 range is laid out at 2, then 3, then 4 grid squares per step. The multiplier is derived from the sheet's contents, so painting and tidying agree on it without recording anything in the file. The grid size in Preferences is untouched. Wire routing: - No wire may pass under a component. A link with no clear route is shown as knobs on both slots instead of a line hidden behind a box. - Vertical runs hug a box within 15px, never closer than 10px, and two runs sharing a gap stand 10px apart. - Horizontal runs of different links stay 5px apart. Runs from one output pin may share, since a fan-out is one signal. - Straight routes claim their rows first, as their segments are pinned to their slots and cannot move later. Knobs: - Wire colour with a small white centre when the far end is on this sheet, grey when it is on another, half and half when a slot carries both. - Selectable and deletable like a wire, and raised above the boxes so a click reaches them. - Selecting a same-sheet pair draws a red dashed line between the two knobs. Also: - get_box_bounds returns the rectangle actually drawn rather than canvas.bbox, which was inflated by the outline stroke and by selection. - Preferences offers to round the box width to a multiple of the grid. - The tidy coordinate-limit warning said to lower the grid size; raising it is what helps, since the reach is 255 * grid size. - Tidy Layout moves from its own menu into Edit. Co-Authored-By: Claude Opus 5 --- app_state.py | 3 + main.py | 126 +++++-- wiresheet.py | 906 ++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 862 insertions(+), 173 deletions(-) diff --git a/app_state.py b/app_state.py index b1d2f5b..3a2e11f 100644 --- a/app_state.py +++ b/app_state.py @@ -44,6 +44,9 @@ selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas selected_tree_item_id = None # Tracks the currently right-clicked Treeview item link_mark_element = None # Component marked as a link endpoint via "Link Mark" selected_link_tag = None # Canvas tag of the currently selected (red-highlighted) link wire +unroutable_link_count = 0 # Links drawn as knobs because no wire could avoid a component +meta_scale = DEFAULT_GRID_SIZE # Pixels per meta coordinate step on the painted sheet +knob_link_ends = {} # wire tag -> (x1, y1, x2, y2) for a link shown as two knobs canvas_link_map = {} # Canvas link tag -> ET.Element () xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly current_sheet_parent = None # Element whose children are painted; None means the app root diff --git a/main.py b/main.py index f2bc8bd..3846b09 100644 --- a/main.py +++ b/main.py @@ -42,6 +42,9 @@ def do_save(event=None): return "break" +NL = chr(10) # newline, for the multi-line message boxes + + def do_tidy_layout(event=None): """Menu command: re-flow the open sheet so nothing overlaps.""" if not app_state.canvas_comp_map: @@ -49,15 +52,28 @@ def do_tidy_layout(event=None): return "break" moved, clamped = wiresheet.tidy_layout() + multiplier = app_state.meta_scale // app_state.GRID_SIZE + if moved == 0: messagebox.showinfo("Tidy Layout", "Nothing to move — no boxes overlap.") elif clamped: messagebox.showwarning( "Tidy Layout", f"Repositioned {moved} component(s).\n\n" - f"{clamped} hit the coordinate limit of {wiresheet.META_COORD_MAX} grid units " - "and were clamped, because meta stores x and y in one byte each. " - "Lower the grid size in Preferences to fit more in." + f"{clamped} still hit the coordinate limit of {wiresheet.META_COORD_MAX} " + "steps, because meta stores x and y in one byte each. This sheet is already " + f"laid out at {multiplier} grid squares per step, the widest meta can reach, " + "so it does not fit however it is spaced. Split it across sub-sheets." + ) + elif multiplier > 1: + messagebox.showinfo( + "Tidy Layout", + f"Repositioned {moved} component(s)." + NL + NL + + f"This sheet needs more width than meta's {wiresheet.META_COORD_MAX} " + f"coordinate steps allow at one grid square each, so it is laid out at " + f"{multiplier} grid squares per step ({app_state.meta_scale} px). Boxes " + f"settle on every {multiplier} grid lines rather than every one. Your grid " + "size in Preferences is unchanged." ) return "break" @@ -179,6 +195,12 @@ def on_canvas_click(event): app_state.drag_data["y"] = cy app_state.selected_canvas_tag = group_tag + # Where the box starts out. The drag is measured as an offset from this pair + # rather than accumulated per motion event, so repeated snapping cannot drift. + origin = wiresheet.get_box_origin(group_tag) + app_state.drag_data["box_x"] = origin[0] if origin else None + app_state.drag_data["box_y"] = origin[1] if origin else None + # Snapshot the pre-drag meta so the move can be pushed as one undoable step. dragged = app_state.canvas_comp_map.get(group_tag) meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None @@ -444,19 +466,41 @@ def on_canvas_drag(event): return canvas = app_state.canvas + group_tag = app_state.drag_data["tag"] cx = canvas.canvasx(event.x) cy = canvas.canvasy(event.y) - delta_x = cx - app_state.drag_data["x"] - delta_y = cy - app_state.drag_data["y"] + box_x = app_state.drag_data.get("box_x") + origin = wiresheet.get_box_origin(group_tag) - canvas.move(app_state.drag_data["tag"], delta_x, delta_y) + if box_x is None or origin is None: + # Nothing to align against, so the box just follows the pointer one to one. + canvas.move(group_tag, cx - app_state.drag_data["x"], cy - app_state.drag_data["y"]) + app_state.drag_data["x"] = cx + app_state.drag_data["y"] = cy + wiresheet.draw_links() + return - app_state.drag_data["x"] = cx - app_state.drag_data["y"] = cy + # Snap to whole meta steps, which is the grid on an ordinary sheet and a multiple of + # it on one too wide for meta's range -- a position between steps cannot be stored. + step = app_state.meta_scale + margin = wiresheet.layout_margin(step) - # Wires are not part of the dragged group, so they are re-routed against the new positions. - wiresheet.draw_links() + # Follow the pointer, but land the box on a step every step of the way, and keep it + # out of the margin strip along the top and left canvas edges. + wanted_x = box_x + (cx - app_state.drag_data["x"]) + wanted_y = app_state.drag_data["box_y"] + (cy - app_state.drag_data["y"]) + target_x = max(margin, round(wanted_x / step) * step) + target_y = max(margin, round(wanted_y / step) * step) + + shift_x = target_x - origin[0] + shift_y = target_y - origin[1] + + if shift_x or shift_y: + canvas.move(group_tag, shift_x, shift_y) + # Wires are not part of the dragged group, so they are re-routed against the + # new positions. + wiresheet.draw_links() def on_canvas_release(event): @@ -464,16 +508,16 @@ def on_canvas_release(event): return canvas = app_state.canvas - grid_size = app_state.GRID_SIZE + step = app_state.meta_scale group_tag = app_state.drag_data["tag"] - outline_item = canvas.find_withtag(f"{group_tag} && block_outline") + origin = wiresheet.get_box_origin(group_tag) - if outline_item: - coords = canvas.coords(outline_item[0]) - current_x, current_y = coords[0], coords[1] + if origin is not None: + current_x, current_y = origin - snapped_x = round(current_x / grid_size) * grid_size - snapped_y = round(current_y / grid_size) * grid_size + margin = wiresheet.layout_margin(step) + snapped_x = max(margin, round(current_x / step) * step) + snapped_y = max(margin, round(current_y / step) * step) shift_x = snapped_x - current_x shift_y = snapped_y - current_y @@ -482,8 +526,8 @@ def on_canvas_release(event): canvas.move(group_tag, shift_x, shift_y) app_state.mark_dirty() - raw_x = int(snapped_x / grid_size) - raw_y = int(snapped_y / grid_size) + raw_x = min(wiresheet.META_COORD_MAX, int(round(snapped_x / step))) + raw_y = min(wiresheet.META_COORD_MAX, int(round(snapped_y / step))) comp_element = app_state.canvas_comp_map.get(group_tag) if comp_element is not None: @@ -591,6 +635,22 @@ def open_preferences_dialog(): f"{app_state.MAX_ALLOWED_BOX_WIDTH} pixels." ) + # The width cap is applied after each box is fitted to its text, so a cap + # that is not a multiple of the grid leaves the widest boxes off-grid. + proposed = wiresheet.suggest_box_width_for_grid(width, val) + if proposed != width: + if not messagebox.askyesno( + "Box Width Off Grid", + f"A box width of {width} px is not a multiple of the {val} px grid, " + f"so the widest boxes would not line up with it.\n\n" + f"Change the box width to {proposed} px?", + parent=dialog, + ): + return + width = proposed + entry_box_width.delete(0, tk.END) + entry_box_width.insert(0, str(width)) + app_state.BOX_WIDTH = width app_state.GRID_SIZE = val app_state.ADD_TIMESTAMP = var_timestamp.get() @@ -693,6 +753,21 @@ def verify_startup_configurations(): "before trying to view or load profile source files." ) open_preferences_dialog() + return + + # editor.properties can be hand-edited, so the pair is reconciled here too. + proposed = wiresheet.suggest_box_width_for_grid(app_state.BOX_WIDTH, app_state.GRID_SIZE) + if proposed != app_state.BOX_WIDTH and messagebox.askyesno( + "Box Width Off Grid", + f"The saved box width of {app_state.BOX_WIDTH} px is not a multiple of the " + f"{app_state.GRID_SIZE} px grid, so the widest boxes will not line up with it.\n\n" + f"Change the box width to {proposed} px?", + ): + app_state.BOX_WIDTH = proposed + app_state.save_preferences() + wiresheet.draw_grid() + if app_state.active_components_list: + wiresheet.render_components(app_state.active_components_list) # --- UI Setup --- @@ -767,6 +842,8 @@ menu_edit.add_command(label="Undo", command=do_undo, accelerator=f"{accel_mod}+Z menu_edit.add_command(label="Redo", command=do_redo, accelerator=f"{accel_mod}+Y") menu_edit.add_separator() menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del") +menu_edit.add_separator() +menu_edit.add_command(label="Tidy Layout", command=do_tidy_layout) menu_bar.add_cascade(label="Edit", menu=menu_edit) menu_kits = tk.Menu(menu_bar, tearoff=0) @@ -776,10 +853,6 @@ menu_kits.add_command( ) menu_bar.add_cascade(label="Kits", menu=menu_kits) -menu_layout = tk.Menu(menu_bar, tearoff=0) -menu_layout.add_command(label="Tidy Layout", command=do_tidy_layout) -menu_bar.add_cascade(label="Layout", menu=menu_layout) - menu_app_info = tk.Menu(menu_bar, tearoff=0) menu_app_info.add_command(label="App Info", command=show_app_info) menu_bar.add_cascade(label="App", menu=menu_app_info) @@ -993,9 +1066,10 @@ def on_palette_release(event): drop_x = canvas.canvasx(event.x_root - canvas.winfo_rootx()) drop_y = canvas.canvasy(event.y_root - canvas.winfo_rooty()) - grid_size = app_state.GRID_SIZE - grid_x = max(0, min(wiresheet.META_COORD_MAX, round(drop_x / grid_size))) - grid_y = max(0, min(wiresheet.META_COORD_MAX, round(drop_y / grid_size))) + step = app_state.meta_scale + min_step = wiresheet.layout_margin(step) // step + grid_x = max(min_step, min(wiresheet.META_COORD_MAX, round(drop_x / step))) + grid_y = max(min_step, min(wiresheet.META_COORD_MAX, round(drop_y / step))) sax_file.add_component(kit_name, type_name, grid_x, grid_y) diff --git a/wiresheet.py b/wiresheet.py index 09a14a3..c2b7676 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -1,5 +1,6 @@ """Canvas painting: the grid, component boxes, and the routed link wires.""" +import math import tkinter as tk import tkinter.font as tkfont @@ -9,10 +10,33 @@ import undo from sax_dom import get_component_path, read_meta_property, update_meta_property LINK_STUB = 12 # straight run leaving a slot before the wire is allowed to turn -LINK_CLEAR = 8 # gap kept between a routed wire and any box it passes +LINK_CLEAR = 8 # 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. +WIRE_HUG = 15 +WIRE_MIN_CLEAR = 10 +WIRE_LANE_GAP = 10 + +# 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 +# where everyone can see it. A link with no clear path at all is shown as a pair of +# knobs, the same way a link leaving the sheet is. +WIRE_STANDOFF_MAX = 60 + +# 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 = 5 LINK_COLOR = "#2f6fba" +KNOB_OFFSHEET_COLOR = "#9aa0a6" # a link whose far end is on another wiresheet KNOB_RADIUS = 4 +KNOB_CORE_RADIUS = 1.5 # white centre marking a link that stays on this sheet # Box metrics. Kept tight on purpose: stored meta positions were authored against # compact boxes, with columns pitched about 100px apart and rows about 60px apart. @@ -23,7 +47,52 @@ FOOTER_HEIGHT = 2 MIN_BOX_WIDTH = 80 # ignored when the configured width is smaller META_COORD_MAX = 255 # meta packs x and y into one byte each -TIDY_GAP = 20 # clear space left between boxes by the tidy command +# meta addresses at most META_COORD_MAX steps on each axis, so a sheet laid out one grid +# square per step can only be META_COORD_MAX * GRID_SIZE px wide. A sheet that needs more +# is laid out at a whole multiple of the grid instead -- 2 grid squares per step, then 3, +# then 4 -- which multiplies the reach without touching the user's grid size. The +# multiplier is derived from the sheet's own contents, so painting and tidying always +# agree on it without anything having to be recorded in the file. +META_SCALE_MAX = 12 + +TIDY_GAP = 30 # minimum clear space kept between two boxes +TIDY_MARGIN = 30 # minimum clear space kept from the top and left canvas edges + + +def snap_up(value, grid_size): + """Smallest multiple of grid_size that is >= value; grid lines sit on multiples. + + Used wherever a minimum clearance has to be honoured on a grid: 30px of margin on a + 20px grid becomes 40px, because 30 is not a grid line and shrinking to 20 would + break the minimum. + """ + if grid_size <= 0: + return value + return int(math.ceil(value / grid_size - 1e-9)) * grid_size + + +def suggest_box_width_for_grid(width, grid_size): + """Next grid line at or above `width`, so the width cap can sit on the grid. + + The cap is applied after a box has been fitted to its text, so a cap that is not a + multiple of the grid puts the widest boxes off-grid. Steps one grid line down if + rounding up would breach MAX_ALLOWED_BOX_WIDTH; the allowed 60..400 width range + always holds a multiple of any allowed grid (2..200), so the result stays usable. + Returns `width` unchanged when it already lands on the grid. + """ + if grid_size <= 0: + return width + proposed = snap_up(width, grid_size) + if proposed > app_state.MAX_ALLOWED_BOX_WIDTH: + proposed -= grid_size + return proposed + + +def layout_margin(grid_size=None): + """The top/left edge offset a box may not encroach on, snapped onto the grid.""" + if grid_size is None: + grid_size = app_state.GRID_SIZE + return snap_up(TIDY_MARGIN, grid_size) def short_type_name(comp_type): @@ -46,6 +115,57 @@ def fit_text(text, font, max_px): return (trimmed + ellipsis) if trimmed else ellipsis +def sheet_fonts(): + """The three fonts a component box is drawn with. Box sizes are measured from these.""" + return ( + tkfont.Font(family="Segoe UI", size=9, weight="bold"), + tkfont.Font(family="Segoe UI", size=7), + tkfont.Font(family="Segoe UI", size=8), + ) + + +def measure_box(comp, fonts, linked_slot_index): + """Works out a component's box without drawing it. + + Returns (width, height, slot_rows, display_name, display_type). Shared by painting + and by the layout arithmetic, so the two can never disagree about how big a box is. + """ + name_font, type_font, slot_font = fonts + comp_name = comp.get("name", "Unknown") + comp_type = comp.get("type", "sys::Component") + comp_id = comp.get("id") + display_name = f"{comp_name} [id: {comp_id}]" if comp_id is not None else comp_name + + if app_state.xml_root_element is not None: + comp_path = get_component_path(comp, app_state.xml_root_element) + else: + comp_path = "" + slots = schema.get_slots_for_type(comp_type, linked_slot_index.get(comp_path, ())) + + slot_rows = [] + for raw_name, display_label in slots: + prop_elem = comp.find(f"prop[@name='{raw_name}']") + val_str = prop_elem.get("val", "null") if prop_elem is not None else "null" + slot_rows.append((raw_name, display_label, val_str)) + + # Dynamically size width and height metrics, then clamp so a long slot name + # cannot push the box across its neighbour. + display_type = short_type_name(comp_type) + max_content_width = max(name_font.measure(display_name), + type_font.measure(display_type)) + 30 + for _, display_label, val_str in slot_rows: + row_width = slot_font.measure(display_label) + slot_font.measure(val_str) + 24 + if row_width > max_content_width: + max_content_width = row_width + + # Widen to the next grid line so a box's right edge lands on the grid like its + # left edge does, which keeps the tidy column pitch grid-aligned too. + fitted_width = snap_up(max(MIN_BOX_WIDTH, max_content_width), app_state.GRID_SIZE) + width = min(app_state.BOX_WIDTH, fitted_width) + height = HEADER_HEIGHT + (len(slot_rows) * SLOT_ROW_HEIGHT) + FOOTER_HEIGHT + return width, height, slot_rows, display_name, display_type + + def draw_grid(): """Draws a grid onto the Canvas based on the scrollable region.""" canvas = app_state.canvas @@ -96,11 +216,31 @@ def build_linked_slot_index(): def get_box_bounds(comp_tag): - """Current on-canvas bounds of a component group, valid after drags.""" - box = app_state.canvas.bbox(comp_tag) + """The rectangle a component's box was actually drawn at, valid after drags. + + Measured off the outline polygon rather than canvas.bbox(): bbox is inflated by the + outline stroke, and by more again while the box is selected and stroked thicker. + Those few pixels matter, because layout budgets its gaps against this width and the + router measures wire clearance against these edges. Falls back to bbox for anything + drawn without an outline. + """ + canvas = app_state.canvas + outline_item = canvas.find_withtag(f"{comp_tag} && block_outline") + if outline_item: + coords = canvas.coords(outline_item[0]) + if len(coords) >= 4: + xs, ys = coords[0::2], coords[1::2] + return min(xs), min(ys), max(xs), max(ys) + box = canvas.bbox(comp_tag) return box if box else None +def get_box_origin(comp_tag): + """Exact (left, top) of a component's box, or None if it is not on the canvas.""" + bounds = get_box_bounds(comp_tag) + return (bounds[0], bounds[1]) if bounds else None + + def segment_hits_boxes(x1, y1, x2, y2, boxes): """True if an axis-aligned segment overlaps any box rectangle.""" if x1 > x2: @@ -124,41 +264,279 @@ def path_is_clear(points, boxes): return True -def route_link_path(src_box, src_y, dst_box, dst_y, boxes): - """Routes an orthogonal wire from a source slot to a target slot around every box.""" +def vertical_run_clearance(cx, y1, y2, boxes): + """Smallest horizontal distance from the vertical run at cx to any box it passes. + + Only boxes whose vertical extent overlaps the run can crowd it, so a run that slips + by above or below a box is not charged for it. Returns 0 when the run is inside a + box, and infinity when it passes nothing at all. + """ + if y1 > y2: + y1, y2 = y2, y1 + nearest = float("inf") + for bx1, by1, bx2, by2 in boxes: + if y2 < by1 - LINK_EPS or y1 > by2 + LINK_EPS: + continue + if bx1 - LINK_EPS <= cx <= bx2 + LINK_EPS: + return 0.0 + nearest = min(nearest, (bx1 - cx) if cx < bx1 else (cx - bx2)) + return nearest + + +def channel_candidates(src_box, dst_box, boxes, low, high): + """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. + """ + ordered = [src_box[2] + WIRE_HUG, dst_box[0] - WIRE_HUG] + for bx1, _, bx2, _ in boxes: + ordered.append(bx2 + WIRE_HUG) + ordered.append(bx1 - WIRE_HUG) + ordered.append((low + high) / 2) + + seen, out = set(), [] + for cx in ordered: + if low <= cx <= high and cx not in seen: + seen.add(cx) + out.append(cx) + 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. + + A lane is rejected while it crowds a box (closer than WIRE_MIN_CLEAR) 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 + 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 + if not (low <= lane <= high): + continue + if vertical_run_clearance(lane, run_lo, run_hi, boxes) < WIRE_MIN_CLEAR - 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: + return lane + return None + + +def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, max_offset=WIRE_HUG): + """Lane for a run pinned to one box edge, starting WIRE_MIN_CLEAR 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 + that limit clears both the boxes and the runs already placed. + """ + offsets = list(range(WIRE_HUG, WIRE_MIN_CLEAR - 1, -1)) + if max_offset > WIRE_HUG: + offsets += list(range(WIRE_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: + 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 + ): + 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. + + 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. + """ + 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 + ) + + +def free_rows(cy, x_lo, x_hi, rows, owner, 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. + 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): + 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): + """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. + No wire may be drawn under a box, so the caller shows such a link as knobs instead, + 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. + """ start = (src_box[2], src_y) end = (dst_box[0], dst_y) leave = (start[0] + LINK_STUB, src_y) arrive = (end[0] - LINK_STUB, dst_y) - preferred = (leave[0] + arrive[0]) / 2 - - # Preference 1: a clear vertical channel somewhere between the two boxes. - channels = {preferred} - for bx1, _, bx2, _ in boxes: - channels.add(bx1 - LINK_CLEAR) - channels.add(bx2 + LINK_CLEAR) - low, high = min(leave[0], arrive[0]), max(leave[0], arrive[0]) - for cx in sorted((c for c in channels if low <= c <= high), key=lambda c: abs(c - preferred)): - candidate = [start, leave, (cx, src_y), (cx, dst_y), arrive, end] - if path_is_clear(candidate, boxes): + run_lo, run_hi = min(src_y, dst_y), max(src_y, dst_y) + ledger = taken if taken is not None else {"cols": [], "rows": []} + claimed = ledger["cols"] + claimed_rows = ledger["rows"] + + def rows_of(points): + """The horizontal runs a finished path lays down, for the ledger.""" + return [ + (y1, min(x1, x2), max(x1, x2), owner) + for (x1, y1), (x2, y2) in zip(points, points[1:]) + if abs(y1 - y2) < LINK_EPS and abs(x1 - x2) > LINK_EPS + ] + + def straight_through(max_steps, allow_stack=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. + """ + for cx in channel_candidates(src_box, dst_box, boxes, low, high): + lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, max_steps) + if lane is None: + continue + candidate = [start, leave, (lane, src_y), (lane, dst_y), arrive, end] + if not path_is_clear(candidate, boxes): + continue + if not allow_stack and any( + row_is_stacked(y, x1, x2, claimed_rows, owner) + for y, x1, x2, _ in rows_of(candidate) + ): + continue + return candidate, [(lane, run_lo, run_hi)] + 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. + """ + lanes = set() + for _, by1, _, by2 in boxes: + lanes.add(by1 - LINK_CLEAR) + lanes.add(by2 + LINK_CLEAR) + + # 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 + # one box's edge is often still blocked by a taller neighbour, whereas the + # 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) + for bottom in bottoms: + nearest_below = [t for t in tops if t > bottom + 2 * LINK_CLEAR] + 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) + if out_x is None: + continue + 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) + if in_x is None: + continue + + # The long run across the sheet is the one segment free to move, so step it + # 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) + if allow_stack and base_cy not in options: + options.append(base_cy) # last resort: share the row rather than drop the wire + + for cy in options: + out_lo, out_hi = min(src_y, cy), max(src_y, cy) + in_lo, in_hi = min(cy, dst_y), max(cy, dst_y) + + # The lanes were picked against the unnudged height, and moving the + # 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): + continue + if (vertical_run_clearance(in_x, in_lo, in_hi, boxes) + < WIRE_MIN_CLEAR - 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)] + return None, None + + # Tier by how much of the spacing guidance has to give way. Crossing a box is never + # on the menu, so the last tier is "no wire", handled by the caller. + if direct_only: + # 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),) + else: + attempts = ( + lambda: straight_through(1), # hugging, own lane + lambda: around_boxes(WIRE_HUG), # hugging, routed around + lambda: straight_through(8), # lane nudged out of the band + lambda: around_boxes(WIRE_STANDOFF_MAX), # 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), + ) + for attempt in attempts: + candidate, runs = attempt() + if candidate is not None: + if taken is not None: + taken["cols"].extend(runs) + taken["rows"].extend(rows_of(candidate)) return candidate - # Preference 2: go over or under everything. Covers feedback links running right to left. - lanes = set() - for _, by1, _, by2 in boxes: - lanes.add(by1 - LINK_CLEAR) - lanes.add(by2 + LINK_CLEAR) - - midline = (src_y + dst_y) / 2 - for cy in sorted(lanes, key=lambda c: abs(c - midline)): - candidate = [start, leave, (leave[0], cy), (arrive[0], cy), arrive, end] - if path_is_clear(candidate, boxes): - return candidate - - # Nothing clear: fall back to the direct dog-leg rather than dropping the wire. - return [start, leave, (preferred, src_y), (preferred, dst_y), arrive, end] + return None def resolve_slot_anchor(comp_tag, slot_name): @@ -172,16 +550,45 @@ def resolve_slot_anchor(comp_tag, slot_name): return box, box[1] + offset -def draw_knob(x, y): - """Marks a link whose far end lives on another wiresheet.""" - app_state.canvas.create_oval( - x - KNOB_RADIUS, y - KNOB_RADIUS, x + KNOB_RADIUS, y + KNOB_RADIUS, - fill=LINK_COLOR, outline="white", width=1, tags=("link", "link_knob") - ) +def draw_knob(x, y, on_sheet, off_sheet, extra_tags=()): + """Marks a slot whose links cannot be shown as a visible wire. + + Painted in the wire colour when the far end is on this sheet but unreachable, and + grey when it is on another wiresheet, so the two cases read differently at a + glance. A slot carrying both is split down the middle, wire colour on the left + and grey on the right. + """ + canvas = app_state.canvas + tags = ("link", "link_knob") + tuple(extra_tags) + box = (x - KNOB_RADIUS, y - KNOB_RADIUS, x + KNOB_RADIUS, y + KNOB_RADIUS) + + if on_sheet and off_sheet: + # Half of each: wire colour on the left for the link that stays on this sheet, + # grey on the right for the one that leaves it. + canvas.create_arc(*box, start=90, extent=180, style=tk.PIESLICE, + fill=LINK_COLOR, outline="", tags=tags) + canvas.create_arc(*box, start=270, extent=180, style=tk.PIESLICE, + fill=KNOB_OFFSHEET_COLOR, outline="", tags=tags) + canvas.create_oval(*box, fill="", outline="white", width=1, tags=tags) + else: + canvas.create_oval(*box, fill=LINK_COLOR if on_sheet else KNOB_OFFSHEET_COLOR, + outline="white", width=1, tags=tags) + + if on_sheet: + # A small white centre is what marks the far end as being on this sheet. It + # keeps its colour through selection, so the middle never fills in -- and on a + # half-and-half knob it sits in the wire-coloured half it belongs to. + cx = x - KNOB_RADIUS / 2 if off_sheet else x + canvas.create_oval( + cx - KNOB_CORE_RADIUS, y - KNOB_CORE_RADIUS, + cx + KNOB_CORE_RADIUS, y + KNOB_CORE_RADIUS, + fill="white", outline="", tags=tags + ("link_knob_core",) + ) LINK_SELECT_COLOR = "#e00000" LINK_SELECT_WIDTH = 2 +LINK_HINT_DASH = (5, 3) # dashes of the line shown between a selected pair of knobs def draw_links(): @@ -189,6 +596,7 @@ def draw_links(): 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 @@ -206,7 +614,27 @@ def draw_links(): boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b] + # 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 + + # 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) for link in links_elem.findall("link"): from_ref = link.get("from", "") to_ref = link.get("to", "") @@ -228,46 +656,151 @@ def draw_links(): 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 - points = route_link_path(src_box, src_y, dst_box, dst_y, boxes) - flat = [coord for point in points for coord in point] - canvas.create_line( - *flat, fill=LINK_COLOR, width=1, - arrow=tk.LAST, arrowshape=(8, 9, 3), tags=("link", wire_tag) + 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) + + # 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 ) - elif src_tag: - src_box, src_y = resolve_slot_anchor(src_tag, from_slot) - if src_box is not None: - draw_knob(src_box[2] + KNOB_RADIUS, src_y) - elif dst_tag: - dst_box, dst_y = resolve_slot_anchor(dst_tag, to_slot) - if dst_box is not None: - draw_knob(dst_box[0] - KNOB_RADIUS, dst_y) + unroutable += 1 + continue - # Wires and knobs sit behind the boxes so they never paint over a neighbouring component. + flat = [coord for point in points for coord in point] + canvas.create_line( + *flat, fill=LINK_COLOR, width=1, + arrow=tk.LAST, arrowshape=(8, 9, 3), tags=("link", wire_tag) + ) + + for (x, y), entry in knobs.items(): + draw_knob(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. 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 + + +_selected_link_restore = [] # [(item, fill, outline, width)] captured when a link is selected + + +def trim_to_knobs(x1, y1, x2, y2): + """Pulls both ends of a knob-to-knob line back to the knob rims. + + Drawn centre to centre the line would cross the hollow middle of each knob, which + is exactly the part meant to stay white. + """ + span = math.hypot(x2 - x1, y2 - y1) + if span <= 2 * KNOB_RADIUS: + return x1, y1, x2, y2 + ux, uy = (x2 - x1) / span, (y2 - y1) / span + inset = KNOB_RADIUS + 1 + return (x1 + ux * inset, y1 + uy * inset, + x2 - ux * inset, y2 - uy * inset) def select_link(wire_tag): - """Highlights a link wire in red.""" + """Highlights a link in red, remembering what each of its items looked like. + + The colours cannot simply be reset to LINK_COLOR afterwards: a link shown as knobs + may be grey, or half grey, and blanket-restoring would repaint it as an ordinary + on-sheet marker. + """ canvas = app_state.canvas deselect_link() if wire_tag and wire_tag in app_state.canvas_link_map: - canvas.itemconfig(wire_tag, fill=LINK_SELECT_COLOR, width=LINK_SELECT_WIDTH) + for item in canvas.find_withtag(wire_tag): + _selected_link_restore.append(( + item, + canvas.itemcget(item, "fill"), + canvas.itemcget(item, "outline") if canvas.type(item) != "line" else "", + canvas.itemcget(item, "width"), + )) + if canvas.type(item) == "line": + canvas.itemconfig(item, fill=LINK_SELECT_COLOR, + width=LINK_SELECT_WIDTH) + elif "link_knob_core" in canvas.gettags(item): + pass # the white centre stays white while selected + else: + canvas.itemconfig(item, fill=LINK_SELECT_COLOR) canvas.tag_raise(wire_tag) app_state.selected_link_tag = wire_tag + # A link with both ends on this sheet but no drawable route is two knobs with + # nothing between them, so while it is selected the connection is spelled out + # as a dashed line. Raised above the boxes, since it is a transient marker + # rather than wiring, and tagged "link" so the next repaint clears it. + ends = app_state.knob_link_ends.get(wire_tag) + if ends: + canvas.create_line( + *trim_to_knobs(*ends), fill=LINK_SELECT_COLOR, + width=LINK_SELECT_WIDTH, dash=LINK_HINT_DASH, + arrow=tk.LAST, arrowshape=(8, 9, 3), + tags=("link", "link_select_hint") + ) + canvas.tag_raise("link_select_hint") + canvas.tag_raise("link_knob") + def deselect_link(): - """Resets the selected link wire back to its default colour.""" + """Puts the selected link back exactly as it was drawn.""" canvas = app_state.canvas + canvas.delete("link_select_hint") if app_state.selected_link_tag: - try: - canvas.itemconfig(app_state.selected_link_tag, fill=LINK_COLOR, width=1) - except tk.TclError: - pass + for item, fill, outline, width in _selected_link_restore: + try: + if canvas.type(item) == "line": + canvas.itemconfig(item, fill=fill, width=width) + else: + canvas.itemconfig(item, fill=fill, outline=outline, width=width) + except tk.TclError: + pass # the item went away with a repaint app_state.selected_link_tag = None + _selected_link_restore.clear() # --- Component painting --- @@ -284,12 +817,16 @@ def render_components(components_to_render): app_state.canvas_tag_by_element.clear() app_state.component_slot_offsets.clear() - name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold") - type_font = tkfont.Font(family="Segoe UI", size=7) - slot_font = tkfont.Font(family="Segoe UI", size=8) + fonts = name_font, type_font, slot_font = sheet_fonts() linked_slot_index = build_linked_slot_index() + # Settle how many pixels one meta step is worth before anything is positioned. A + # sheet that overflows meta's range is painted at a coarser multiple of the grid, + # and tidy works the same figure out independently, so the two always agree. + meta_scale = sheet_meta_scale(components_to_render) + app_state.meta_scale = meta_scale + max_reached_x = 1000 max_reached_y = 1000 @@ -298,52 +835,15 @@ def render_components(components_to_render): footer_height = FOOTER_HEIGHT for comp in components_to_render: - comp_name = comp.get("name", "Unknown") - comp_type = comp.get("type", "sys::Component") - comp_id = comp.get("id") - - if comp_id is not None: - full_display_name = f"{comp_name} [id: {comp_id}]" - else: - full_display_name = comp_name - - meta_str = None meta_prop = comp.find("prop[@name='meta']") - if meta_prop is not None: - meta_str = meta_prop.get("val") + meta_str = meta_prop.get("val") if meta_prop is not None else None + raw_x, raw_y = read_meta_property(meta_str)[:2] - raw_x, raw_y, reserved, g1, g2, g3, g4, ug_res = read_meta_property(meta_str) + coord_x = raw_x * meta_scale + coord_y = raw_y * meta_scale - coord_x = (raw_x * app_state.GRID_SIZE) - coord_y = (raw_y * app_state.GRID_SIZE) - - # Retrieve manifest slot definitions, keeping any slot a link needs to land on - if app_state.xml_root_element is not None: - comp_path = get_component_path(comp, app_state.xml_root_element) - else: - comp_path = "" - slots = schema.get_slots_for_type(comp_type, linked_slot_index.get(comp_path, ())) - - slot_data_rows = [] - for raw_name, display_label in slots: - prop_elem = comp.find(f"prop[@name='{raw_name}']") - val_str = prop_elem.get("val", "null") if prop_elem is not None else "null" - slot_data_rows.append((raw_name, display_label, val_str)) - - # Dynamically size width and height metrics, then clamp so a long slot name - # cannot push the box across its neighbour. - display_type = short_type_name(comp_type) - name_width = name_font.measure(full_display_name) - type_width = type_font.measure(display_type) - max_content_width = max(name_width, type_width) + 30 - - for _, display_label, val_str in slot_data_rows: - row_width = slot_font.measure(display_label) + slot_font.measure(val_str) + 24 - if row_width > max_content_width: - max_content_width = row_width - - box_width = min(app_state.BOX_WIDTH, max(MIN_BOX_WIDTH, max_content_width)) - box_height = header_height + (len(slot_data_rows) * slot_row_height) + footer_height + (box_width, box_height, slot_data_rows, + full_display_name, display_type) = measure_box(comp, fonts, linked_slot_index) if coord_x + box_width > max_reached_x: max_reached_x = coord_x + box_width @@ -449,60 +949,172 @@ def render_current_sheet(): render_components([child for child in parent if child.tag == "comp"]) +def wire_demand_by_boundary(column_of_comp): + """How many vertical wire runs each gap between neighbouring columns has to carry. + + Keyed by the left column's index. A wire spanning several columns is charged only to + the gap holding its vertical run -- it crosses the others horizontally, which costs + no width -- and the router hugs the source box, so that gap is the one just right of + the source's column. A wire running right to left doubles back over or under the + boxes instead, which puts one stub beside each of its two ends. + """ + demand = {} + root = app_state.xml_root_element + if root is None: + return demand + links_elem = root.find("links") + if links_elem is None: + return demand + + column_of_path = { + get_component_path(comp, root): column + for comp, column in column_of_comp.items() + } + + for link in links_elem.findall("link"): + from_ref, to_ref = link.get("from", ""), link.get("to", "") + if "." not in from_ref or "." not in to_ref: + continue + src = column_of_path.get(from_ref.rsplit(".", 1)[0]) + dst = column_of_path.get(to_ref.rsplit(".", 1)[0]) + if src is None or dst is None: + continue + + if src + 1 == dst: + boundaries = (src,) # neighbours: one run in the gap between + else: + # Anything further apart, and anything doubling back, has to travel over or + # under the boxes in between, which needs a stub beside each of its ends. + boundaries = (src, dst - 1) + for boundary in boundaries: + if boundary >= 0: + demand[boundary] = demand.get(boundary, 0) + 1 + + return demand + + +def column_gap_for(run_count): + """Clear space a column gap needs to carry `run_count` vertical wire runs. + + Lanes start WIRE_MIN_CLEAR in from either box edge and stand WIRE_LANE_GAP apart, so + n of them span 2 * WIRE_MIN_CLEAR + (n - 1) * WIRE_LANE_GAP. One or two runs already + fit inside the plain TIDY_GAP -- at 10 and 20 across a 30px gap, both still hugging + a box -- which is why the great majority of gaps stay at exactly TIDY_GAP. + """ + return max(TIDY_GAP, 2 * WIRE_MIN_CLEAR + max(0, run_count - 1) * WIRE_LANE_GAP) + + +def plan_columns(columns, column_keys, demand, scale): + """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. + """ + margin = snap_up(TIDY_MARGIN, scale) + placements = [] + clamped = 0 + cursor_x = margin # the left-most column clears the canvas edge by the margin + + for index, grid_x in enumerate(column_keys): + column = sorted(columns[grid_x], key=lambda i: i["grid_y"]) + cursor_y = margin + + for item in column: + step_x = min(META_COORD_MAX, round(cursor_x / scale)) + step_y = min(META_COORD_MAX, round(cursor_y / scale)) + if step_x == META_COORD_MAX or step_y == META_COORD_MAX: + clamped += 1 + 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(cursor_y + item["height"] + TIDY_GAP, scale) + + gap = column_gap_for(demand.get(index, 0)) + cursor_x = snap_up(cursor_x + max(i["width"] for i in column) + gap, scale) + + return placements, clamped + + +def build_layout_items(components): + """Column-grouped layout items for a sheet, measured rather than read off the canvas. + + Returns (columns, column_keys, demand). Called both while painting -- to settle the + scale before anything is drawn -- and by tidy, so the two agree. + """ + fonts = sheet_fonts() + linked_slot_index = build_linked_slot_index() + + columns = {} + for comp in components: + meta_prop = comp.find("prop[@name='meta']") + meta_val = meta_prop.get("val") if meta_prop is not None else None + grid_x, grid_y = read_meta_property(meta_val)[:2] + width, height = measure_box(comp, fonts, linked_slot_index)[:2] + columns.setdefault(grid_x, []).append({ + "comp": comp, "meta": meta_val, + "grid_x": grid_x, "grid_y": grid_y, + "width": width, "height": height, + }) + + column_keys = sorted(columns) + column_of_comp = { + item["comp"]: index + for index, key in enumerate(column_keys) + for item in columns[key] + } + return columns, column_keys, wire_demand_by_boundary(column_of_comp) + + +def sheet_meta_scale(components): + """Pixels per meta step for this sheet: GRID_SIZE times the smallest multiplier that fits. + + Tries one grid square per step first, then two, three and so on, stopping as soon as + the whole sheet fits inside meta's 0..META_COORD_MAX range on both axes. Derived + purely from the sheet's contents, so every caller works out the same answer. + """ + grid_size = app_state.GRID_SIZE + if not components: + return grid_size + + columns, column_keys, demand = 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) + if not clamped: + return grid_size * multiplier + return grid_size * META_SCALE_MAX + + def tidy_layout(): """Re-flows the open sheet so no two boxes overlap, preserving the author's columns. Boxes keep their column grouping and their top-to-bottom order; only the spacing is - rebuilt, from the sizes actually painted. Returns (moved_count, clamped_count). + rebuilt, from the sizes actually painted. Every placement lands on a whole meta step, + the first column and row clear the canvas edge by at least TIDY_MARGIN, and + neighbours are left at least TIDY_GAP apart, widened where wires need lanes. A sheet + too wide for meta's 0..255 range is laid out at a coarser multiple of the grid rather + than being squashed against the limit. Returns (moved_count, clamped_count). """ if not app_state.canvas_comp_map: return 0, 0 - placed = [] - for comp_tag, comp in app_state.canvas_comp_map.items(): - bounds = get_box_bounds(comp_tag) - if bounds is None: - continue - meta_prop = comp.find("prop[@name='meta']") - meta_val = meta_prop.get("val") if meta_prop is not None else None - grid_x, grid_y = read_meta_property(meta_val)[:2] - placed.append({ - "comp": comp, - "meta": meta_val, - "grid_x": grid_x, - "grid_y": grid_y, - "width": bounds[2] - bounds[0], - "height": bounds[3] - bounds[1], - }) - - if not placed: + components = list(app_state.canvas_comp_map.values()) + columns, column_keys, demand = build_layout_items(components) + if not column_keys: return 0, 0 - columns = {} - for item in placed: - columns.setdefault(item["grid_x"], []).append(item) + scale = sheet_meta_scale(components) + placements, clamped = plan_columns(columns, column_keys, demand, scale) - grid_size = app_state.GRID_SIZE - changes = [] - clamped = 0 - cursor_x = grid_size # leave one grid square of margin at the left - - for grid_x in sorted(columns): - column = sorted(columns[grid_x], key=lambda i: i["grid_y"]) - cursor_y = grid_size - - for item in column: - new_grid_x = min(META_COORD_MAX, round(cursor_x / grid_size)) - new_grid_y = min(META_COORD_MAX, round(cursor_y / grid_size)) - if new_grid_x == META_COORD_MAX or new_grid_y == META_COORD_MAX: - clamped += 1 - - if (new_grid_x, new_grid_y) != (item["grid_x"], item["grid_y"]): - changes.append((item["comp"], item["meta"], new_grid_x, new_grid_y)) - - cursor_y += item["height"] + TIDY_GAP - - cursor_x += max(i["width"] for i in column) + TIDY_GAP + changes = [ + (item["comp"], item["meta"], step_x, step_y) + for item, step_x, step_y in placements + if (step_x, step_y) != (item["grid_x"], item["grid_y"]) + ] if not changes: return 0, clamped