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