Files
sedona_editor/wiresheet.py
T
ArdaandClaude Opus 5.5 67fbfe03dd Wiresheet: Ctrl+click to select several links, delete them together
Ctrl+click toggles a link in the selection; Del or the link popup deletes
every selected link after one confirmation, as a single undoable step.
On a device, links the device refuses stay and are named in one warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:55:18 +03:00

1736 lines
70 KiB
Python

"""Canvas painting: the grid, component boxes, and the routed link wires."""
import math
import os
import sys
import tkinter as tk
import tkinter.font as tkfont
from dataclasses import dataclass
import app_state
import live_hooks
import schema
import undo
import wire_style
from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value,
update_meta_property)
LINK_EPS = 0.5 # tolerance so a wire touching its own box edge is not a collision
# How far a wire keeps from the boxes it passes, in grid squares. A wire stays one
# square clear of a box's top and bottom and two clear of its sides, and it never turns
# closer than two squares to the side of a box -- including the turn just after leaving
# a slot and the last one before arriving at one. Two wires running side by side, either
# way, stand at least half a square apart.
CLEAR_SIDE_GRIDS = 2
CLEAR_END_GRIDS = 1
CLEAR_WIRE_GRIDS = 0.5
# The tight spacing, used only for a wire the grid spacing above cannot place -- boxes
# laid out closer together than it needs. A vertical run sits WIRE_HUG from the box it
# leaves or enters and never nearer than WIRE_MIN_CLEAR; a horizontal one keeps
# LINK_CLEAR from a box above or below; the last turn is ARRIVE_TURN_GRIDS back. Side by
# side, vertical runs stand WIRE_LANE_GAP apart and horizontal ones WIRE_ROW_GAP.
WIRE_HUG = 10
WIRE_MIN_CLEAR = 6
LINK_CLEAR = 5
ARRIVE_TURN_GRIDS = 1.5
WIRE_LANE_GAP = 6
WIRE_ROW_GAP = 3
# 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
@dataclass(frozen=True)
class Clearance:
"""The spacing a route keeps from boxes, at 1:1.
side nearest a vertical run may come to a box's left or right edge
hug where a vertical run beside the box it leaves or enters prefers to sit
end nearest a horizontal run may come to a box's top or bottom edge
arrive how far in front of the target box the last turn is made
lane nearest two different wires' vertical runs may stand side by side
row nearest two different wires' horizontal runs may stand side by side
"""
side: float
hug: float
end: float
arrive: float
lane: float
row: float
def grid_clearance():
"""The spacing every wire is routed with first, measured in grid squares."""
grid = app_state.GRID_SIZE
side = CLEAR_SIDE_GRIDS * grid
apart = CLEAR_WIRE_GRIDS * grid
return Clearance(side=side, hug=side, end=CLEAR_END_GRIDS * grid, arrive=side,
lane=apart, row=apart)
def tight_clearance():
"""The older, tighter spacing, kept for a wire that cannot be routed on the grid one.
Boxes laid out before the grid rule may stand closer than it needs, and a wire that
brushes a box is still far better than a link reduced to knobs.
"""
return Clearance(side=WIRE_MIN_CLEAR, hug=WIRE_HUG, end=LINK_CLEAR,
arrive=max(WIRE_HUG, ARRIVE_TURN_GRIDS * app_state.GRID_SIZE),
lane=WIRE_LANE_GAP, row=WIRE_ROW_GAP)
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.
# Anything larger and boxes land on top of each other before a single one is moved.
HEADER_HEIGHT = 26
SLOT_ROW_HEIGHT = 14
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
# 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 = 20 # minimum clear space kept between two boxes
TIDY_MARGIN = 30 # minimum clear space kept from the top and left canvas edges
# The scales the sheet can be painted at. A ladder rather than a free multiplier, so
# stepping out and back in lands on 1.0 exactly instead of on 0.9999, and so the labels
# are the round percentages people expect. Zoom to Fit is the one thing that lands
# between them, and the ladder's ends are the range it is allowed to reach.
ZOOM_LEVELS = (0.25, 0.33, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0)
# Breathing room left around a sheet that has just been fitted, in painted pixels, so
# the outermost box is not flush against the canvas edge.
FIT_PADDING = 20
# Below this a grid line is closer to its neighbour than the line is wide, which reads
# as a grey wash rather than a grid, so the grid is left off instead.
MIN_GRID_STEP = 3
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):
"""'ontrolControl::HvacControl' -> 'HvacControl'. The kit prefix alone cost ~40px."""
return comp_type.split("::")[-1] if "::" in comp_type else comp_type
def fit_text(text, font, max_px):
"""Truncates with an ellipsis so a label can never widen or overflow its box."""
if max_px <= 0:
return ""
if font.measure(text) <= max_px:
return text
ellipsis = "…"
if font.measure(ellipsis) > max_px:
return ""
trimmed = text
while trimmed and font.measure(trimmed + ellipsis) > max_px:
trimmed = trimmed[:-1]
return (trimmed + ellipsis) if trimmed else ellipsis
# Name, type and slot text, at 1:1. Zoom scales the fonts from these, which is why the
# sizes are named here rather than written into sheet_fonts alone.
FONT_SIZES = (9, 7, 8)
def sheet_fonts():
"""The three fonts a component box is drawn with. Box sizes are measured from these."""
name_size, type_size, slot_size = FONT_SIZES
return (
tkfont.Font(family="Segoe UI", size=name_size, weight="bold"),
tkfont.Font(family="Segoe UI", size=type_size),
tkfont.Font(family="Segoe UI", size=slot_size),
)
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.
Drawn at the sheet's zoom, so a box still lands on a line at every scale. The area
covered is the same patch of sheet whatever the zoom, which is why the span is
scaled along with the step.
"""
canvas = app_state.canvas
canvas.delete("grid")
step = app_state.GRID_SIZE * app_state.zoom
if step < MIN_GRID_STEP:
return
span = 3000 * app_state.zoom
for index in range(int(span // step) + 1):
offset = index * step
canvas.create_line(offset, 0, offset, span, fill="#e0e0e0", tags="grid")
canvas.create_line(0, offset, span, offset, fill="#e0e0e0", tags="grid")
canvas.tag_lower("grid")
def create_round_rectangle(canvas, x1, y1, x2, y2, radius=4, **kwargs):
"""Draws a visually smooth rounded rectangle using native canvas arcs and polygons."""
points = [
x1+radius, y1, x1+radius, y1, x2-radius, y1, x2-radius, y1, x2, y1,
x2, y1+radius, x2, y1+radius, x2, y2-radius, x2, y2-radius, x2, y2,
x2-radius, y2, x2-radius, y2, x1+radius, y2, x1+radius, y2, x1, y2,
x1, y2-radius, x1, y2-radius, x1, y1+radius, x1, y1+radius, x1, y1
]
return canvas.create_polygon(points, **kwargs, smooth=True)
# --- Link indexing and routing ---
def build_linked_slot_index():
"""Maps component path -> set of slot names touched by a link, either end."""
index = {}
if app_state.xml_root_element is None:
return index
links_elem = app_state.xml_root_element.find("links")
if links_elem is None:
return index
for link in links_elem.findall("link"):
for ref in (link.get("from", ""), link.get("to", "")):
if "." not in ref:
continue
comp_path, slot_name = ref.rsplit(".", 1)
index.setdefault(comp_path, set()).add(slot_name)
return index
def get_box_bounds(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:
x1, x2 = x2, x1
if y1 > y2:
y1, y2 = y2, y1
for bx1, by1, bx2, by2 in boxes:
if x2 < bx1 + LINK_EPS or x1 > bx2 - LINK_EPS:
continue
if y2 < by1 + LINK_EPS or y1 > by2 - LINK_EPS:
continue
return True
return False
def path_is_clear(points, boxes, clear):
"""True if no part of the path runs under a box or crowds one.
Every run between the first turn and the last keeps `clear` from each box: `end`
above and below, `side` left and right. The two runs pinned to the slots are only
held out from under the boxes, since they have to reach their pins wherever the
rows happen to be.
"""
points = wire_style.simplify(points)
padded = [(x1 - clear.side, y1 - clear.end, x2 + clear.side, y2 + clear.end)
for x1, y1, x2, y2 in boxes]
last = len(points) - 2
for idx, ((x1, y1), (x2, y2)) in enumerate(zip(points, points[1:])):
pinned = idx in (0, last)
if segment_hits_boxes(x1, y1, x2, y2, boxes if pinned else padded):
return False
return True
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, clear):
"""Vertical channel positions to try, best first.
A run hugs the box it leaves or the box it enters, `clear.hug` away -- that is the
rule that a vertical line stays close to 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] + clear.hug, dst_box[0] - clear.arrive]
for bx1, _, bx2, _ in boxes:
ordered.append(bx2 + clear.hug)
ordered.append(bx1 - clear.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 same_signal(pins, other_pins):
"""True if two wires may lie exactly on top of each other.
A wire's `pins` are the slots at its two ends. Wires leaving one output carry the
same signal, and wires arriving at one input merge into it, so where two of them
share a pin they may share a run too -- that is a branch or a merge, not a clash.
"""
return bool(pins and other_pins and pins & other_pins)
def runs_clash(at, lo, hi, other_at, other_lo, other_hi, pins, other_pins, apart):
"""True if a run would sit too close beside, on top of, or end to end with another.
`at` is the run's fixed coordinate (x for a vertical run, y for a horizontal one)
and lo..hi its extent along the other axis. Two runs closer than `apart` side by
side clash where they overlap, and so do two on one line that stop less than
`apart` short of each other, which would read as one continuous wire. A wire never
clashes with itself: its own runs meet at its bends.
"""
if pins is not None and pins == other_pins:
return False
if hi < other_lo - apart + LINK_EPS or lo > other_hi + apart - LINK_EPS:
return False
gap = abs(at - other_at)
if gap >= apart - LINK_EPS:
return False
return not (gap < LINK_EPS and same_signal(pins, other_pins))
def lane_is_crowded(lane, run_lo, run_hi, claimed, pins, apart):
"""True if a vertical run at `lane` would crowd one already placed.
Wires sharing a pin may share a trunk exactly -- that is a branch, and gets a
junction dot. Running alongside it a few pixels off is refused for every wire alike.
"""
return any(runs_clash(lane, run_lo, run_hi, tx, t_lo, t_hi, pins, t_pins, apart)
for tx, t_lo, t_hi, t_pins in claimed)
def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, clear, max_steps=8,
pins=None):
"""Nudges cx outwards in `clear.lane` steps until the run has a lane of its own.
A lane is rejected while it crowds a box (closer than `clear.side`) or while it
would run alongside an already placed run that overlaps it vertically -- two runs
passing through the same gap have to stand `clear.lane` apart. Returns None when
no lane inside the gap works. `max_steps` of 1 permits no nudging at all, which is
how the caller asks for the exact hug position and nothing else.
"""
for step in range(0, max_steps):
for sign in ((0,) if step == 0 else (1, -1)):
lane = cx + sign * step * clear.lane
if not (low <= lane <= high):
continue
if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS:
continue
if not lane_is_crowded(lane, run_lo, run_hi, taken, pins, clear.lane):
return lane
return None
def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, clear, max_offset=0,
min_offset=0, pins=None):
"""Lane for a run pinned to one box edge, starting `clear.side` out from it.
`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 `clear.hug`, out to `max_offset`. Returns None when nothing up to
that limit clears both the boxes and the runs already placed. `min_offset` raises
the floor, and the hug position with it, for a run that must stand further off.
"""
floor = int(math.ceil(max(clear.side, min_offset)))
hug = max(int(math.ceil(clear.hug)), floor)
offsets = list(range(hug, floor - 1, -1))
if max_offset > hug:
offsets += list(range(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) < clear.side - LINK_EPS:
continue
if lane_is_crowded(lane, run_lo, run_hi, claimed, pins, clear.lane):
continue
return lane
return None
def row_is_stacked(y, x_lo, x_hi, rows, pins, apart):
"""True if a horizontal run here would hide under, or blur into, another wire's.
A wire sharing a pin may lie exactly on top: 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(runs_clash(y, x_lo, x_hi, ry, rx1, rx2, pins, row_pins, apart)
for ry, rx1, rx2, row_pins in rows)
def free_rows(cy, x_lo, x_hi, rows, pins, apart, 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 `apart` 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 * apart
if not row_is_stacked(row, x_lo, x_hi, rows, pins, apart):
out.append(row)
return out
def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None,
direct_only=False, pins=None, clear=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, pins), ...], "rows": [(y, x1, x2, pins), ...]}. Each new wire
claims a vertical lane and a horizontal lane of its own against it; pass None to
route in isolation. `pins` are the slots at the wire's two ends (see same_signal). The search relaxes in tiers: a wire that can only be drawn with its run
standing off further than it would like is still far better than no wire at all.
`clear` is the spacing kept from the boxes, grid_clearance() unless given.
"""
clear = clear or grid_clearance()
start = (src_box[2], src_y)
end = (dst_box[0], dst_y)
# The first turn stands clear.side out from the source, the last clear.arrive in
# front of the target, never closer.
leave = (start[0] + clear.side, src_y)
arrive = (end[0] - clear.arrive, dst_y)
low, high = min(leave[0], arrive[0]), max(leave[0], arrive[0])
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."""
points = wire_style.simplify(points)
return [
(y1, min(x1, x2), max(x1, x2), pins)
for (x1, y1), (x2, y2) in zip(points, points[1:])
if abs(y1 - y2) < LINK_EPS and abs(x1 - x2) > LINK_EPS
]
def stacked(candidate):
return any(row_is_stacked(y, x1, x2, claimed_rows, pins, clear.row)
for y, x1, x2, _ in rows_of(candidate))
def straight_through(max_steps, allow_stack=False):
"""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.
"""
candidates = channel_candidates(src_box, dst_box, boxes, low, high, clear)
for cx in candidates:
lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear,
max_steps, pins)
if lane is None:
continue
candidate = [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
if not path_is_clear(candidate, boxes, clear):
continue
if not allow_stack and stacked(candidate):
continue
return candidate, [(lane, run_lo, run_hi, pins)]
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` a 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 - clear.end)
lanes.add(by2 + clear.end)
# 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) - clear.end)
lanes.add(max(bottoms) + clear.end)
for bottom in bottoms:
nearest_below = [t for t in tops if t > bottom + 2 * clear.end]
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, clear, max_offset, pins=pins)
if out_x is None:
continue
# Checked against its own outward run too, but as another wire's: the two
# ends of one wire must never fold back onto each other.
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), None)], clear,
max_offset, min_offset=clear.arrive, pins=pins)
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, pins, clear.row)
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)
< clear.side - LINK_EPS):
continue
if (vertical_run_clearance(in_x, in_lo, in_hi, boxes)
< clear.side - LINK_EPS):
continue
candidate = [start, (out_x, src_y), (out_x, cy),
(in_x, cy), (in_x, dst_y), end]
if not path_is_clear(candidate, boxes, clear):
continue
# The runs out of and into the slots are pinned to their rows, so they
# are checked here: the long run was already kept clear by free_rows.
if not allow_stack and stacked(candidate):
continue
return candidate, [(out_x, out_lo, out_hi, pins),
(in_x, in_lo, in_hi, pins)]
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:
standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side)
attempts = (
lambda: straight_through(1), # hugging, own lane
lambda: around_boxes(clear.hug), # hugging, routed around
lambda: straight_through(8), # lane nudged out of the band
lambda: around_boxes(standoff), # 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(standoff, 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
return None
def resolve_slot_anchor(comp_tag, slot_name):
"""Returns (box_bounds, y) for a slot row, centring on the box when the row is hidden."""
box = get_box_bounds(comp_tag)
if box is None:
return None, None
offset = app_state.component_slot_offsets.get(comp_tag, {}).get(slot_name)
if offset is None:
return box, (box[1] + box[3]) / 2
return box, box[1] + offset
def draw_knob(canvas, 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.
The canvas is passed in rather than read off `app_state`: the Link View draws the
same grey marker on a canvas of its own, and a knob is a knob wherever it is shown.
"""
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. It stays centred
# even on a half-and-half knob: pushed into the wire-coloured half it would fill
# that half almost completely and leave no colour to read.
canvas.create_oval(
x - KNOB_CORE_RADIUS, y - KNOB_CORE_RADIUS,
x + 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
@dataclass
class WireJob:
"""One link with both ends on this sheet, on its way to becoming a wire."""
tag: str
src_box: tuple
src_y: float
dst_box: tuple
dst_y: float
owner: str # the source pin; its wires branch with a junction dot
target: str # the target pin
points: list = None # the routed path, or None while unrouted / unroutable
@property
def pins(self):
"""Both end slots, told apart by side since one slot can be either end."""
return frozenset((("out", self.owner), ("in", self.target)))
def stub_rows(self, clear):
"""The two runs pinned to the slots, before the first turn and after the last.
Whatever route the wire takes, these are where it has to be, so they are
claimed for every wire before any is routed. Otherwise an early wire can lay
its long run along a row a later wire's slot is obliged to leave by.
"""
return [(self.src_y, self.src_box[2], self.src_box[2] + clear.side, self.pins),
(self.dst_y, self.dst_box[0] - clear.arrive, self.dst_box[0], self.pins)]
def route(self, boxes, ledger, clear, direct_only=False):
self.points = route_link_path(self.src_box, self.src_y, self.dst_box, self.dst_y,
boxes, ledger, direct_only=direct_only,
pins=self.pins, clear=clear)
def knob_ends(self):
"""Where the two knobs of an unroutable link sit: (x1, y1, x2, y2)."""
return (self.src_box[2] + KNOB_RADIUS, self.src_y,
self.dst_box[0] - KNOB_RADIUS, self.dst_y)
def collect_link_jobs(links_elem, path_to_tag, want_knob):
"""Turns the sheet's <link>s into wire jobs, and off-sheet ones into knob requests."""
jobs = []
for link in links_elem.findall("link"):
from_ref = link.get("from", "")
to_ref = link.get("to", "")
if "." not in from_ref or "." not in to_ref:
continue
from_path, from_slot = from_ref.rsplit(".", 1)
to_path, to_slot = to_ref.rsplit(".", 1)
src_tag = path_to_tag.get(from_path)
dst_tag = path_to_tag.get(to_path)
if not (src_tag or dst_tag):
continue
wire_tag = f"link_wire_{len(app_state.canvas_link_map)}"
if src_tag and dst_tag:
src_box, src_y = resolve_slot_anchor(src_tag, from_slot)
dst_box, dst_y = resolve_slot_anchor(dst_tag, to_slot)
if src_box is None or dst_box is None:
continue
app_state.canvas_link_map[wire_tag] = link
jobs.append(WireJob(wire_tag, src_box, src_y, dst_box, dst_y, from_ref, to_ref))
continue
# 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
app_state.canvas_link_map[wire_tag] = link
want_knob(knob_x, slot_y, "off_sheet", wire_tag)
return jobs
def paint_wires(canvas, jobs):
"""Draws routed wires: rounded bends, hops over crossings, dots where a fan-out splits.
Junction dots go down first so the wires sit on top of them; a click near a branch
then still lands on a wire rather than on an untagged dot.
"""
for job in jobs:
job.points = wire_style.simplify(job.points)
by_owner = {}
for job in jobs:
by_owner.setdefault(job.owner, []).append(job.points)
r = wire_style.JUNCTION_RADIUS
for x, y in wire_style.fan_out_junctions(by_owner):
canvas.create_oval(x - r, y - r, x + r, y + r, fill=LINK_COLOR, outline="",
tags=("link", "link_junction"))
hops = wire_style.find_hops([job.points for job in jobs])
for job, job_hops in zip(jobs, hops):
flat = [c for point in wire_style.styled_points(job.points, job_hops) for c in point]
canvas.create_line(
*flat, fill=LINK_COLOR, width=1, joinstyle=tk.ROUND,
arrow=tk.LAST, arrowshape=(8, 9, 3), tags=("link", job.tag)
)
def draw_links():
"""Paints wires between slots on this sheet and knobs for links leaving it."""
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
app_state.selected_link_tags.clear()
_selected_link_restore.clear()
app_state.unroutable_link_count = 0
if app_state.xml_root_element is None:
return
links_elem = app_state.xml_root_element.find("links")
if links_elem is None:
return
path_to_tag = {
get_component_path(comp_element, app_state.xml_root_element): comp_tag
for comp_element, comp_tag in app_state.canvas_tag_by_element.items()
}
if not path_to_tag:
return
# 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):
entry = knobs.setdefault(
(round(x, 1), round(y, 1)),
{"on_sheet": False, "off_sheet": False, "tags": set()},
)
entry[kind] = True
entry["tags"].add(wire_tag)
jobs = collect_link_jobs(links_elem, path_to_tag, want_knob)
boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b]
# Shorter, simpler links claim lanes first; ties go to the smaller vertical span.
jobs.sort(key=lambda j: (abs(j.dst_box[0] - j.src_box[2]), abs(j.dst_y - j.src_y)))
# What the wires placed so far have claimed, shared across the whole sheet.
# Routed in two passes: a wire that can run straight has its horizontal runs pinned
# 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.
# Every wire is offered the full grid spacing before any wire settles for less: boxes
# placed closer than the grid rule allows still get a wire, just a tighter one.
roomy = grid_clearance()
ledger = {"cols": [], "rows": [row for job in jobs for row in job.stub_rows(roomy)]}
for job in jobs:
job.route(boxes, ledger, roomy, direct_only=True)
for clear in (roomy, tight_clearance()):
for job in jobs:
if job.points is None:
job.route(boxes, ledger, clear)
routed = [job for job in jobs if job.points is not None]
paint_wires(canvas, routed)
# No route stays out from under the boxes: rather than hide a wire beneath a
# component, show the link as knobs on both slots -- the marker a link to another
# sheet gets -- and remember the ends so selecting it can draw a dashed hint.
for job in jobs:
if job.points is None:
x1, y1, x2, y2 = job.knob_ends()
want_knob(x1, y1, "on_sheet", job.tag)
want_knob(x2, y2, "on_sheet", job.tag)
app_state.knob_link_ends[job.tag] = job.knob_ends()
for (x, y), entry in knobs.items():
draw_knob(canvas, x, y, entry["on_sheet"], entry["off_sheet"],
tuple(entry["tags"]))
# Wires sit behind the boxes so they never paint over a neighbouring component.
# Knobs are raised back above: they sit just outside their own box edge, and while
# 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 = len(jobs) - len(routed)
# wire tag -> [(item, fill, outline, width)], captured when that link is selected
_selected_link_restore = {}
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, add=False):
"""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.
With `add` the link joins whatever links are already selected (Ctrl+click);
otherwise it becomes the only one.
"""
canvas = app_state.canvas
if not add:
deselect_link()
elif wire_tag in app_state.selected_link_tags:
return
if wire_tag and wire_tag in app_state.canvas_link_map:
# A knob can stand for several links. One already selected through another link
# is red by now, so its true colours are taken from that link's record.
captured = {entry[0]: entry for entries in _selected_link_restore.values()
for entry in entries}
restore = _selected_link_restore.setdefault(wire_tag, [])
for item in canvas.find_withtag(wire_tag):
restore.append(captured.get(item) or (
item,
canvas.itemcget(item, "fill"),
canvas.itemcget(item, "outline") if canvas.type(item) != "line" else "",
canvas.itemcget(item, "width"),
))
_paint_link_selected(wire_tag)
app_state.selected_link_tags.append(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", f"link_select_hint_{wire_tag}")
)
canvas.tag_raise("link_select_hint")
canvas.tag_raise("link_knob")
def _paint_link_selected(wire_tag):
"""Paints one link's wire and knobs in the selection red."""
canvas = app_state.canvas
for item in canvas.find_withtag(wire_tag):
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)
def toggle_link(wire_tag):
"""Ctrl+click: adds a link to the selection, or takes it back out."""
if wire_tag in app_state.selected_link_tags:
_restore_link(wire_tag)
app_state.selected_link_tags.remove(wire_tag)
app_state.selected_link_tag = (app_state.selected_link_tags[-1]
if app_state.selected_link_tags else None)
# A knob shared with a link still selected went back to its own colours above.
for still_selected in app_state.selected_link_tags:
_paint_link_selected(still_selected)
if app_state.canvas.find_withtag("link_select_hint"):
app_state.canvas.tag_raise("link_select_hint")
app_state.canvas.tag_raise("link_knob")
else:
select_link(wire_tag, add=True)
def _restore_link(wire_tag):
"""Puts one selected link back exactly as it was drawn."""
canvas = app_state.canvas
canvas.delete(f"link_select_hint_{wire_tag}")
for item, fill, outline, width in _selected_link_restore.pop(wire_tag, ()):
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
def deselect_link():
"""Puts every selected link back exactly as it was drawn."""
canvas = app_state.canvas
canvas.delete("link_select_hint")
for wire_tag in app_state.selected_link_tags:
_restore_link(wire_tag)
app_state.selected_link_tags.clear()
app_state.selected_link_tag = None
_selected_link_restore.clear()
# --- Component painting ---
def draw_component_box(canvas, comp, x, y, fonts, linked_slot_index, tags,
outline_tags=()):
"""Paints one component box with its top-left corner at (x, y).
Returns (width, height, slot_offsets), where slot_offsets maps a slot name to the y
offset of its row's centre from the box top -- which is what a wire anchors to.
Every caller gets the same box. The wiresheet paints these at their meta positions
and the Link View arranges them around one component; a box that read differently
between the two would suggest the user was looking at something else. `tags` goes on
every item, so the whole box is moved and picked as one, and the sheet adds
`outline_tags` to the outline alone -- the item it strokes to show a selection.
"""
name_font, type_font, slot_font = fonts
(box_width, box_height, slot_data_rows,
full_display_name, display_type) = measure_box(comp, fonts, linked_slot_index)
# Filled in as the rows are drawn below; keyed by slot name for link anchoring.
slot_offsets = {}
create_round_rectangle(
canvas, x, y, x + box_width, y + box_height,
radius=5, fill="white", outline="#7f9db9", width=2,
tags=tuple(tags) + tuple(outline_tags)
)
# Draw Header bar
canvas.create_rectangle(
x + 1, y + 1, x + box_width - 1, y + HEADER_HEIGHT,
fill="#dce6f2", outline="", tags=tags
)
canvas.create_line(
x, y + HEADER_HEIGHT, x + box_width, y + HEADER_HEIGHT,
fill="#7f9db9", tags=tags
)
# Header textual labels, ellipsised to leave room for the indicator square
header_text_width = box_width - 26
canvas.create_text(
x + 5, y + 8, anchor="w",
text=fit_text(full_display_name, name_font, header_text_width),
font=name_font, fill="black", tags=tags
)
canvas.create_text(
x + 5, y + 19, anchor="w",
text=fit_text(display_type, type_font, header_text_width),
font=type_font, fill="#555555", tags=tags
)
# Quick access indicator icon background slot placeholder
canvas.create_rectangle(
x + box_width - 17, y + 6, x + box_width - 5, y + 18,
fill="#ffe699", outline="#b4c6e7", tags=tags
)
# Render dynamic slots rows
current_row_y = y + HEADER_HEIGHT
for idx, (raw_name, s_name, val_str) in enumerate(slot_data_rows):
row_bg = "#eaeaea" if idx % 2 == 0 else "#dfdfdf"
slot_offsets[raw_name] = (current_row_y + (SLOT_ROW_HEIGHT // 2)) - y
canvas.create_rectangle(
x + 1, current_row_y, x + box_width - 1, current_row_y + SLOT_ROW_HEIGHT,
fill=row_bg, outline="", tags=tags
)
# Value keeps its full text; the name yields space to it when the row is tight
value_width = slot_font.measure(val_str)
label_space = box_width - 16 - value_width
canvas.create_text(
x + 6, current_row_y + (SLOT_ROW_HEIGHT // 2), anchor="w",
text=fit_text(s_name, slot_font, label_space),
font=slot_font, fill="black", tags=tags
)
canvas.create_text(
x + box_width - 6, current_row_y + (SLOT_ROW_HEIGHT // 2), anchor="e",
text=val_str, font=slot_font, fill="#333333", tags=tags
)
current_row_y += SLOT_ROW_HEIGHT
canvas.create_line(
x, current_row_y, x + box_width, current_row_y,
fill="#b8b8b8", tags=tags
)
return box_width, box_height, slot_offsets
def render_components(components_to_render):
"""Handles parsing and visual painting of given component listings on canvas with slot attributes."""
canvas = app_state.canvas
app_state.active_components_list = components_to_render
app_state.selected_canvas_tag = None
app_state.selected_canvas_tags.clear()
canvas.delete("component")
canvas.delete("link")
canvas.delete("slot_hover") # link_drag's hover ring, stale once boxes repaint
app_state.canvas_comp_map.clear()
app_state.canvas_tag_by_element.clear()
app_state.component_slot_offsets.clear()
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
for comp in components_to_render:
meta_prop = comp.find("prop[@name='meta']")
meta_str = meta_prop.get("val") if meta_prop is not None else None
raw_x, raw_y = read_meta_property(meta_str)[:2]
coord_x = raw_x * meta_scale
coord_y = raw_y * meta_scale
comp_tag = f"comp_group_{id(comp)}"
app_state.canvas_comp_map[comp_tag] = comp
app_state.canvas_tag_by_element[comp] = comp_tag
box_width, box_height, slot_offsets = draw_component_box(
canvas, comp, coord_x, coord_y, fonts, linked_slot_index,
("component", comp_tag), outline_tags=("block_outline",)
)
app_state.component_slot_offsets[comp_tag] = slot_offsets
if coord_x + box_width > max_reached_x:
max_reached_x = coord_x + box_width
if coord_y + box_height > max_reached_y:
max_reached_y = coord_y + box_height
draw_links()
apply_sheet_zoom(fonts, max_reached_x + 200, max_reached_y + 200)
def apply_sheet_zoom(fonts, extent_x, extent_y):
"""Scales the finished sheet to app_state.zoom and sizes the canvas to match.
Painting and routing both work at 1:1, because that is where every metric here is
tuned -- box header and row heights, wire stubs, lane gaps, knob radii -- so zoom is
applied to the finished picture rather than threaded through each of them. Text
scales with it because the three fonts the boxes were drawn with are resized after
they were measured with, which is what keeps a zoomed box in exactly the proportions
of a 1:1 one.
"""
canvas = app_state.canvas
zoom = app_state.zoom
if zoom != 1.0:
canvas.scale("component", 0, 0, zoom, zoom)
canvas.scale("link", 0, 0, zoom, zoom)
for font, base_size in zip(fonts, FONT_SIZES):
font.configure(size=max(1, round(base_size * zoom)))
scale_knob_ends(zoom)
canvas.config(scrollregion=(0, 0, extent_x * zoom, extent_y * zoom))
draw_grid()
def scale_knob_ends(zoom):
"""Brings the cached knob-to-knob ends onto the painted scale.
They are recorded while the wires are routed, which happens at 1:1, and are read
back at click time to draw the dashed line across a selected pair of knobs.
"""
for wire_tag, ends in app_state.knob_link_ends.items():
app_state.knob_link_ends[wire_tag] = tuple(value * zoom for value in ends)
def redraw_links():
"""Re-routes the wires of a sheet that is already painted, at whatever zoom it is at.
The router reads box positions straight off the canvas and measures its clearances
in 1:1 pixels, so the boxes are put back to that scale for the duration and the
whole picture, new wires included, is scaled again afterwards. Callers that have
just moved a box -- a drag, a drop -- want this rather than draw_links.
"""
zoom = app_state.zoom
if zoom == 1.0:
draw_links()
return
canvas = app_state.canvas
canvas.scale("component", 0, 0, 1.0 / zoom, 1.0 / zoom)
draw_links()
canvas.scale("component", 0, 0, zoom, zoom)
canvas.scale("link", 0, 0, zoom, zoom)
scale_knob_ends(zoom)
def zoom_step(direction):
"""The next zoom level in (+1) or out (-1); the current one at either end of the ladder.
The scale in force need not be one of the ladder's -- Zoom to Fit lands wherever the
sheet happens to fit -- so stepping in takes the first level above it and stepping
out the first below.
"""
current = app_state.zoom
if direction > 0:
above = [level for level in ZOOM_LEVELS if level > current + 1e-9]
return above[0] if above else ZOOM_LEVELS[-1]
below = [level for level in ZOOM_LEVELS if level < current - 1e-9]
return below[-1] if below else ZOOM_LEVELS[0]
def sheet_content_bounds():
"""The 1:1 rectangle the painted sheet occupies, or None if nothing is painted.
Wires are measured along with the boxes: one that loops back can reach further left
or higher than any box does, and a fit that cut it off would be hiding wiring.
Measured off item coordinates rather than canvas.bbox(), which is inflated by the
outline stroke. A stroke is the same two pixels at every zoom, so a bbox divided
back to 1:1 reports a slightly smaller sheet the further in it is zoomed -- enough
that fitting an already fitted sheet would creep instead of standing still.
"""
canvas = app_state.canvas
if canvas is None:
return None
xs, ys = [], []
for comp_tag in app_state.canvas_comp_map:
bounds = get_box_bounds(comp_tag)
if bounds:
xs.extend((bounds[0], bounds[2]))
ys.extend((bounds[1], bounds[3]))
for item in canvas.find_withtag("link"):
coords = canvas.coords(item)
xs.extend(coords[0::2])
ys.extend(coords[1::2])
if not xs or not ys:
return None
zoom = app_state.zoom
return (min(xs) / zoom, min(ys) / zoom, max(xs) / zoom, max(ys) / zoom)
def fit_zoom_level():
"""The scale at which the whole painted sheet fits the visible canvas, or None.
Not snapped to the ladder: a sheet fits at whatever scale it fits at, and set_zoom
holds that between the ladder's ends. Stepping in or out from there carries on up
and down the ladder as usual.
"""
canvas = app_state.canvas
bounds = sheet_content_bounds()
if bounds is None:
return None
# A canvas cannot scroll left of or above its origin, so anything out there cannot
# be brought into view however far the sheet is zoomed out. The fit is measured from
# the origin in that case rather than from the content's own corner.
left = max(0.0, bounds[0])
top = max(0.0, bounds[1])
content_width = bounds[2] - left
content_height = bounds[3] - top
view_width = canvas.winfo_width() - 2 * FIT_PADDING
view_height = canvas.winfo_height() - 2 * FIT_PADDING
if min(content_width, content_height, view_width, view_height) <= 0:
return None
return min(view_width / content_width, view_height / content_height)
def set_zoom(level):
"""Repaints the open sheet at this scale. Returns the level actually applied."""
level = min(max(level, ZOOM_LEVELS[0]), ZOOM_LEVELS[-1])
if level != app_state.zoom:
app_state.zoom = level
if app_state.xml_root_element is None:
# Nothing painted yet, but the grid is already on screen.
draw_grid()
else:
render_current_sheet()
return app_state.zoom
def render_current_sheet():
"""Repaints whichever sheet is open, re-reading children from the DOM.
Undo of a delete puts an element back, so the component list has to be rebuilt from
the document rather than reused from the previous render.
"""
if app_state.xml_root_element is None:
return
parent = app_state.current_sheet_parent
if parent is None:
app_element = app_state.xml_root_element.find("app")
if app_element is None:
app_element = app_state.xml_root_element
parent = app_element
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 clear.side out from the source box and clear.arrive in front of the
target box, and stand clear.lane apart, so n of them span
clear.side + clear.arrive + (n - 1) * clear.lane.
"""
clear = grid_clearance()
return max(TIDY_GAP, clear.side + clear.arrive
+ max(0, run_count - 1) * clear.lane)
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. 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); in live
mode moved_count is None when the device refused every move, already reported.
"""
if not app_state.canvas_comp_map:
return 0, 0
components = list(app_state.canvas_comp_map.values())
columns, column_keys, demand = build_layout_items(components)
if not column_keys:
return 0, 0
scale = sheet_meta_scale(components)
placements, clamped = plan_columns(columns, column_keys, demand, scale)
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
command = undo.MetaMoveCommand(changes, label="tidy layout")
command.redo() # apply it; the command is the single writer of meta
if app_state.live_mode:
# Each new position goes to the device, and a box the device refused goes back to
# where the device still has it. Only the boxes that moved are undoable.
failures = []
moved = []
for change in changes:
element, old_val, _, _ = change
new_val = element.find("prop[@name='meta']").get("val", "0")
if not live_hooks.live_set_prop_value(element, "meta", new_val):
failures.append(f"{element.get('name', '?')}: {live_hooks.last_error}")
if old_val is None:
remove_prop(element, "meta")
else:
set_prop_value(element, "meta", old_val)
else:
moved.append(change)
if moved:
undo.push(undo.LiveMetaMoveCommand(moved, label="tidy layout"))
render_current_sheet()
if failures:
live_hooks.warn_failure(
"Tidy Layout",
f"{len(failures)} of {len(changes)} component(s) could not be moved on the "
"device and were left where they were",
"\n".join(f" • {failure}" for failure in failures),
)
if len(failures) == len(changes):
return None, clamped # already reported; nothing moved
app_state.mark_dirty()
return len(changes) - len(failures), clamped
undo.push(command)
app_state.mark_dirty()
render_current_sheet()
return len(changes), clamped
# ---------------------------------------------------------------------------
# Print (PNG export via screen capture)
# ---------------------------------------------------------------------------
_PRINT_PAD = 20 # breathing room around content in the saved image
def print_sheet(event=None):
"""Saves the wiresheet as a PNG image sized to its content and opens it.
The canvas is temporarily scrolled and resized so that every component and wire
is visible, then captured with PIL.ImageGrab. The grid is hidden during the
capture. The image size adapts automatically: a sparse sheet produces a small
image, a crowded one a larger image. When the content at the current zoom is
larger than the screen, the zoom is temporarily reduced so the full sheet fits.
"""
canvas = app_state.canvas
if canvas is None:
return
bounds = sheet_content_bounds()
if bounds is None:
app_state.set_status("Nothing to print.")
return
from PIL import ImageGrab
from tkinter import filedialog
# Build a default file name from the file name and current sheet path.
title_parts = []
if app_state.current_file_path:
title_parts.append(os.path.splitext(os.path.basename(app_state.current_file_path))[0])
if app_state.current_sheet_parent is not None:
path = get_component_path(app_state.current_sheet_parent,
app_state.xml_root_element)
if path:
title_parts.append(path.replace("/", "_"))
default_name = ("_".join(title_parts) if title_parts else "wiresheet") + ".png"
png_path = filedialog.asksaveasfilename(
parent=app_state.window,
title="Save Wiresheet as PNG",
defaultextension=".png",
initialfile=default_name,
filetypes=[("PNG Image", "*.png"), ("All Files", "*.*")],
)
if not png_path:
return
pad = _PRINT_PAD
# Work out how large the content is at 1:1 and decide a print zoom that keeps
# the whole sheet within the available screen area.
content_w = bounds[2] - bounds[0] + 2 * pad
content_h = bounds[3] - bounds[1] + 2 * pad
screen_w = canvas.winfo_screenwidth() - 100
screen_h = canvas.winfo_screenheight() - 100
max_zoom = min(screen_w / content_w, screen_h / content_h, app_state.zoom)
# Don't zoom below the minimum ladder level.
print_zoom = max(max_zoom, ZOOM_LEVELS[0])
# The original zoom is restored after capture.
original_zoom = app_state.zoom
zoom_changed = abs(print_zoom - original_zoom) > 1e-9
# Content rectangle at the print zoom (canvas pixel coordinates).
x1 = max(0.0, bounds[0] * print_zoom - pad)
y1 = max(0.0, bounds[1] * print_zoom - pad)
x2 = bounds[2] * print_zoom + pad
y2 = bounds[3] * print_zoom + pad
capture_w = int(math.ceil(x2 - x1))
capture_h = int(math.ceil(y2 - y1))
# Remember the canvas's current geometry so it can be restored afterwards.
old_scrollregion = canvas.cget("scrollregion")
old_width = canvas.winfo_width()
old_height = canvas.winfo_height()
old_xview = canvas.xview()
old_yview = canvas.yview()
try:
# Re-render at the print zoom if needed.
if zoom_changed:
app_state.zoom = print_zoom
render_current_sheet()
# Hide the grid.
grid_items = canvas.find_withtag("grid")
for item in grid_items:
canvas.itemconfigure(item, state="hidden")
# Expand the canvas widget and scroll region so the full content area is
# rendered on-screen in one shot.
canvas.config(
width=capture_w,
height=capture_h,
scrollregion=(x1, y1, x2, y2),
)
canvas.xview_moveto(0.0)
canvas.yview_moveto(0.0)
# Force Tk to process the resize and repaint before the grab.
canvas.update_idletasks()
canvas.update()
# Grab the canvas area from the screen.
cx = canvas.winfo_rootx()
cy = canvas.winfo_rooty()
grab_w = min(capture_w, canvas.winfo_width())
grab_h = min(capture_h, canvas.winfo_height())
image = ImageGrab.grab(bbox=(cx, cy, cx + grab_w, cy + grab_h))
image.save(png_path)
# Restore canvas geometry.
canvas.config(
width=old_width,
height=old_height,
scrollregion=old_scrollregion,
)
canvas.xview_moveto(old_xview[0])
canvas.yview_moveto(old_yview[0])
# Restore the grid.
for item in grid_items:
canvas.itemconfigure(item, state="normal")
# Restore the original zoom if it was changed.
if zoom_changed:
app_state.zoom = original_zoom
render_current_sheet()
else:
canvas.update_idletasks()
# Open the saved image with the system viewer.
if sys.platform == "win32":
os.startfile(png_path)
else:
import subprocess
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.Popen([opener, png_path])
app_state.set_status(f"Wiresheet saved: {png_path}")
except Exception as exc:
# Restore everything on error.
try:
canvas.config(
width=old_width,
height=old_height,
scrollregion=old_scrollregion,
)
canvas.xview_moveto(old_xview[0])
canvas.yview_moveto(old_yview[0])
for item in canvas.find_withtag("grid"):
canvas.itemconfigure(item, state="normal")
if zoom_changed:
app_state.zoom = original_zoom
render_current_sheet()
else:
canvas.update_idletasks()
except Exception:
pass
app_state.set_status(f"Print failed: {exc}", warn=True)