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:
2026-09-12 21:51:22 +03:00
co-authored by Claude Opus 4.6
parent 6cd030c7ea
commit c59cd22153
5 changed files with 222 additions and 18 deletions
+2 -6
View File
@@ -633,12 +633,8 @@ And two things worth knowing about the manifests themselves rather than the code
## Known gaps ## Known gaps
- The yellow square in each box header is drawn but wired to nothing; it is the natural - The yellow square in each box header is a type-icon placeholder that is drawn but not yet
place for a per-box collapse/expand toggle. functional; no icon set has been wired to it.
- Undo covers box moves, adds and deletes. Nothing else pushes a command, so a change made
outside those three is not reversible.
- `on_canvas_release` snaps using the rounded-rectangle polygon's first coordinate,
which is `x1 + radius` rather than `x1`, so snapping is offset by the corner radius.
- Slot values are edited from the Properties view only; a wiresheet box row is still - Slot values are edited from the Properties view only; a wiresheet box row is still
display-only. display-only.
- The Link View stacks each side in one column, so a component with a dozen peers makes a - The Link View stacks each side in one column, so a component with a dozen peers makes a
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os import os
VERSION = "0.0.0.092" VERSION = "0.0.0.093"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
+11
View File
@@ -339,6 +339,17 @@ class DeviceSession:
"to": f"{to_path}.{to_slot_name}"}) "to": f"{to_path}.{to_slot_name}"})
def _slot_name_by_id(self, kit_id, type_id_val, slot_id): def _slot_name_by_id(self, kit_id, type_id_val, slot_id):
# SOX links use absolute slot IDs across the full inheritance chain.
# final_components_registry has correctly resolved absolute IDs;
# the per-type slot_registry only stores local (per-type) IDs
# which are wrong for types with intermediate parents.
type_name = self.sox_client.type_name_map.get((kit_id, type_id_val))
if type_name and type_name in app_state.final_components_registry:
for s in app_state.final_components_registry[type_name]:
if s["slot_id"] == slot_id:
return s["name"]
# Fallback to slot_registry (local IDs — wrong for inherited types,
# but keeps things working when the schema registry is not yet loaded)
slots = self.sox_client.slot_registry.get((kit_id, type_id_val)) slots = self.sox_client.slot_registry.get((kit_id, type_id_val))
if slots: if slots:
for s in slots: for s in slots:
+19 -2
View File
@@ -635,7 +635,7 @@ def on_tree_select(event):
tree.after_idle(_clear_restoring_flag) tree.after_idle(_clear_restoring_flag)
app_state.current_sheet_parent = element app_state.current_sheet_parent = element
except Exception: except Exception:
pass import traceback; traceback.print_exc()
if element is not None: if element is not None:
components_to_render = [child for child in element if child.tag == "comp"] components_to_render = [child for child in element if child.tag == "comp"]
@@ -1351,6 +1351,8 @@ def attach_tree_scrollbars(container, tree):
tree.bind("<Configure>", lambda event: fit_tree_column(tree), add="+") tree.bind("<Configure>", lambda event: fit_tree_column(tree), add="+")
def _on_tree_open(event): def _on_tree_open(event):
tree.after_idle(fit_tree_column, tree) tree.after_idle(fit_tree_column, tree)
if _restoring_selection:
return
if app_state.live_mode: if app_state.live_mode:
opened = tree.focus() opened = tree.focus()
if opened: if opened:
@@ -2091,14 +2093,20 @@ def _live_tree_expand(comp_id, tree_item_id):
ds.build_links_elements(sax_root) ds.build_links_elements(sax_root)
def _ui(): def _ui():
global _restoring_selection
app_state.xml_root_element = sax_root app_state.xml_root_element = sax_root
sax_file.refresh_treeview_from_dom(sax_root) sax_file.refresh_treeview_from_dom(sax_root)
# Re-open and select the node the user clicked # Re-open and re-select the node without triggering a wiresheet
# re-render — the sheet is already showing the right content and
# a redundant close_wiresheet + open_wiresheet cycle can lose links.
for iid, elem in app_state.tree_element_map.items(): for iid, elem in app_state.tree_element_map.items():
if elem.get("id") == str(comp_id): if elem.get("id") == str(comp_id):
app_state.current_sheet_parent = elem
_restoring_selection = True
tree.item(iid, open=True) tree.item(iid, open=True)
tree.selection_set(iid) tree.selection_set(iid)
tree.see(iid) tree.see(iid)
tree.after_idle(_clear_restoring_flag)
break break
app_state.set_status(f"Loaded {len(children)} children") app_state.set_status(f"Loaded {len(children)} children")
@@ -2171,6 +2179,7 @@ SHORTCUTS = [
("KP_0", do_zoom_reset), ("KP_0", do_zoom_reset),
("9", do_zoom_fit), ("9", do_zoom_fit),
("KP_9", do_zoom_fit), ("KP_9", do_zoom_fit),
("p", wiresheet.print_sheet),
] ]
for key, handler in SHORTCUTS: for key, handler in SHORTCUTS:
window.bind_all(f"<Command-{key}>", handler) window.bind_all(f"<Command-{key}>", handler)
@@ -2211,6 +2220,8 @@ menu_file.add_command(label="Open", command=sax_file.open_file)
menu_file.add_command(label="Save", command=do_save, accelerator=f"{accel_mod}+S") menu_file.add_command(label="Save", command=do_save, accelerator=f"{accel_mod}+S")
menu_file.add_command(label="Save As...", command=sax_file.save_file_as) menu_file.add_command(label="Save As...", command=sax_file.save_file_as)
menu_file.add_separator() menu_file.add_separator()
menu_file.add_command(label="Print...", command=wiresheet.print_sheet, accelerator=f"{accel_mod}+P")
menu_file.add_separator()
menu_file.add_command(label="Exit", command=window.quit) menu_file.add_command(label="Exit", command=window.quit)
menu_bar.add_cascade(label="File", menu=menu_file) menu_bar.add_cascade(label="File", menu=menu_file)
@@ -2308,6 +2319,12 @@ _btn_tidy = ttk.Button(toolbar, text="⊞", width=3, command=do_tidy_layout)
_btn_tidy.pack(side=tk.LEFT, padx=(3, 0)) _btn_tidy.pack(side=tk.LEFT, padx=(3, 0))
_ToolTip(_btn_tidy, "Tidy Layout (Ctrl+W)") _ToolTip(_btn_tidy, "Tidy Layout (Ctrl+W)")
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=2)
_btn_print = ttk.Button(toolbar, text="🖨", width=3, command=wiresheet.print_sheet)
_btn_print.pack(side=tk.LEFT)
_ToolTip(_btn_print, "Print Wiresheet (Ctrl+P)")
paned_window = ttk.PanedWindow(window, orient=tk.HORIZONTAL) paned_window = ttk.PanedWindow(window, orient=tk.HORIZONTAL)
paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
+189 -9
View File
@@ -1,6 +1,8 @@
"""Canvas painting: the grid, component boxes, and the routed link wires.""" """Canvas painting: the grid, component boxes, and the routed link wires."""
import math import math
import os
import sys
import tkinter as tk import tkinter as tk
import tkinter.font as tkfont import tkinter.font as tkfont
@@ -9,8 +11,9 @@ import schema
import undo import undo
from sax_dom import get_component_path, read_meta_property, update_meta_property 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 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 # 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 # 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 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_LANE_GAP per extra run, which is why TIDY_GAP below is exactly 2 * WIRE_HUG.
WIRE_HUG = 15 WIRE_HUG = 10
WIRE_MIN_CLEAR = 10 WIRE_MIN_CLEAR = 6
WIRE_LANE_GAP = 10 WIRE_LANE_GAP = 6
# No wire may pass under a component, and that outranks hugging: when the hug band is # 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 # 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 # 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 # 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. # from one output pin still shares the run right beside the pin.
WIRE_ROW_GAP = 5 WIRE_ROW_GAP = 3
LINK_COLOR = "#2f6fba" LINK_COLOR = "#2f6fba"
KNOB_OFFSHEET_COLOR = "#9aa0a6" # a link whose far end is on another wiresheet KNOB_OFFSHEET_COLOR = "#9aa0a6" # a link whose far end is on another wiresheet
KNOB_RADIUS = 4 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. # agree on it without anything having to be recorded in the file.
META_SCALE_MAX = 12 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 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 # 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 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. 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) lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, max_steps)
if lane is None: if lane is None:
continue 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 # 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 # 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. # detouring wire from parking its movable lane on top of one of them.
attempt_names = ("straight(1)",)
attempts = (lambda: straight_through(1),) attempts = (lambda: straight_through(1),)
else: else:
attempt_names = (
"straight(1)", "around(hug)", "straight(8)", "around(standoff)",
"straight(1,stack)", "straight(8,stack)", "around(standoff,stack)",
)
attempts = ( attempts = (
lambda: straight_through(1), # hugging, own lane lambda: straight_through(1), # hugging, own lane
lambda: around_boxes(WIRE_HUG), # hugging, routed around 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: straight_through(8, allow_stack=True),
lambda: around_boxes(WIRE_STANDOFF_MAX, 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() candidate, runs = attempt()
if candidate is not None: if candidate is not None:
if taken is not None: if taken is not None:
@@ -703,6 +712,10 @@ def draw_links():
app_state.canvas_link_map[wire_tag] = link app_state.canvas_link_map[wire_tag] = link
want_knob(knob_x, slot_y, "off_sheet", wire_tag) 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 # 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 # 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 # 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() render_current_sheet()
return len(changes), clamped 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)