Ctrl+A hands the sheet the keyboard, and repainting the same sheet keeps whatever of the selection is still on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2204 lines
92 KiB
Python
2204 lines
92 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
|
|
SHEET_MARGIN_GRIDS = 3 # grid squares of grey kept past anything that overflows the sheet
|
|
|
|
|
|
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():
|
|
"""Paints the sheet and grids it; the canvas around it is the off-sheet tint.
|
|
|
|
Drawn at the sheet's zoom, so a box still lands on a line at every scale.
|
|
"""
|
|
canvas = app_state.canvas
|
|
canvas.delete("grid")
|
|
|
|
# The sheet starts past the margin strip no box may enter, which stays grey too.
|
|
x1 = y1 = layout_margin(app_state.meta_scale) * app_state.zoom
|
|
x2, y2 = (coord * app_state.zoom for coord in sheet_extent())
|
|
canvas.create_rectangle(x1, y1, x2, y2, outline="#c8c8c8", tags=("grid", "sheet_bg"))
|
|
app_state.paint_sheet_tint() # white or pink by saved state, grey or pink around it
|
|
|
|
step = app_state.GRID_SIZE * app_state.zoom
|
|
if step >= MIN_GRID_STEP:
|
|
draw_grid_lines(canvas, step, x1, y1, x2, y2)
|
|
canvas.tag_lower("grid")
|
|
|
|
|
|
def sheet_extent():
|
|
"""1:1 (width, height) of the sheet: the furthest position meta can store a box at.
|
|
|
|
Only a box's top-left corner has to fit; a box reaching past the edge is drawn over
|
|
the grey, and keeping it inside is left to the author.
|
|
"""
|
|
side = META_COORD_MAX * (app_state.meta_scale or app_state.GRID_SIZE)
|
|
return side, side
|
|
|
|
|
|
def draw_grid_lines(canvas, step, x1, y1, x2, y2):
|
|
for index in range(math.ceil(x1 / step - 1e-6), math.floor(x2 / step + 1e-6) + 1):
|
|
canvas.create_line(index * step, y1, index * step, y2, fill="#e0e0e0", tags="grid")
|
|
for index in range(math.ceil(y1 / step - 1e-6), math.floor(y2 / step + 1e-6) + 1):
|
|
canvas.create_line(x1, index * step, x2, index * step, fill="#e0e0e0", tags="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 keeps `clear` from each box: `end` above and below, `side` left and
|
|
right. Only the two stubs pinned to the slots -- `side` out of the source and
|
|
`arrive` into the target -- are held merely out from under the boxes, since they
|
|
have to reach their pins wherever the rows happen to be. A run that carries on
|
|
level past its stub is still a run, so it may not graze a box edge on the way.
|
|
"""
|
|
points = wire_style.simplify(points)
|
|
padded = [(x1 - clear.side, y1 - clear.end, x2 + clear.side, y2 + clear.end)
|
|
for x1, y1, x2, y2 in boxes]
|
|
last = len(points) - 2
|
|
for idx, ((x1, y1), (x2, y2)) in enumerate(zip(points, points[1:])):
|
|
level = abs(y1 - y2) < LINK_EPS
|
|
lo, hi = x1, x2 # the part of the run that must keep its distance
|
|
if level and idx == 0:
|
|
lo = x1 + math.copysign(min(clear.side, abs(x2 - x1)), x2 - x1)
|
|
if level and idx == last:
|
|
hi = x2 - math.copysign(min(clear.arrive, abs(x2 - lo)), x2 - lo)
|
|
if idx in (0, last) and level:
|
|
if segment_hits_boxes(x1, y1, x2, y2, boxes):
|
|
return False
|
|
if abs(hi - lo) > LINK_EPS and segment_hits_boxes(lo, y1, hi, y2, padded):
|
|
return False
|
|
elif idx in (0, last):
|
|
if segment_hits_boxes(x1, y1, x2, y2, boxes):
|
|
return False
|
|
elif segment_hits_boxes(x1, y1, x2, y2, padded):
|
|
return False
|
|
return True
|
|
|
|
|
|
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, accept=None):
|
|
"""Nudges cx outwards in `clear.lane` steps until the run has a lane of its own.
|
|
|
|
A lane is rejected while it crowds a box (closer than `clear.side`) or while it
|
|
would run alongside an already placed run that overlaps it vertically -- two runs
|
|
passing through the same gap have to stand `clear.lane` apart. Returns None when
|
|
no lane inside the gap works. `max_steps` of 1 permits no nudging at all, which is
|
|
how the caller asks for the exact hug position and nothing else. `accept`, when
|
|
given, can turn down a lane that is otherwise free, and the search carries on.
|
|
"""
|
|
for step in range(0, max_steps):
|
|
for sign in ((0,) if step == 0 else (1, -1)):
|
|
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 lane_is_crowded(lane, run_lo, run_hi, taken, pins, clear.lane):
|
|
continue
|
|
if accept is None or accept(lane):
|
|
return lane
|
|
return None
|
|
|
|
|
|
def crosses_wires(points, ledger, pins):
|
|
"""True if the path would cross a run another wire has already laid down.
|
|
|
|
A crossing gets a hop, so it is allowed, but a lane that avoids one is worth
|
|
looking for first. Wires sharing a pin are one signal and never count.
|
|
"""
|
|
points = wire_style.simplify(points)
|
|
for (x1, y1), (x2, y2) in zip(points, points[1:]):
|
|
if abs(x1 - x2) < LINK_EPS: # vertical: against other wires' rows
|
|
lo, hi = min(y1, y2), max(y1, y2)
|
|
for ry, rx1, rx2, row_pins in ledger["rows"]:
|
|
if (row_pins != pins and not same_signal(pins, row_pins)
|
|
and rx1 + LINK_EPS < x1 < rx2 - LINK_EPS
|
|
and lo + LINK_EPS < ry < hi - LINK_EPS):
|
|
return True
|
|
else: # horizontal: against their lanes
|
|
lo, hi = min(x1, x2), max(x1, x2)
|
|
for cx, c_lo, c_hi, col_pins in ledger["cols"]:
|
|
if (col_pins != pins and not same_signal(pins, col_pins)
|
|
and lo + LINK_EPS < cx < hi - LINK_EPS
|
|
and c_lo + LINK_EPS < y1 < c_hi - LINK_EPS):
|
|
return True
|
|
return False
|
|
|
|
|
|
def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, clear, max_offset=0,
|
|
min_offset=0, pins=None):
|
|
"""Lane for a run pinned to one box edge, starting `clear.side` out from it.
|
|
|
|
`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 path_through(lane):
|
|
return [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
|
|
|
|
def straight_through(max_steps, allow_stack=False, uncrossed=False, hug_only=False):
|
|
"""A single vertical channel in the gap between the two boxes.
|
|
|
|
The two horizontal runs are pinned to their slots, so unless `allow_stack`
|
|
they are refused when they would hide under another wire: the wire is better
|
|
off detouring, which puts it on a lane it can actually move. With `uncrossed`
|
|
only a lane that crosses no other wire will do; with `hug_only` only the lane
|
|
beside its own source is tried (beside either end for a level wire), not ones
|
|
beside the boxes in between.
|
|
"""
|
|
accept = None
|
|
if uncrossed:
|
|
accept = lambda lane: not crosses_wires(path_through(lane), ledger, pins)
|
|
candidates = channel_candidates(src_box, dst_box, boxes, low, high, clear)
|
|
if hug_only:
|
|
# A bent wire hugs its source only: turning at the far end instead would
|
|
# take the lane the wires below it need to turn outside it.
|
|
hugs = (src_box[2] + clear.hug,)
|
|
if abs(src_y - dst_y) < LINK_EPS:
|
|
hugs += (dst_box[0] - clear.arrive,)
|
|
candidates = [cx for cx in candidates if cx in hugs]
|
|
for cx in candidates:
|
|
lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear,
|
|
max_steps, pins, accept)
|
|
if lane is None:
|
|
continue
|
|
candidate = path_through(lane)
|
|
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.
|
|
# Only an uncrossed one, too: a wire that would cross another waits for the
|
|
# second pass, where it can look further for a lane that does not.
|
|
# A wire whose hug lane is taken waits as well, rather than jumping to a lane far
|
|
# across the gap whose long run would then shut out the wires routed after it.
|
|
attempts = (lambda: straight_through(1, uncrossed=True, hug_only=True),)
|
|
else:
|
|
standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side)
|
|
attempts = (
|
|
lambda: straight_through(8, uncrossed=True), # own lane, crossing nothing
|
|
lambda: straight_through(1), # hugging, own lane
|
|
lambda: around_boxes(clear.hug), # hugging, routed around
|
|
lambda: straight_through(8), # lane nudged out of the band
|
|
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]
|
|
|
|
# Links between nearer columns claim lanes first, and straight ones before bent ones.
|
|
# Among bent wires leaving one column, the first routed hugs its source closest, so
|
|
# climbing wires go top source first and falling ones bottom source first: each
|
|
# later wire then turns outside the earlier ones instead of crossing them.
|
|
def routing_order(job):
|
|
bent = abs(job.dst_y - job.src_y) > LINK_EPS
|
|
climbing = job.dst_y < job.src_y
|
|
return (round(job.dst_box[0] - job.src_box[0]), bent, not climbing,
|
|
job.src_y if climbing else -job.src_y)
|
|
jobs.sort(key=routing_order)
|
|
|
|
# What the wires placed so far have claimed, shared across the whole sheet.
|
|
# Routed in two passes: a wire that can run straight has its horizontal runs pinned
|
|
# 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)]}
|
|
# A wire that cannot go direct defers the bent wires after it across the same gap
|
|
# in the same direction too, so they still take their lanes in routing order.
|
|
deferred = set()
|
|
for job in jobs:
|
|
group = (round(job.src_box[0]), round(job.dst_box[0]), job.dst_y < job.src_y)
|
|
if group in deferred and abs(job.dst_y - job.src_y) > LINK_EPS:
|
|
continue
|
|
job.route(boxes, ledger, roomy, direct_only=True)
|
|
if job.points is None:
|
|
deferred.add(group)
|
|
for clear in (roomy, tight_clearance()):
|
|
for job in jobs:
|
|
if job.points is None:
|
|
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
|
|
# Repainting the same sheet (a refresh, an undo, a device update) keeps whatever of
|
|
# the selection is still on it; another sheet starts with nothing selected.
|
|
kept = [app_state.canvas_comp_map.get(t) for t in app_state.selected_canvas_tags
|
|
if t != app_state.selected_canvas_tag]
|
|
kept.append(app_state.canvas_comp_map.get(app_state.selected_canvas_tag))
|
|
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
|
|
|
|
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
|
|
|
|
_, _, 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
|
|
|
|
draw_links()
|
|
apply_sheet_zoom(fonts)
|
|
|
|
on_sheet = set(components_to_render)
|
|
for element in kept:
|
|
if element is not None and element in on_sheet:
|
|
tag = app_state.canvas_tag_by_element[element]
|
|
app_state.selected_canvas_tags.add(tag)
|
|
app_state.selected_canvas_tag = tag
|
|
for tag in app_state.selected_canvas_tags:
|
|
canvas.itemconfig(f"{tag} && block_outline", outline="#1f4e79", width=3)
|
|
|
|
|
|
def apply_sheet_zoom(fonts):
|
|
"""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)
|
|
|
|
fit_sheet_region()
|
|
|
|
|
|
def fit_sheet_region():
|
|
"""Sets the scrollable area: the whole sheet, plus whatever hangs off its edges.
|
|
|
|
The sheet itself never grows (see sheet_extent). A box near the right or bottom
|
|
edge can still reach past it, or a wire loop past the top, so the scrollable area
|
|
takes those in too, with SHEET_MARGIN_GRIDS of grey beyond them.
|
|
"""
|
|
canvas = app_state.canvas
|
|
zoom = app_state.zoom
|
|
margin = SHEET_MARGIN_GRIDS * app_state.GRID_SIZE
|
|
width, height = sheet_extent()
|
|
x1, y1, x2, y2 = 0, 0, width, height
|
|
bounds = sheet_content_bounds()
|
|
if bounds is not None:
|
|
if bounds[0] < 0:
|
|
x1 = bounds[0] - margin
|
|
if bounds[1] < 0:
|
|
y1 = bounds[1] - margin
|
|
if bounds[2] > width:
|
|
x2 = bounds[2] + margin
|
|
if bounds[3] > height:
|
|
y2 = bounds[3] + margin
|
|
canvas.config(scrollregion=(x1 * zoom, y1 * zoom, x2 * zoom, y2 * zoom))
|
|
draw_grid()
|
|
|
|
|
|
def scroll_view_to(x, y):
|
|
"""Scrolls so canvas point (x, y) sits at the view's top-left, as near as the sheet allows."""
|
|
canvas = app_state.canvas
|
|
region = str(canvas.cget("scrollregion")).split()
|
|
if len(region) != 4:
|
|
return
|
|
x1, y1, x2, y2 = (float(v) for v in region)
|
|
canvas.xview_moveto(max(0.0, (x - x1) / ((x2 - x1) or 1.0)))
|
|
canvas.yview_moveto(max(0.0, (y - y1) / ((y2 - y1) or 1.0)))
|
|
|
|
|
|
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()
|
|
fit_sheet_region() # a box dragged over the edge brings the grey past it into view
|
|
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)
|
|
fit_sheet_region()
|
|
|
|
|
|
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
|
|
|
|
content_width = bounds[2] - bounds[0]
|
|
content_height = bounds[3] - bounds[1]
|
|
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)
|
|
|
|
|
|
LAYOUT_SWEEPS = 4 # down-and-up passes plan_columns makes to straighten the wires
|
|
|
|
|
|
def plan_columns(columns, column_keys, demand, scale, wires=()):
|
|
"""Walks the columns at `scale` px per meta step, without touching the DOM.
|
|
|
|
Returns (placements, clamped) where a placement is (item, step_x, step_y). Boxes are
|
|
positioned in pixels exactly as they will be painted, then divided down into meta
|
|
steps, so a coarser scale reaches further across the sheet for the same 0..255 range.
|
|
|
|
Column x positions come from widths and wire lanes alone. Heights are then settled
|
|
to make the wires run as straight as they can: each box is offered the height that
|
|
lines its slot up with the slot at the other end of its wires (the median, when it
|
|
has several), and each column is re-sorted by those heights, which also untangles
|
|
crossings. Boxes keep at least TIDY_GAP apart, but any gap above that is free -- a
|
|
box drops as far as its wires want. Sweeps run left to right, lining boxes up with
|
|
what feeds them, and right to left, lining them up with what they feed.
|
|
|
|
`wires` are (src_column, dst_column, src_item, src_slot, dst_item, dst_slot), left to
|
|
right only. One that skips columns stands in each column it crosses as a placeholder
|
|
box one wire tall, so it is sorted and spaced like the boxes there. The router turns
|
|
such a wire down beside its source, crossing the columns in between level with its
|
|
target slot, so a box still in its way is pushed down below it. The last sweep runs
|
|
right to left so those heights are final when that is checked.
|
|
"""
|
|
margin = snap_up(TIDY_MARGIN, scale)
|
|
clear_end = grid_clearance().end
|
|
count = len(column_keys)
|
|
|
|
# Column x positions depend only on widths and wire lanes, so they come first.
|
|
column_x = []
|
|
cursor_x = margin # the left-most column clears the canvas edge by the margin
|
|
for index, grid_x in enumerate(column_keys):
|
|
column_x.append(cursor_x)
|
|
gap = column_gap_for(demand.get(index, 0))
|
|
cursor_x = snap_up(cursor_x + max(i["width"] for i in columns[grid_x]) + gap,
|
|
scale)
|
|
|
|
order = [list(columns[key]) for key in column_keys]
|
|
|
|
# A wire skipping columns gets a placeholder in each column it crosses, as tall as a
|
|
# wire, so it is sorted and spaced among that column's boxes like one of them. That
|
|
# is what keeps a box from being placed on the wrong side of a wire it then has to
|
|
# cross. The wire is modelled as a chain through its placeholders, so every link of
|
|
# the chain joins neighbouring columns.
|
|
segments = [] # (a, a_slot, b, b_slot), a one column left of b
|
|
for src_col, dst_col, src, s_slot, dst, d_slot in wires:
|
|
previous, previous_slot = src, s_slot
|
|
for index in range(src_col + 1, dst_col):
|
|
placeholder = {"wire": True, "height": 0, "slot_offsets": {}}
|
|
order[index].append(placeholder)
|
|
segments.append((previous, previous_slot, placeholder, None))
|
|
previous, previous_slot = placeholder, None
|
|
segments.append((previous, previous_slot, dst, d_slot))
|
|
|
|
incoming = {id(item): [] for column in order for item in column}
|
|
outgoing = {id(item): [] for column in order for item in column}
|
|
for segment in segments:
|
|
outgoing[id(segment[0])].append(segment)
|
|
incoming[id(segment[2])].append(segment)
|
|
|
|
def offset(item, slot):
|
|
if item.get("wire"):
|
|
return 0
|
|
return item["slot_offsets"].get(slot, item["height"] / 2)
|
|
|
|
def gap(above, below):
|
|
"""Clear space between two neighbours in a column, boxes or wires."""
|
|
if above.get("wire") and below.get("wire"):
|
|
return grid_clearance().row
|
|
if above.get("wire") or below.get("wire"):
|
|
return clear_end
|
|
return TIDY_GAP
|
|
|
|
top = {}
|
|
for column in order: # starting point: packed from the top
|
|
cursor_y = margin
|
|
for item in column:
|
|
top[id(item)] = cursor_y
|
|
cursor_y = snap_up(cursor_y + item["height"] + TIDY_GAP, scale)
|
|
|
|
def wanted_from_left(item):
|
|
return [(top[id(a)] + offset(a, a_slot) - offset(item, b_slot), offset(item, b_slot))
|
|
for a, a_slot, _, b_slot in incoming[id(item)]]
|
|
|
|
def wanted_from_right(item):
|
|
return [(top[id(b)] + offset(b, b_slot) - offset(item, a_slot), offset(item, a_slot))
|
|
for _, a_slot, b, b_slot in outgoing[id(item)]]
|
|
|
|
def place(index, primary, fallback):
|
|
column = order[index]
|
|
wanted = {}
|
|
anchor = {}
|
|
for item in column:
|
|
if item.get("loose"):
|
|
continue
|
|
heights = sorted(primary(item) or fallback(item))
|
|
if heights:
|
|
spot, slot_offset = heights[(len(heights) - 1) // 2]
|
|
else:
|
|
spot, slot_offset = top[id(item)], 0
|
|
wanted[id(item)] = spot
|
|
anchor[id(item)] = spot + slot_offset # the wire height it lines up with
|
|
# Sorted by the wire each box lines up with, so neighbours' wires do not cross;
|
|
# unlinked boxes stay packed below everything else, in their own order.
|
|
column.sort(key=lambda i: (bool(i.get("loose")), anchor.get(id(i), 0)))
|
|
|
|
rows = sorted(
|
|
top[id(dst)] + offset(dst, d_slot)
|
|
for src_col, dst_col, _, _, dst, d_slot in wires
|
|
if src_col < index < dst_col
|
|
)
|
|
floor_y = margin
|
|
above = None
|
|
for item in column:
|
|
if above is not None:
|
|
floor_y = above_bottom + gap(above, item)
|
|
if item.get("loose"):
|
|
spot = snap_up(floor_y, scale)
|
|
elif item.get("wire"):
|
|
spot = max(floor_y, wanted[id(item)]) # never stored, so not snapped
|
|
else:
|
|
spot = max(snap_up(floor_y, scale), round(wanted[id(item)] / scale) * scale)
|
|
if not item.get("wire"):
|
|
# Step below every passing wire the box would still sit across, keeping
|
|
# the clearance the router wants between a horizontal run and a box.
|
|
moved = True
|
|
while moved:
|
|
moved = False
|
|
for row in rows:
|
|
if spot - clear_end < row < spot + item["height"] + clear_end:
|
|
spot = snap_up(row + clear_end, scale)
|
|
moved = True
|
|
top[id(item)] = spot
|
|
above, above_bottom = item, spot + item["height"]
|
|
|
|
def lift():
|
|
"""Shifts the whole sheet up until its top-most box sits on the margin again.
|
|
|
|
Boxes only ever settle downwards, so without this the sweeps would carry the
|
|
whole picture further down the sheet each time round.
|
|
"""
|
|
tops = [top[id(i)] for column in order for i in column
|
|
if not i.get("loose") and not i.get("wire")]
|
|
shift = (min(tops, default=margin) - margin) // scale * scale
|
|
if shift > 0:
|
|
for key in top:
|
|
top[key] -= shift
|
|
|
|
for _ in range(LAYOUT_SWEEPS):
|
|
for index in range(count):
|
|
place(index, wanted_from_left, wanted_from_right)
|
|
lift()
|
|
for index in reversed(range(count)):
|
|
place(index, wanted_from_right, wanted_from_left)
|
|
lift()
|
|
|
|
placements = []
|
|
clamped = 0
|
|
for index, column in enumerate(order):
|
|
for item in column:
|
|
if item.get("wire"):
|
|
continue
|
|
step_x = min(META_COORD_MAX, round(column_x[index] / scale))
|
|
step_y = min(META_COORD_MAX, round(top[id(item)] / scale))
|
|
if step_x == META_COORD_MAX or step_y == META_COORD_MAX:
|
|
clamped += 1
|
|
placements.append((item, step_x, step_y))
|
|
return placements, clamped
|
|
|
|
|
|
def sheet_link_pairs(components):
|
|
"""(source, src_slot, target, dst_slot) for each link between two components here."""
|
|
root = app_state.xml_root_element
|
|
pairs = []
|
|
if root is None:
|
|
return pairs
|
|
links_elem = root.find("links")
|
|
if links_elem is None:
|
|
return pairs
|
|
|
|
comp_by_path = {get_component_path(comp, root): comp for comp in components}
|
|
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_path, src_slot = from_ref.rsplit(".", 1)
|
|
dst_path, dst_slot = to_ref.rsplit(".", 1)
|
|
src = comp_by_path.get(src_path)
|
|
dst = comp_by_path.get(dst_path)
|
|
if src is not None and dst is not None and src is not dst:
|
|
pairs.append((src, src_slot, dst, dst_slot))
|
|
return pairs
|
|
|
|
|
|
def flow_columns(items, pairs):
|
|
"""Lays linked boxes out as a left-to-right signal flow, in as few columns as it can.
|
|
|
|
Returns a list of columns, each listed top to bottom. A box goes one column right of
|
|
the furthest box feeding it, so the column count is the length of the longest chain
|
|
and cannot be smaller. A box nothing feeds is then pulled right to sit just before
|
|
the first box it feeds, which keeps its wires short. Feedback loops are cut where
|
|
they run backwards relative to the boxes' current positions, so a loop keeps the
|
|
direction the author drew it in.
|
|
"""
|
|
def position(item):
|
|
return item["grid_x"], item["grid_y"], item["comp"].get("name", "")
|
|
|
|
item_of = {item["comp"]: item for item in items}
|
|
succs = {id(item): [] for item in items}
|
|
for src, _, dst, _ in pairs:
|
|
if item_of[dst] not in succs[id(item_of[src])]:
|
|
succs[id(item_of[src])].append(item_of[dst])
|
|
for out in succs.values():
|
|
out.sort(key=position)
|
|
|
|
# Depth-first from the left-most boxes; an edge back onto the current path is
|
|
# feedback and is left out of the layering.
|
|
forward = {id(item): [] for item in items}
|
|
state = {} # id -> 1 on the current path, 2 finished
|
|
for start in sorted(items, key=position):
|
|
if id(start) in state:
|
|
continue
|
|
state[id(start)] = 1
|
|
stack = [(start, iter(succs[id(start)]))]
|
|
while stack:
|
|
node, pending = stack[-1]
|
|
nxt = next(pending, None)
|
|
if nxt is None:
|
|
state[id(node)] = 2
|
|
stack.pop()
|
|
elif state.get(id(nxt)) == 1:
|
|
continue # feedback
|
|
else:
|
|
forward[id(node)].append(nxt)
|
|
if id(nxt) not in state:
|
|
state[id(nxt)] = 1
|
|
stack.append((nxt, iter(succs[id(nxt)])))
|
|
|
|
preds = {id(item): [] for item in items}
|
|
for item in items:
|
|
for nxt in forward[id(item)]:
|
|
preds[id(nxt)].append(item)
|
|
|
|
# Longest path from the sources, in topological order.
|
|
layer = {}
|
|
indegree = {id(item): len(preds[id(item)]) for item in items}
|
|
ready = sorted((i for i in items if not indegree[id(i)]), key=position)
|
|
order = []
|
|
while ready:
|
|
node = ready.pop(0)
|
|
order.append(node)
|
|
layer[id(node)] = max((layer[id(p)] + 1 for p in preds[id(node)]), default=0)
|
|
for nxt in forward[id(node)]:
|
|
indegree[id(nxt)] -= 1
|
|
if not indegree[id(nxt)]:
|
|
ready.append(nxt)
|
|
for node in reversed(order):
|
|
if not preds[id(node)] and forward[id(node)]:
|
|
layer[id(node)] = min(layer[id(n)] for n in forward[id(node)]) - 1
|
|
|
|
# Into any one box, wires from further left must arrive at higher slots than wires
|
|
# from nearer columns. A long wire crosses the columns in between level with its
|
|
# slot, so a nearer box feeding a slot above it would have to cross it on the way.
|
|
# Only boxes nothing feeds are free to move, so they are held back far enough left.
|
|
feeders = {} # id(target) -> [(slot height, source)]
|
|
for src, _, dst, d_slot in pairs:
|
|
src_item, dst_item = item_of[src], item_of[dst]
|
|
if dst_item in forward[id(src_item)]:
|
|
feeders.setdefault(id(dst_item), []).append(
|
|
(dst_item["slot_offsets"].get(d_slot, 0), src_item))
|
|
sources = [n for n in order if not preds[id(n)] and forward[id(n)]]
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
for node in sources:
|
|
limit = layer[id(node)]
|
|
for target_id, fed in feeders.items():
|
|
own = [height for height, src in fed if src is node]
|
|
for height, other in fed:
|
|
if other is not node and own and height > min(own):
|
|
limit = min(limit, layer[id(other)])
|
|
limit = max(0, limit)
|
|
if limit < layer[id(node)]:
|
|
layer[id(node)] = limit
|
|
changed = True
|
|
|
|
columns = [[] for _ in range(max(layer.values(), default=-1) + 1)]
|
|
for item in sorted(items, key=lambda i: (i["grid_y"], i["grid_x"])):
|
|
columns[layer[id(item)]].append(item)
|
|
|
|
# Order each column after the boxes feeding it, so wires run as level as they can;
|
|
# then once back right to left, settling the boxes nothing feeds beside the boxes
|
|
# they feed. Ties keep the author's top-to-bottom order.
|
|
row = {}
|
|
|
|
def settle(column, neighbours):
|
|
def key(item):
|
|
near = [row[id(n)] for n in neighbours(item) if id(n) in row]
|
|
return sum(near) / len(near) if near else row.get(id(item), float("inf"))
|
|
column.sort(key=key)
|
|
for index, item in enumerate(column):
|
|
row[id(item)] = index
|
|
|
|
for column in columns:
|
|
settle(column, lambda i: preds[id(i)])
|
|
for column in reversed(columns):
|
|
settle(column, lambda i: forward[id(i)] if not preds[id(i)] else ())
|
|
return columns
|
|
|
|
|
|
def build_layout_items(components):
|
|
"""Column-grouped layout items for a sheet, measured rather than read off the canvas.
|
|
|
|
Returns (columns, column_keys, demand, wires), each column listed top to bottom.
|
|
Linked boxes are arranged by flow_columns. A box with no link to another box on this
|
|
sheet -- unlinked, or linked only off-sheet -- goes to the bottom of the first
|
|
column, alphabetically by name.
|
|
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()
|
|
pairs = sheet_link_pairs(components)
|
|
on_sheet = {comp for src, _, dst, _ in pairs for comp in (src, dst)}
|
|
|
|
linked = []
|
|
loose = []
|
|
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, slot_rows = measure_box(comp, fonts, linked_slot_index)[:3]
|
|
item = {
|
|
"comp": comp, "meta": meta_val,
|
|
"grid_x": grid_x, "grid_y": grid_y,
|
|
"width": width, "height": height,
|
|
# Where each slot's wire meets the box, as painted by draw_component_box.
|
|
"slot_offsets": {
|
|
raw_name: HEADER_HEIGHT + row * SLOT_ROW_HEIGHT + SLOT_ROW_HEIGHT // 2
|
|
for row, (raw_name, _, _) in enumerate(slot_rows)
|
|
},
|
|
}
|
|
(linked if comp in on_sheet else loose).append(item)
|
|
|
|
columns = dict(enumerate(flow_columns(linked, pairs)))
|
|
if loose:
|
|
loose.sort(key=lambda i: (i["comp"].get("name", "").casefold(),
|
|
i["comp"].get("name", "")))
|
|
for item in loose:
|
|
item["loose"] = True # plan_columns keeps these packed at the bottom
|
|
columns.setdefault(0, []).extend(loose)
|
|
|
|
column_keys = sorted(columns)
|
|
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),
|
|
forward_wires(columns, column_of_comp))
|
|
|
|
|
|
def forward_wires(columns, column_of_comp):
|
|
"""Every wire running left to right between two columns, for plan_columns.
|
|
|
|
Each is (src_column, dst_column, src_item, src_slot, dst_item, dst_slot). Feedback
|
|
wires, running right to left, are left out: they go over or under the boxes anyway.
|
|
"""
|
|
wires = []
|
|
root = app_state.xml_root_element
|
|
links_elem = root.find("links") if root is not None else None
|
|
if links_elem is None:
|
|
return wires
|
|
|
|
item_by_path = {
|
|
get_component_path(item["comp"], root): item
|
|
for column in columns.values()
|
|
for item in column
|
|
}
|
|
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_path, src_slot = from_ref.rsplit(".", 1)
|
|
dst_path, dst_slot = to_ref.rsplit(".", 1)
|
|
src_item = item_by_path.get(src_path)
|
|
dst_item = item_by_path.get(dst_path)
|
|
if src_item is None or dst_item is None:
|
|
continue
|
|
src_col = column_of_comp[src_item["comp"]]
|
|
dst_col = column_of_comp[dst_item["comp"]]
|
|
if dst_col > src_col:
|
|
wires.append((src_col, dst_col, src_item, src_slot, dst_item, dst_slot))
|
|
return wires
|
|
|
|
|
|
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, wires = build_layout_items(components)
|
|
if not column_keys:
|
|
return grid_size
|
|
|
|
for multiplier in range(1, META_SCALE_MAX + 1):
|
|
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier,
|
|
wires)
|
|
if not clamped:
|
|
return grid_size * multiplier
|
|
return grid_size * META_SCALE_MAX
|
|
|
|
|
|
def tidy_layout():
|
|
"""Re-flows the open sheet as a left-to-right signal flow with no two boxes overlapping.
|
|
|
|
Columns follow the wiring (see flow_columns), using as few as the longest chain of
|
|
links allows; spacing is rebuilt from the sizes actually painted. Boxes with no link to another box on the
|
|
sheet are gathered at the bottom of the first column, A to Z. 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, wires = build_layout_items(components)
|
|
if not column_keys:
|
|
return 0, 0
|
|
|
|
scale = sheet_meta_scale(components)
|
|
placements, clamped = plan_columns(columns, column_keys, demand, scale, wires)
|
|
|
|
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)
|