Tidy Layout as signal flow; fixed-size wiresheet; signed Sox int writes
- Tidy Layout lays linked boxes out left to right by signal flow, in as few columns as the longest chain allows; feedback loops are cut where they run backwards, and sources sit just before the boxes they feed. - Boxes with no link on the open sheet go to the bottom of the first column, alphabetically. - A box that wires skipping its column would cross is pushed down below them. - The wiresheet is fixed at meta's 255-step reach; outside it is light grey. Dragging keeps a box's top-left corner on the sheet. - Sox write_i4 wraps to signed, so meta with x >= 128 reaches the device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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.173"
|
VERSION = "0.0.0.174"
|
||||||
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
|
||||||
|
|||||||
@@ -933,7 +933,7 @@ def do_tidy_layout(event=None):
|
|||||||
multiplier = app_state.meta_scale // app_state.GRID_SIZE
|
multiplier = app_state.meta_scale // app_state.GRID_SIZE
|
||||||
|
|
||||||
if moved == 0:
|
if moved == 0:
|
||||||
messagebox.showinfo("Tidy Layout", "Nothing to move — no boxes overlap.")
|
messagebox.showinfo("Tidy Layout", "Nothing to move — the sheet is already tidy.")
|
||||||
elif clamped:
|
elif clamped:
|
||||||
messagebox.showwarning(
|
messagebox.showwarning(
|
||||||
"Tidy Layout",
|
"Tidy Layout",
|
||||||
@@ -991,14 +991,8 @@ def zoom_to(level, anchor=None):
|
|||||||
if after == before:
|
if after == before:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Scroll the repainted sheet back under the held point. The scrollregion starts at
|
# Scroll the repainted sheet back under the held point.
|
||||||
# the origin, so its far corner is the span the view fractions are measured against.
|
wiresheet.scroll_view_to(sheet_x * after - anchor[0], sheet_y * after - anchor[1])
|
||||||
region = str(canvas.cget("scrollregion")).split()
|
|
||||||
if len(region) == 4:
|
|
||||||
span_x = float(region[2]) or 1.0
|
|
||||||
span_y = float(region[3]) or 1.0
|
|
||||||
canvas.xview_moveto(max(0.0, (sheet_x * after - anchor[0]) / span_x))
|
|
||||||
canvas.yview_moveto(max(0.0, (sheet_y * after - anchor[1]) / span_y))
|
|
||||||
|
|
||||||
|
|
||||||
def do_zoom_in(event=None):
|
def do_zoom_in(event=None):
|
||||||
@@ -1054,12 +1048,8 @@ def do_zoom_fit(event=None):
|
|||||||
applied = wiresheet.set_zoom(level)
|
applied = wiresheet.set_zoom(level)
|
||||||
update_zoom_label()
|
update_zoom_label()
|
||||||
|
|
||||||
region = str(canvas.cget("scrollregion")).split()
|
wiresheet.scroll_view_to(bounds[0] * applied - wiresheet.FIT_PADDING,
|
||||||
if len(region) == 4:
|
bounds[1] * applied - wiresheet.FIT_PADDING)
|
||||||
span_x = float(region[2]) or 1.0
|
|
||||||
span_y = float(region[3]) or 1.0
|
|
||||||
canvas.xview_moveto(max(0.0, (bounds[0] * applied - wiresheet.FIT_PADDING) / span_x))
|
|
||||||
canvas.yview_moveto(max(0.0, (bounds[1] * applied - wiresheet.FIT_PADDING) / span_y))
|
|
||||||
return "break"
|
return "break"
|
||||||
|
|
||||||
|
|
||||||
@@ -1256,16 +1246,11 @@ def scroll_canvas_to(tag):
|
|||||||
"""Scrolls the sheet so the tagged items sit in the middle of the view."""
|
"""Scrolls the sheet so the tagged items sit in the middle of the view."""
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
box = canvas.bbox(tag)
|
box = canvas.bbox(tag)
|
||||||
region = str(canvas.cget("scrollregion")).split()
|
if box is None:
|
||||||
if box is None or len(region) != 4:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
span_x = float(region[2]) or 1.0
|
wiresheet.scroll_view_to((box[0] + box[2]) / 2 - canvas.winfo_width() / 2,
|
||||||
span_y = float(region[3]) or 1.0
|
(box[1] + box[3]) / 2 - canvas.winfo_height() / 2)
|
||||||
middle_x = (box[0] + box[2]) / 2 - canvas.winfo_width() / 2
|
|
||||||
middle_y = (box[1] + box[3]) / 2 - canvas.winfo_height() / 2
|
|
||||||
canvas.xview_moveto(max(0.0, middle_x / span_x))
|
|
||||||
canvas.yview_moveto(max(0.0, middle_y / span_y))
|
|
||||||
|
|
||||||
|
|
||||||
def select_link_element(link_elem):
|
def select_link_element(link_elem):
|
||||||
@@ -2111,8 +2096,10 @@ def on_canvas_drag(event):
|
|||||||
# out of the margin strip along the top and left canvas edges.
|
# out of the margin strip along the top and left canvas edges.
|
||||||
wanted_x = box_x + (cx - app_state.drag_data["x"])
|
wanted_x = box_x + (cx - app_state.drag_data["x"])
|
||||||
wanted_y = app_state.drag_data["box_y"] + (cy - app_state.drag_data["y"])
|
wanted_y = app_state.drag_data["box_y"] + (cy - app_state.drag_data["y"])
|
||||||
target_x = max(margin, round(wanted_x / step) * step)
|
# The box's corner also stays on the sheet, the furthest meta can store it.
|
||||||
target_y = max(margin, round(wanted_y / step) * step)
|
limit = wiresheet.META_COORD_MAX * step
|
||||||
|
target_x = min(limit, max(margin, round(wanted_x / step) * step))
|
||||||
|
target_y = min(limit, max(margin, round(wanted_y / step) * step))
|
||||||
|
|
||||||
shift_x = target_x - origin[0]
|
shift_x = target_x - origin[0]
|
||||||
shift_y = target_y - origin[1]
|
shift_y = target_y - origin[1]
|
||||||
@@ -2137,8 +2124,9 @@ def on_canvas_release(event):
|
|||||||
current_x, current_y = origin
|
current_x, current_y = origin
|
||||||
|
|
||||||
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
|
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
|
||||||
snapped_x = max(margin, round(current_x / step) * step)
|
limit = wiresheet.META_COORD_MAX * step
|
||||||
snapped_y = max(margin, round(current_y / step) * step)
|
snapped_x = min(limit, max(margin, round(current_x / step) * step))
|
||||||
|
snapped_y = min(limit, max(margin, round(current_y / step) * step))
|
||||||
|
|
||||||
shift_x = snapped_x - current_x
|
shift_x = snapped_x - current_x
|
||||||
shift_y = snapped_y - current_y
|
shift_y = snapped_y - current_y
|
||||||
@@ -3755,11 +3743,11 @@ canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
|
|||||||
|
|
||||||
canvas = tk.Canvas(
|
canvas = tk.Canvas(
|
||||||
frame_editor,
|
frame_editor,
|
||||||
bg="white",
|
bg=wiresheet.OFF_SHEET_COLOUR,
|
||||||
highlightthickness=0,
|
highlightthickness=0,
|
||||||
xscrollcommand=canvas_hscroll.set,
|
xscrollcommand=canvas_hscroll.set,
|
||||||
yscrollcommand=canvas_vscroll.set,
|
yscrollcommand=canvas_vscroll.set,
|
||||||
scrollregion=(0, 0, 2000, 2000),
|
scrollregion=(0, 0, *wiresheet.sheet_extent()),
|
||||||
)
|
)
|
||||||
app_state.canvas = canvas
|
app_state.canvas = canvas
|
||||||
canvas.pack(fill=tk.BOTH, expand=True)
|
canvas.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|||||||
+2
-2
@@ -180,7 +180,7 @@ def reset_to_startup_state():
|
|||||||
|
|
||||||
if app_state.canvas is not None:
|
if app_state.canvas is not None:
|
||||||
app_state.canvas.delete("all")
|
app_state.canvas.delete("all")
|
||||||
wiresheet.draw_grid()
|
wiresheet.fit_sheet_region()
|
||||||
|
|
||||||
if app_state.window is not None:
|
if app_state.window is not None:
|
||||||
app_state.window.title(app_state.base_window_title())
|
app_state.window.title(app_state.base_window_title())
|
||||||
@@ -223,7 +223,7 @@ def open_file():
|
|||||||
for item in tree.get_children():
|
for item in tree.get_children():
|
||||||
tree.delete(item)
|
tree.delete(item)
|
||||||
canvas.delete("all")
|
canvas.delete("all")
|
||||||
wiresheet.draw_grid()
|
wiresheet.fit_sheet_region()
|
||||||
|
|
||||||
xml_tree = ET.parse(file_path)
|
xml_tree = ET.parse(file_path)
|
||||||
app_state.xml_root_element = xml_tree.getroot()
|
app_state.xml_root_element = xml_tree.getroot()
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ class SoxMsg:
|
|||||||
|
|
||||||
def write_i4(self, v):
|
def write_i4(self, v):
|
||||||
self._grow(4)
|
self._grow(4)
|
||||||
struct.pack_into(">i", self._buf, self._pos, v)
|
# Wrapped to signed: meta packs x into the top byte, so x >= 128 reads as an
|
||||||
|
# unsigned 32-bit value although the device stores the same bits as sys::int.
|
||||||
|
struct.pack_into(">i", self._buf, self._pos, ((v + 2**31) % 2**32) - 2**31)
|
||||||
self._pos += 4
|
self._pos += 4
|
||||||
|
|
||||||
def write_i8(self, v):
|
def write_i8(self, v):
|
||||||
|
|||||||
+298
-59
@@ -122,6 +122,8 @@ FIT_PADDING = 20
|
|||||||
# Below this a grid line is closer to its neighbour than the line is wide, which reads
|
# 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.
|
# as a grey wash rather than a grid, so the grid is left off instead.
|
||||||
MIN_GRID_STEP = 3
|
MIN_GRID_STEP = 3
|
||||||
|
SHEET_MARGIN_GRIDS = 3 # grid squares of grey kept past anything that overflows the sheet
|
||||||
|
OFF_SHEET_COLOUR = "#f3f3f3" # canvas outside the area a box's origin can be stored in
|
||||||
|
|
||||||
|
|
||||||
def snap_up(value, grid_size):
|
def snap_up(value, grid_size):
|
||||||
@@ -238,28 +240,40 @@ def measure_box(comp, fonts, linked_slot_index):
|
|||||||
|
|
||||||
|
|
||||||
def draw_grid():
|
def draw_grid():
|
||||||
"""Draws a grid onto the Canvas based on the scrollable region.
|
"""Paints the sheet white and grids it; the canvas around it stays OFF_SHEET_COLOUR.
|
||||||
|
|
||||||
Drawn at the sheet's zoom, so a box still lands on a line at every scale. The area
|
Drawn at the sheet's zoom, so a box still lands on a line at every scale.
|
||||||
covered is the same patch of sheet whatever the zoom, which is why the span is
|
|
||||||
scaled along with the step.
|
|
||||||
"""
|
"""
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
canvas.delete("grid")
|
canvas.delete("grid")
|
||||||
|
|
||||||
|
x1, y1 = 0, 0
|
||||||
|
x2, y2 = (coord * app_state.zoom for coord in sheet_extent())
|
||||||
|
canvas.create_rectangle(x1, y1, x2, y2, fill="white", outline="#c8c8c8", tags="grid")
|
||||||
|
|
||||||
step = app_state.GRID_SIZE * app_state.zoom
|
step = app_state.GRID_SIZE * app_state.zoom
|
||||||
if step < MIN_GRID_STEP:
|
if step >= MIN_GRID_STEP:
|
||||||
return
|
draw_grid_lines(canvas, step, x1, y1, x2, y2)
|
||||||
|
|
||||||
span = 3000 * app_state.zoom
|
|
||||||
for index in range(int(span // step) + 1):
|
|
||||||
offset = index * step
|
|
||||||
canvas.create_line(offset, 0, offset, span, fill="#e0e0e0", tags="grid")
|
|
||||||
canvas.create_line(0, offset, span, offset, fill="#e0e0e0", tags="grid")
|
|
||||||
|
|
||||||
canvas.tag_lower("grid")
|
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):
|
def create_round_rectangle(canvas, x1, y1, x2, y2, radius=4, **kwargs):
|
||||||
"""Draws a visually smooth rounded rectangle using native canvas arcs and polygons."""
|
"""Draws a visually smooth rounded rectangle using native canvas arcs and polygons."""
|
||||||
points = [
|
points = [
|
||||||
@@ -1160,9 +1174,6 @@ def render_components(components_to_render):
|
|||||||
meta_scale = sheet_meta_scale(components_to_render)
|
meta_scale = sheet_meta_scale(components_to_render)
|
||||||
app_state.meta_scale = meta_scale
|
app_state.meta_scale = meta_scale
|
||||||
|
|
||||||
max_reached_x = 1000
|
|
||||||
max_reached_y = 1000
|
|
||||||
|
|
||||||
for comp in components_to_render:
|
for comp in components_to_render:
|
||||||
meta_prop = comp.find("prop[@name='meta']")
|
meta_prop = comp.find("prop[@name='meta']")
|
||||||
meta_str = meta_prop.get("val") if meta_prop is not None else None
|
meta_str = meta_prop.get("val") if meta_prop is not None else None
|
||||||
@@ -1175,22 +1186,17 @@ def render_components(components_to_render):
|
|||||||
app_state.canvas_comp_map[comp_tag] = comp
|
app_state.canvas_comp_map[comp_tag] = comp
|
||||||
app_state.canvas_tag_by_element[comp] = comp_tag
|
app_state.canvas_tag_by_element[comp] = comp_tag
|
||||||
|
|
||||||
box_width, box_height, slot_offsets = draw_component_box(
|
_, _, slot_offsets = draw_component_box(
|
||||||
canvas, comp, coord_x, coord_y, fonts, linked_slot_index,
|
canvas, comp, coord_x, coord_y, fonts, linked_slot_index,
|
||||||
("component", comp_tag), outline_tags=("block_outline",)
|
("component", comp_tag), outline_tags=("block_outline",)
|
||||||
)
|
)
|
||||||
app_state.component_slot_offsets[comp_tag] = slot_offsets
|
app_state.component_slot_offsets[comp_tag] = slot_offsets
|
||||||
|
|
||||||
if coord_x + box_width > max_reached_x:
|
|
||||||
max_reached_x = coord_x + box_width
|
|
||||||
if coord_y + box_height > max_reached_y:
|
|
||||||
max_reached_y = coord_y + box_height
|
|
||||||
|
|
||||||
draw_links()
|
draw_links()
|
||||||
apply_sheet_zoom(fonts, max_reached_x + 200, max_reached_y + 200)
|
apply_sheet_zoom(fonts)
|
||||||
|
|
||||||
|
|
||||||
def apply_sheet_zoom(fonts, extent_x, extent_y):
|
def apply_sheet_zoom(fonts):
|
||||||
"""Scales the finished sheet to app_state.zoom and sizes the canvas to match.
|
"""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
|
Painting and routing both work at 1:1, because that is where every metric here is
|
||||||
@@ -1212,10 +1218,46 @@ def apply_sheet_zoom(fonts, extent_x, extent_y):
|
|||||||
|
|
||||||
scale_knob_ends(zoom)
|
scale_knob_ends(zoom)
|
||||||
|
|
||||||
canvas.config(scrollregion=(0, 0, extent_x * zoom, extent_y * 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()
|
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):
|
def scale_knob_ends(zoom):
|
||||||
"""Brings the cached knob-to-knob ends onto the painted scale.
|
"""Brings the cached knob-to-knob ends onto the painted scale.
|
||||||
|
|
||||||
@@ -1237,6 +1279,7 @@ def redraw_links():
|
|||||||
zoom = app_state.zoom
|
zoom = app_state.zoom
|
||||||
if zoom == 1.0:
|
if zoom == 1.0:
|
||||||
draw_links()
|
draw_links()
|
||||||
|
fit_sheet_region() # a box dragged over the edge brings the grey past it into view
|
||||||
return
|
return
|
||||||
|
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
@@ -1245,6 +1288,7 @@ def redraw_links():
|
|||||||
canvas.scale("component", 0, 0, zoom, zoom)
|
canvas.scale("component", 0, 0, zoom, zoom)
|
||||||
canvas.scale("link", 0, 0, zoom, zoom)
|
canvas.scale("link", 0, 0, zoom, zoom)
|
||||||
scale_knob_ends(zoom)
|
scale_knob_ends(zoom)
|
||||||
|
fit_sheet_region()
|
||||||
|
|
||||||
|
|
||||||
def zoom_step(direction):
|
def zoom_step(direction):
|
||||||
@@ -1307,14 +1351,8 @@ def fit_zoom_level():
|
|||||||
if bounds is None:
|
if bounds is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# A canvas cannot scroll left of or above its origin, so anything out there cannot
|
content_width = bounds[2] - bounds[0]
|
||||||
# be brought into view however far the sheet is zoomed out. The fit is measured from
|
content_height = bounds[3] - bounds[1]
|
||||||
# the origin in that case rather than from the content's own corner.
|
|
||||||
left = max(0.0, bounds[0])
|
|
||||||
top = max(0.0, bounds[1])
|
|
||||||
|
|
||||||
content_width = bounds[2] - left
|
|
||||||
content_height = bounds[3] - top
|
|
||||||
view_width = canvas.winfo_width() - 2 * FIT_PADDING
|
view_width = canvas.winfo_width() - 2 * FIT_PADDING
|
||||||
view_height = canvas.winfo_height() - 2 * FIT_PADDING
|
view_height = canvas.winfo_height() - 2 * FIT_PADDING
|
||||||
|
|
||||||
@@ -1411,59 +1449,225 @@ def column_gap_for(run_count):
|
|||||||
+ max(0, run_count - 1) * clear.lane)
|
+ max(0, run_count - 1) * clear.lane)
|
||||||
|
|
||||||
|
|
||||||
def plan_columns(columns, column_keys, demand, scale):
|
def plan_columns(columns, column_keys, demand, scale, spans=()):
|
||||||
"""Walks the columns at `scale` px per meta step, without touching the DOM.
|
"""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
|
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
|
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.
|
steps, so a coarser scale reaches further across the sheet for the same 0..255 range.
|
||||||
|
|
||||||
|
`spans` are the wires that skip columns, as (src_column, dst_column, dst_item,
|
||||||
|
dst_slot). The router turns such a wire down beside its source, so it crosses every
|
||||||
|
column in between level with its target slot; a box in its way is pushed down below
|
||||||
|
it instead of making the wire detour. Columns are filled right to left for that,
|
||||||
|
since a wire's height is set by its target, which always lies further right.
|
||||||
"""
|
"""
|
||||||
margin = snap_up(TIDY_MARGIN, scale)
|
margin = snap_up(TIDY_MARGIN, scale)
|
||||||
placements = []
|
clear_end = grid_clearance().end
|
||||||
clamped = 0
|
clamped = 0
|
||||||
cursor_x = margin # the left-most column clears the canvas edge by the margin
|
|
||||||
|
|
||||||
|
# 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):
|
for index, grid_x in enumerate(column_keys):
|
||||||
column = sorted(columns[grid_x], key=lambda i: i["grid_y"])
|
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)
|
||||||
|
|
||||||
|
top_of = {} # id(item) -> pixel y its box was given
|
||||||
|
placed = {} # id(item) -> (step_x, step_y)
|
||||||
|
for index in reversed(range(len(column_keys))):
|
||||||
|
rows = sorted(
|
||||||
|
top_of[id(dst_item)] + dst_item["slot_offsets"].get(
|
||||||
|
dst_slot, dst_item["height"] / 2)
|
||||||
|
for src_col, dst_col, dst_item, dst_slot in spans
|
||||||
|
if src_col < index < dst_col
|
||||||
|
)
|
||||||
cursor_y = margin
|
cursor_y = margin
|
||||||
|
|
||||||
for item in column:
|
for item in columns[column_keys[index]]: # already in top-to-bottom order
|
||||||
step_x = min(META_COORD_MAX, round(cursor_x / scale))
|
top = cursor_y
|
||||||
step_y = min(META_COORD_MAX, round(cursor_y / scale))
|
# Step below every passing wire the box would sit across, keeping the
|
||||||
|
# clearance the router wants between a horizontal run and a box edge.
|
||||||
|
moved = True
|
||||||
|
while moved:
|
||||||
|
moved = False
|
||||||
|
for row in rows:
|
||||||
|
if top - clear_end < row < top + item["height"] + clear_end:
|
||||||
|
top = snap_up(row + clear_end, scale)
|
||||||
|
moved = True
|
||||||
|
|
||||||
|
top_of[id(item)] = top
|
||||||
|
step_x = min(META_COORD_MAX, round(column_x[index] / scale))
|
||||||
|
step_y = min(META_COORD_MAX, round(top / scale))
|
||||||
if step_x == META_COORD_MAX or step_y == META_COORD_MAX:
|
if step_x == META_COORD_MAX or step_y == META_COORD_MAX:
|
||||||
clamped += 1
|
clamped += 1
|
||||||
placements.append((item, step_x, step_y))
|
placed[id(item)] = (step_x, step_y)
|
||||||
|
|
||||||
# Clear the box just placed by at least TIDY_GAP, then step to the next
|
# Clear the box just placed by at least TIDY_GAP, then step to the next
|
||||||
# whole meta step so the box still lands on a storable position.
|
# whole meta step so the box still lands on a storable position.
|
||||||
cursor_y = snap_up(cursor_y + item["height"] + TIDY_GAP, scale)
|
cursor_y = snap_up(top + item["height"] + TIDY_GAP, scale)
|
||||||
|
|
||||||
gap = column_gap_for(demand.get(index, 0))
|
|
||||||
cursor_x = snap_up(cursor_x + max(i["width"] for i in column) + gap, scale)
|
|
||||||
|
|
||||||
|
placements = [
|
||||||
|
(item, *placed[id(item)])
|
||||||
|
for grid_x in column_keys
|
||||||
|
for item in columns[grid_x]
|
||||||
|
]
|
||||||
return placements, clamped
|
return placements, clamped
|
||||||
|
|
||||||
|
|
||||||
|
def sheet_link_pairs(components):
|
||||||
|
"""(source, target) for each link between two different components on this sheet."""
|
||||||
|
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 = comp_by_path.get(from_ref.rsplit(".", 1)[0])
|
||||||
|
dst = comp_by_path.get(to_ref.rsplit(".", 1)[0])
|
||||||
|
if src is not None and dst is not None and src is not dst:
|
||||||
|
pairs.append((src, dst))
|
||||||
|
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
|
||||||
|
|
||||||
|
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):
|
def build_layout_items(components):
|
||||||
"""Column-grouped layout items for a sheet, measured rather than read off the canvas.
|
"""Column-grouped layout items for a sheet, measured rather than read off the canvas.
|
||||||
|
|
||||||
Returns (columns, column_keys, demand). Called both while painting -- to settle the
|
Returns (columns, column_keys, demand, spans), each column listed top to bottom.
|
||||||
scale before anything is drawn -- and by tidy, so the two agree.
|
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()
|
fonts = sheet_fonts()
|
||||||
linked_slot_index = build_linked_slot_index()
|
linked_slot_index = build_linked_slot_index()
|
||||||
|
pairs = sheet_link_pairs(components)
|
||||||
|
on_sheet = {comp for pair in pairs for comp in pair}
|
||||||
|
|
||||||
columns = {}
|
linked = []
|
||||||
|
loose = []
|
||||||
for comp in components:
|
for comp in components:
|
||||||
meta_prop = comp.find("prop[@name='meta']")
|
meta_prop = comp.find("prop[@name='meta']")
|
||||||
meta_val = meta_prop.get("val") if meta_prop is not None else None
|
meta_val = meta_prop.get("val") if meta_prop is not None else None
|
||||||
grid_x, grid_y = read_meta_property(meta_val)[:2]
|
grid_x, grid_y = read_meta_property(meta_val)[:2]
|
||||||
width, height = measure_box(comp, fonts, linked_slot_index)[:2]
|
width, height, slot_rows = measure_box(comp, fonts, linked_slot_index)[:3]
|
||||||
columns.setdefault(grid_x, []).append({
|
item = {
|
||||||
"comp": comp, "meta": meta_val,
|
"comp": comp, "meta": meta_val,
|
||||||
"grid_x": grid_x, "grid_y": grid_y,
|
"grid_x": grid_x, "grid_y": grid_y,
|
||||||
"width": width, "height": height,
|
"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", "")))
|
||||||
|
columns.setdefault(0, []).extend(loose)
|
||||||
|
|
||||||
column_keys = sorted(columns)
|
column_keys = sorted(columns)
|
||||||
column_of_comp = {
|
column_of_comp = {
|
||||||
@@ -1471,7 +1675,40 @@ def build_layout_items(components):
|
|||||||
for index, key in enumerate(column_keys)
|
for index, key in enumerate(column_keys)
|
||||||
for item in columns[key]
|
for item in columns[key]
|
||||||
}
|
}
|
||||||
return columns, column_keys, wire_demand_by_boundary(column_of_comp)
|
return (columns, column_keys, wire_demand_by_boundary(column_of_comp),
|
||||||
|
column_skipping_spans(columns, column_of_comp))
|
||||||
|
|
||||||
|
|
||||||
|
def column_skipping_spans(columns, column_of_comp):
|
||||||
|
"""Wires running left to right past at least one column, for plan_columns.
|
||||||
|
|
||||||
|
Each is (src_column, dst_column, dst_item, dst_slot).
|
||||||
|
"""
|
||||||
|
spans = []
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
links_elem = root.find("links") if root is not None else None
|
||||||
|
if links_elem is None:
|
||||||
|
return spans
|
||||||
|
|
||||||
|
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_item = item_by_path.get(from_ref.rsplit(".", 1)[0])
|
||||||
|
dst_path, dst_slot = to_ref.rsplit(".", 1)
|
||||||
|
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 >= 2:
|
||||||
|
spans.append((src_col, dst_col, dst_item, dst_slot))
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
def sheet_meta_scale(components):
|
def sheet_meta_scale(components):
|
||||||
@@ -1485,22 +1722,24 @@ def sheet_meta_scale(components):
|
|||||||
if not components:
|
if not components:
|
||||||
return grid_size
|
return grid_size
|
||||||
|
|
||||||
columns, column_keys, demand = build_layout_items(components)
|
columns, column_keys, demand, spans = build_layout_items(components)
|
||||||
if not column_keys:
|
if not column_keys:
|
||||||
return grid_size
|
return grid_size
|
||||||
|
|
||||||
for multiplier in range(1, META_SCALE_MAX + 1):
|
for multiplier in range(1, META_SCALE_MAX + 1):
|
||||||
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier)
|
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier,
|
||||||
|
spans)
|
||||||
if not clamped:
|
if not clamped:
|
||||||
return grid_size * multiplier
|
return grid_size * multiplier
|
||||||
return grid_size * META_SCALE_MAX
|
return grid_size * META_SCALE_MAX
|
||||||
|
|
||||||
|
|
||||||
def tidy_layout():
|
def tidy_layout():
|
||||||
"""Re-flows the open sheet so no two boxes overlap, preserving the author's columns.
|
"""Re-flows the open sheet as a left-to-right signal flow with no two boxes overlapping.
|
||||||
|
|
||||||
Boxes keep their column grouping and their top-to-bottom order; only the spacing is
|
Columns follow the wiring (see flow_columns), using as few as the longest chain of
|
||||||
rebuilt, from the sizes actually painted. Every placement lands on a whole meta step,
|
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
|
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
|
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
|
too wide for meta's 0..255 range is laid out at a coarser multiple of the grid rather
|
||||||
@@ -1511,12 +1750,12 @@ def tidy_layout():
|
|||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
components = list(app_state.canvas_comp_map.values())
|
components = list(app_state.canvas_comp_map.values())
|
||||||
columns, column_keys, demand = build_layout_items(components)
|
columns, column_keys, demand, spans = build_layout_items(components)
|
||||||
if not column_keys:
|
if not column_keys:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
scale = sheet_meta_scale(components)
|
scale = sheet_meta_scale(components)
|
||||||
placements, clamped = plan_columns(columns, column_keys, demand, scale)
|
placements, clamped = plan_columns(columns, column_keys, demand, scale, spans)
|
||||||
|
|
||||||
changes = [
|
changes = [
|
||||||
(item["comp"], item["meta"], step_x, step_y)
|
(item["comp"], item["meta"], step_x, step_y)
|
||||||
|
|||||||
Reference in New Issue
Block a user