Fix live-device slot ID resolution, add print and tighten wire routing
_slot_name_by_id() now resolves SOX link slot IDs against the inheritance-flattened final_components_registry instead of the per-type slot_registry whose local IDs were wrong for types with intermediate parents (e.g. RegFloat→Register→Component). This produced incorrect link endpoints, inflated linked-slot visibility, and different box sizes between file and live modes. Also: tighten routing constants for denser wiring, add wiresheet PNG print/export (Ctrl+P), sort link routing jobs by gap distance, remove debug route logging, and fix live tree-expand re-selection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+189
-9
@@ -1,6 +1,8 @@
|
||||
"""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
|
||||
|
||||
@@ -9,8 +11,9 @@ import schema
|
||||
import undo
|
||||
from sax_dom import get_component_path, read_meta_property, update_meta_property
|
||||
|
||||
LINK_STUB = 12 # straight run leaving a slot before the wire is allowed to turn
|
||||
LINK_CLEAR = 8 # gap kept between a horizontal wire lane and any box above or below
|
||||
|
||||
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
|
||||
@@ -18,9 +21,9 @@ LINK_EPS = 0.5 # tolerance so a wire touching its own box edge is not a col
|
||||
# the closest it is ever allowed to get, and two runs sharing one gap stand
|
||||
# WIRE_LANE_GAP apart. A gap therefore has to be WIRE_HUG either side plus one
|
||||
# WIRE_LANE_GAP per extra run, which is why TIDY_GAP below is exactly 2 * WIRE_HUG.
|
||||
WIRE_HUG = 15
|
||||
WIRE_MIN_CLEAR = 10
|
||||
WIRE_LANE_GAP = 10
|
||||
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
|
||||
@@ -32,7 +35,7 @@ WIRE_STANDOFF_MAX = 60
|
||||
# a free horizontal lane is nudged until it clears any other wire's by this much. The
|
||||
# short stubs joining a slot cannot move -- they have to meet their pin -- so a fan-out
|
||||
# from one output pin still shares the run right beside the pin.
|
||||
WIRE_ROW_GAP = 5
|
||||
WIRE_ROW_GAP = 3
|
||||
LINK_COLOR = "#2f6fba"
|
||||
KNOB_OFFSHEET_COLOR = "#9aa0a6" # a link whose far end is on another wiresheet
|
||||
KNOB_RADIUS = 4
|
||||
@@ -55,7 +58,7 @@ META_COORD_MAX = 255 # meta packs x and y into one byte each
|
||||
# agree on it without anything having to be recorded in the file.
|
||||
META_SCALE_MAX = 12
|
||||
|
||||
TIDY_GAP = 30 # minimum clear space kept between two boxes
|
||||
TIDY_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
|
||||
@@ -455,7 +458,8 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None,
|
||||
they are refused when they would hide under another wire: the wire is better
|
||||
off detouring, which puts it on a lane it can actually move.
|
||||
"""
|
||||
for cx in channel_candidates(src_box, dst_box, boxes, low, high):
|
||||
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
|
||||
@@ -541,8 +545,13 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None,
|
||||
# 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
|
||||
@@ -555,7 +564,7 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None,
|
||||
lambda: straight_through(8, allow_stack=True),
|
||||
lambda: around_boxes(WIRE_STANDOFF_MAX, allow_stack=True),
|
||||
)
|
||||
for attempt in attempts:
|
||||
for name, attempt in zip(attempt_names, attempts):
|
||||
candidate, runs = attempt()
|
||||
if candidate is not None:
|
||||
if taken is not None:
|
||||
@@ -703,6 +712,10 @@ def draw_links():
|
||||
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
|
||||
@@ -1319,3 +1332,170 @@ def tidy_layout():
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user