The final vertical run into an input slot now stands at least 1.5 * GRID_SIZE in front of the box, so the arrowhead sits on a straight run instead of on the bend. Column gaps in tidy layout widen to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1547 lines
62 KiB
Python
1547 lines
62 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
|
|
|
|
import app_state
|
|
import live_hooks
|
|
import schema
|
|
import undo
|
|
from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value,
|
|
update_meta_property)
|
|
|
|
|
|
LINK_STUB = 8 # straight run leaving a slot before the wire is allowed to turn
|
|
LINK_CLEAR = 5 # 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 = 10
|
|
WIRE_MIN_CLEAR = 6
|
|
WIRE_LANE_GAP = 6
|
|
|
|
# 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
|
|
|
|
# The last turn before a target slot is kept this many grid squares back from the box,
|
|
# so the arrowhead always sits on a straight run of its own instead of on the bend.
|
|
ARRIVE_TURN_GRIDS = 1.5
|
|
|
|
# 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 = 3
|
|
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):
|
|
for idx in range(len(points) - 1):
|
|
(x1, y1), (x2, y2) = points[idx], points[idx + 1]
|
|
if segment_hits_boxes(x1, y1, x2, y2, boxes):
|
|
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 arrive_turn():
|
|
"""How far in front of a target box the wire makes its last turn, at 1:1."""
|
|
return max(WIRE_HUG, ARRIVE_TURN_GRIDS * app_state.GRID_SIZE)
|
|
|
|
|
|
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] - arrive_turn()]
|
|
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,
|
|
min_offset=WIRE_MIN_CLEAR):
|
|
"""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. `min_offset` raises
|
|
the floor, and the hug position with it, for a run that must stand further off.
|
|
"""
|
|
floor = max(WIRE_MIN_CLEAR, int(math.ceil(min_offset)))
|
|
hug = max(WIRE_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) < 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)
|
|
# The last turn stands arrive_turn() in front of the target, never closer.
|
|
arrive = (end[0] - max(LINK_STUB, arrive_turn()), 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."""
|
|
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.
|
|
"""
|
|
candidates = channel_candidates(src_box, dst_box, boxes, low, high)
|
|
for cx in candidates:
|
|
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,
|
|
min_offset=arrive_turn())
|
|
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.
|
|
attempt_names = ("straight(1)",)
|
|
attempts = (lambda: straight_through(1),)
|
|
else:
|
|
attempt_names = (
|
|
"straight(1)", "around(hug)", "straight(8)", "around(standoff)",
|
|
"straight(1,stack)", "straight(8,stack)", "around(standoff,stack)",
|
|
)
|
|
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 name, attempt in zip(attempt_names, 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
|
|
|
|
|
|
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
|
|
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 = {}
|
|
for comp_element, comp_tag in app_state.canvas_tag_by_element.items():
|
|
path_to_tag[get_component_path(comp_element, app_state.xml_root_element)] = comp_tag
|
|
|
|
if not path_to_tag:
|
|
return
|
|
|
|
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", "")
|
|
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 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
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
# Sort by horizontal gap distance so shorter, simpler links claim lanes first.
|
|
# Ties are broken by vertical span, keeping the cheapest wires at the front.
|
|
jobs.sort(key=lambda j: (abs(j[3][0] - j[1][2]), abs(j[4] - j[2])))
|
|
|
|
# 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
|
|
)
|
|
unroutable += 1
|
|
continue
|
|
|
|
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(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.
|
|
# 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 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:
|
|
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():
|
|
"""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:
|
|
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 ---
|
|
|
|
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
|
|
|
|
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 WIRE_MIN_CLEAR out from the source box and arrive_turn() in front of
|
|
the target box, and stand WIRE_LANE_GAP apart, so n of them span
|
|
WIRE_MIN_CLEAR + arrive_turn() + (n - 1) * WIRE_LANE_GAP.
|
|
"""
|
|
return max(TIDY_GAP, WIRE_MIN_CLEAR + arrive_turn()
|
|
+ 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. 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:
|
|
# No undo in live mode: each new position goes to the device, and a box the
|
|
# device refused goes back to where the device still has it.
|
|
failures = []
|
|
for element, old_val, _, _ in changes:
|
|
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)
|
|
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)
|