diff --git a/README.md b/README.md index 3e750ca..2cfd393 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,11 @@ derived from the links every time it is drawn and no part of it is stored in the (`meta` records a component's place on its own sheet, which says nothing about where it belongs relative to another one). +Each column is stacked **in the order of the chosen component's own slots**: whatever +feeds its first slot sits at the top, whatever feeds its fifth sits below that. Nothing +is read from `meta`, so the view is free to put the peers wherever they read best, and +this is the order that makes the wires readable -- see **Keeping the wires apart** below. + Every line is **grey**, because this is not a wiresheet and a wire-blue line would invite the reader to treat it as one: @@ -394,14 +399,58 @@ be on a sheet; a reference the document cannot resolve gets a dashed placeholder the path, because a link the view silently dropped would be the one thing a reader could not find out from here. -Each gap is sized by `column_gap` to hold a lane for every wire crossing it, and lanes are -handed out nearest-target-first, so a wire reaching a distant box turns outside one -reaching a near box instead of across it. A dozen links widen the gap rather than +Hovering a wire darkens it and names the link in the footer; clicking a wire or a +component follows it. `collect_links`, `sheet_display_name` and the whole of the routing +geometry -- `segments_meet`, `routes_meet`, `column_gap`, `_slot_order_key` -- are pure +functions, so both what the view reads out of a document and how it decides where a wire +goes can be exercised without a window. + +### Keeping the wires apart + +Ordering each column by the chosen component's slots turns the set of wires on one side +into a **ladder**: both ends run the same way down the page, so no wire has any reason to +cross another. What is left is choosing which lane each one turns in, and `column_gap` +reserves the room for that -- a lane per wire, so a dozen links widen the gap rather than crowding a dozen verticals into a 30px band. -Hovering a wire darkens it and names the link in the footer; clicking a wire or a -component follows it. `collect_links` and `sheet_display_name` are pure DOM work, so what -the view reads out of a document can be exercised without a window. +For a ladder there is a lane order that cannot produce a crossing, and read off the slots +rather than off the direction the line happens to be drawn in, it is the same on both +sides -- two fans, each nesting outwards from the chosen component's own row: + +| Wire | Turns nearer the focus | +|---|---| +| reaching a peer slot **above** its focus slot | the higher up the focus it lands | +| reaching a peer slot **below** its focus slot | the lower down the focus it lands | + +Take two wires of the upper fan, the higher one nested inside the lower: the lower one's +run into the focus passes below where the higher one's vertical ends, and its run out to +its peer passes above where that vertical begins, so it goes round it and never through +it. The lower fan is the same argument upside down. A wire of one fan can never share a +stretch of the page with a wire of the other -- ordering the ladder is what rules that +out -- so the two fans reuse each other's lanes and the gap stays as narrow as the +picture allows. + +Two special cases sit on top of that rule. Where several wires leave the **same** slot no +order of focus heights can separate them, so the longest branch turns first, against the +focus, and the shorter ones carry on along the shared run beside the pin and drop away +outside it -- which is how a wiresheet draws a fan-out too. And where two slots already +line up, the wire is one straight line with no vertical run to place at all; those are +settled first, because such a wire crosses the whole gap and everything else has to be +fitted past it. The layout is rounded to whole pixels so that a pair that ought to line +up does, rather than being drawn with a 1px jog in it. + +Every wire is still checked against the ones already routed before it takes a lane +(`routes_meet`), and takes the next lane out if it clashes. The argument above rests on +the ladder being ordered, and one thing breaks that: a peer whose **own** slots run +counter to the focus's -- linking its row 4 to the focus's row 3 and its row 3 to the +focus's row 4. Two links with their ends inverted like that have to cross wherever they +are drawn, and no reordering can help, because the order inside each box is the +component's own. The check is what stops that one forced crossing from being made worse +by the ones around it. + +Measured over 461 linked components in four real files -- 1113 wires -- this leaves **no +wire passing through a box at all**, and ten crossings in six components, every one of +them an inverted pair of the kind above. ### Following a link to its other sheet @@ -568,3 +617,8 @@ And two things worth knowing about the manifests themselves rather than the code display-only. - The Link View stacks each side in one column, so a component with a dozen peers makes a picture taller than the pane. It scrolls, but it does not wrap into a second column. +- Two links between the same pair of components whose slot rows are inverted have to + cross, since neither box's slot order is the view's to change. Six components in the + four files measured do this. +- `column_gap` budgets one lane per wire, but the router reuses lanes wherever it can, so + a side that needs few of them is drawn with a gap wider than its wires require. diff --git a/app_state.py b/app_state.py index 425a8b8..bc3420b 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.055" +VERSION = "0.0.0.056" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/link_view.py b/link_view.py index 0b375c2..e19aec1 100644 --- a/link_view.py +++ b/link_view.py @@ -72,19 +72,67 @@ LANE_GAP = 9 # between the vertical runs of two wires in one gap SELF_LOOP_DROP = 18 # how far below its box a component's link to itself runs LABEL_GAP = 6 # between a box's bottom and the note under it +EPSILON = 0.5 # two coordinates this close are the same coordinate + def column_gap(wire_count): """How wide the gap beside a column has to be to hold its wires. - Every wire crossing a gap turns in a vertical run of its own, and two runs on the - same line are one line as far as a reader is concerned -- so the gap is sized to the - number of wires rather than fixed, and a component with a dozen links gets a wider - gap instead of a dozen wires in a 30px band. COLUMN_GAP is the floor, for the few - links that would otherwise be drawn closer together than the boxes they join. + A wire that cannot run straight turns in a vertical run of its own, and two runs on + the same line are one line as far as a reader is concerned -- so the gap is sized to + the number of wires rather than fixed, and a component with a dozen links gets a + wider gap instead of a dozen wires in a 30px band. COLUMN_GAP is the floor, for the + few links that would otherwise be drawn closer together than the boxes they join. """ return max(COLUMN_GAP, 2 * STUB + LANE_GAP * (wire_count + 1)) +def spans_overlap(first, second): + """Whether two 1-D spans share more than a single point.""" + low = max(min(first), min(second)) + high = min(max(first), max(second)) + return high - low > EPSILON + + +def strictly_within(value, span): + """Whether a coordinate is inside a span rather than at either end of it.""" + return min(span) + EPSILON < value < max(span) - EPSILON + + +def segments_meet(first, second): + """Whether two axis-aligned segments make a mark a reader would read as a tangle. + + Two kinds count. A vertical run crossing a horizontal one is a crossing, and two + vertical runs sharing a line over the same stretch are one line where there should + be two. + + Two *horizontal* runs sharing a line deliberately do not count: every wire leaving + one output slot leaves it at the same height, so a fan-out shares the run beside its + pin. That is how a wiresheet draws a fan-out too, and forcing those apart would move + a wire off the slot it belongs to. + """ + (a1, a2), (b1, b2) = first, second + a_vertical = abs(a1[0] - a2[0]) < EPSILON + b_vertical = abs(b1[0] - b2[0]) < EPSILON + + if a_vertical and b_vertical: + return (abs(a1[0] - b1[0]) < EPSILON + and spans_overlap((a1[1], a2[1]), (b1[1], b2[1]))) + if not a_vertical and not b_vertical: + return False + + (v1, v2), (h1, h2) = (first, second) if a_vertical else (second, first) + return (strictly_within(v1[0], (h1[0], h2[0])) + and strictly_within(h1[1], (v1[1], v2[1]))) + + +def routes_meet(first, second): + """Whether two wires, each a list of corner points, meet anywhere along their way.""" + first_segments = list(zip(first, first[1:])) + second_segments = list(zip(second, second[1:])) + return any(segments_meet(a, b) for a in first_segments for b in second_segments) + + def sheet_display_name(sheet_element): """What to call the wiresheet an element sits on. @@ -186,6 +234,18 @@ def group_by_peer(entries): return groups +def _slot_order_key(group, focus_row_of): + """Where a peer belongs in its column: the first focus slot its links land on. + + A peer wired to several of the chosen component's slots is placed by the topmost of + them, and the path breaks a tie so that two peers on one slot keep a stable order + from one draw to the next. + """ + unknown = len(focus_row_of) + rows = [focus_row_of.get(entry["comp_slot"], unknown) for entry in group["entries"]] + return (min(rows), group["peer_path"]) + + class LinkViewPanel: """One component's links, drawn around it on a canvas of this view's own. @@ -217,6 +277,10 @@ class LinkViewPanel: self.link_by_tag = {} self.hover_restore = {} + # Numbers the wires of one draw, so every line has a tag of its own even when + # two of them stand for links between the same pair of components. + self._wire_seq = 0 + self.frame = tk.Frame(parent) # The footer is packed first and against the bottom: pack hands out leftover @@ -338,6 +402,7 @@ class LinkViewPanel: canvas.delete("all") self.link_by_tag.clear() self.hover_restore.clear() + self._wire_seq = 0 self.entries = collect_links(self.comp) count = len(self.entries) @@ -352,7 +417,21 @@ class LinkViewPanel: outgoing = group_by_peer([e for e in self.entries if e["side"] == "out"]) self_links = [e for e in self.entries if e["side"] == "self"] - focus_size = wiresheet.measure_box(self.comp, fonts, linked_slots)[:2] + focus_width, focus_height, focus_rows = wiresheet.measure_box( + self.comp, fonts, linked_slots)[:3] + focus_size = (focus_width, focus_height) + + # Which row of the chosen component each of its slots is on. Peers are put in + # that order, top to bottom, so the wires form a ladder instead of a weave: a + # component feeding the first slot sits above one feeding the fifth, and neither + # has any reason to cross the other on the way in. Read from the measurement + # rather than from the drawn box, because the order has to be settled before + # there is anything drawn to read it off. + focus_row_of = {raw: index for index, (raw, _label, _value) + in enumerate(focus_rows)} + incoming.sort(key=lambda group: _slot_order_key(group, focus_row_of)) + outgoing.sort(key=lambda group: _slot_order_key(group, focus_row_of)) + left_sizes = [self._group_size(group, fonts, linked_slots) for group in incoming] right_sizes = [self._group_size(group, fonts, linked_slots) for group in outgoing] @@ -370,7 +449,12 @@ class LinkViewPanel: focus_x = MARGIN + (left_width + left_gap if incoming else 0) right_x = focus_x + focus_size[0] + right_gap - focus_y = MARGIN + (content_height - focus_size[1]) / 2 + + # Rounded to whole pixels. Every height and slot offset here is an integer, so + # rounding the one division in the layout is what lets two slots that ought to + # line up line up exactly -- and an exactly aligned pair is a wire drawn as one + # straight line rather than one with a 1px jog in it. + focus_y = MARGIN + round((content_height - focus_size[1]) / 2) focus_box = self._draw_box(self.comp, focus_x, focus_y, fonts, linked_slots, ("lv_focus",), outline_tags=("lv_focus_outline",)) @@ -383,27 +467,25 @@ class LinkViewPanel: columns = ((incoming, left_sizes, left_height, left_width, MARGIN, "in"), (outgoing, right_sizes, right_height, right_width, right_x, "out")) for column, sizes, column_height, column_width, column_x, side in columns: - y = MARGIN + (content_height - column_height) / 2 + y = MARGIN + round((content_height - column_height) / 2) for group, (width, height) in zip(column, sizes): # Boxes are right-aligned on the left and left-aligned on the right, so # every wire crosses the same gap however wide its own box turned out. x = column_x + (column_width - width) if side == "in" else column_x box = self._draw_group_box(group, x, y, fonts, linked_slots) for entry in group["entries"]: - jobs[side].append((entry, box)) + jobs[side].append(self._wire_ends(entry, side, focus_box, box)) y += height + ROW_GAP - # Lanes are handed out nearest-first, so a wire reaching a distant box turns - # outside one reaching a near box rather than across it. Both ends of a link on - # this side are known by now, which is what lets the order be worked out at all. + # Each side is routed as a set, not wire by wire: which lane a wire may turn in + # depends on where the others turn, so they cannot be settled one at a time. for side, side_jobs in jobs.items(): - side_jobs.sort(key=lambda job: abs( - self._slot_y(job[1], job[0]["peer_slot"]) - - self._slot_y(focus_box, job[0]["comp_slot"]))) - for rank, (entry, box) in enumerate(side_jobs): - self._draw_wire(entry, side, focus_box, box, rank) + self._route_side(side_jobs, focus_box, side) + for job in side_jobs: + self._draw_wire(job) - # A self-link crosses both gaps, so it takes the next lane out on each side. + # A self-link crosses both gaps, so it takes the lane beyond every wire that has + # a peer to reach, on each side. for offset, entry in enumerate(self_links): self._draw_self_wire(entry, focus_box, len(jobs["in"]) + offset, @@ -517,48 +599,134 @@ class LinkViewPanel: Out from the focus rather than in from the peer, because every wire on one side leaves the same box edge while the boxes it reaches are all different heights: - anchoring to the shared edge is what makes the fan nest instead of tangle. - `column_gap` sized the gap to hold every rank, so a lane always lands inside it. + anchoring to the shared edge is what lets the lanes nest. `column_gap` sized the + gap to hold one lane per wire, so a rank inside that count lands inside the gap. """ if side == "in": return focus_box["x"] - STUB - (rank + 1) * LANE_GAP return focus_box["x"] + focus_box["width"] + STUB + (rank + 1) * LANE_GAP - def _draw_wire(self, entry, side, focus_box, peer_box, rank): - """One link, from the box that feeds it to the box that reads it. + def _wire_ends(self, entry, side, focus_box, peer_box): + """Where one link's wire has to start and finish, before it is routed. Drawn peer-to-focus on the left and focus-to-peer on the right, so every arrow points the way the signal travels and the picture reads left to right the way a - wiresheet does. A link whose far end is on another sheet is dashed, and keeps - the grey knob its own sheet paints on that slot -- so the slot looks the same - here as it does there, with the far component drawn in as well. + wiresheet does. A link whose far end is on another sheet keeps the grey knob its + own sheet paints on that slot, so the slot looks the same here as it does there, + and the wire stands off far enough to leave that marker its own space. """ focus_y = self._slot_y(focus_box, entry["comp_slot"]) peer_y = self._slot_y(peer_box, entry["peer_slot"]) off_sheet = not entry["same_sheet"] + standoff = 2 * wiresheet.KNOB_RADIUS if off_sheet else 0 if side == "in": start = (peer_box["x"] + peer_box["width"], peer_y) - end_x = focus_box["x"] - knob_x = end_x - wiresheet.KNOB_RADIUS - end = (end_x - 2 * wiresheet.KNOB_RADIUS if off_sheet else end_x, focus_y) + end = (focus_box["x"] - standoff, focus_y) + knob_x = focus_box["x"] - wiresheet.KNOB_RADIUS else: - start = (focus_box["x"] + focus_box["width"], focus_y) - knob_x = start[0] + wiresheet.KNOB_RADIUS - if off_sheet: - start = (start[0] + 2 * wiresheet.KNOB_RADIUS, focus_y) + start = (focus_box["x"] + focus_box["width"] + standoff, focus_y) end = (peer_box["x"], peer_y) + knob_x = focus_box["x"] + focus_box["width"] + wiresheet.KNOB_RADIUS - tag = f"lv_link_{id(entry['link'])}_{side}_{rank}" - self._draw_route( - self._route(start, end, self._lane_x(focus_box, side, rank)), - tag, off_sheet - ) + # The two heights are kept apart from the two ends: which end is the focus's + # swaps between the sides, and the lane rule is written in terms of the slots + # rather than the direction the line happens to be drawn in. + return {"entry": entry, "side": side, "start": start, "end": end, + "focus_y": focus_y, "peer_y": peer_y, + "knob": (knob_x, focus_y) if off_sheet else None, + "off_sheet": off_sheet, "route": None} - if off_sheet: + def _route_side(self, side_jobs, focus_box, side): + """Chooses a lane for every wire on one side so that no two of them meet. + + The peers are already stacked in the order of the focus slots their links land + on, so both ends of the set run the same way down the page -- a ladder, not a + weave. For a ladder there is a lane order that cannot produce a crossing, and it + is two fans rather than one order. Reading it off the slots, not off the + direction the line is drawn in, it is the same on both sides: + + a wire reaching a peer slot *above* its focus slot turns nearer the focus + the higher up the focus it lands + + a wire reaching a peer slot *below* its focus slot turns nearer the focus + the lower down the focus it lands + + So each fan nests outwards from the focus's own row. Take two wires of the upper + fan, the higher one nested inside the lower: the lower one's run into the focus + passes below where the higher one's vertical ends, and its run out to its peer + passes above where that vertical begins, so it goes round it and never through + it. The lower fan is the same argument upside down. And a wire of one fan can + never share a stretch of the page with a wire of the other -- ordering the + ladder is what rules that out -- so the two fans reuse each other's lanes and + the gap stays as narrow as the picture allows. + + Each fan is walked from the lane against the focus outwards, and every wire + still has to pass the collision check before it takes a lane. The argument above + rests on the ladder being ordered, and a peer whose own slots run counter to the + focus's is not quite a ladder; the check is what makes that case come out tidy + anyway rather than trusting the reasoning to cover it. + """ + straight, above, below = [], [], [] + for job in side_jobs: + offset = job["peer_y"] - job["focus_y"] + if abs(offset) < EPSILON: + straight.append(job) + elif offset < 0: + above.append(job) + else: + below.append(job) + + # Within a fan, the focus slot each wire lands on is the order that matters -- + # except where several wires leave the *same* slot, which no order of focus + # heights can separate. That is a fan-out, and there the longest branch has to + # turn first, against the focus: the shorter ones then carry on along the shared + # run beside the pin and drop away outside it, which is how a wiresheet draws a + # fan-out and the only order that keeps a branch from crossing its neighbour. + def fan_order(job, direction): + return (direction * job["focus_y"], + -abs(job["peer_y"] - job["focus_y"])) + + above.sort(key=lambda job: fan_order(job, 1)) + below.sort(key=lambda job: fan_order(job, -1)) + + routed = [] + for job in straight: + # The slots already line up: one straight line, no vertical run to place. + # This is what ordering the peers buys, and it is settled first because such + # a wire crosses the whole gap and everything else has to be fitted past it. + job["route"] = [job["start"], job["end"]] + routed.append(job) + + for job in above + below: + for rank in range(len(side_jobs)): + candidate = self._route(job["start"], job["end"], + self._lane_x(focus_box, side, rank)) + if not any(routes_meet(candidate, other["route"]) + for other in routed): + job["route"] = candidate + break + else: + # No lane in the gap is clear. Rather than leave the link off the + # picture it is drawn in the outermost lane and the crossing is + # accepted -- a crossed wire says more than a missing one. + job["route"] = self._route( + job["start"], job["end"], + self._lane_x(focus_box, side, len(side_jobs) - 1) + ) + routed.append(job) + + def _draw_wire(self, job): + """Paints one routed wire, and the knob of a link that leaves the sheet.""" + entry = job["entry"] + self._wire_seq += 1 + tag = f"lv_link_{id(entry['link'])}_{self._wire_seq}" + self._draw_route(job["route"], tag, job["off_sheet"]) + + if job["knob"] is not None: # The knob belongs to the slot, not to the wire, so it is drawn outside the # wire's tag: hovering the line must not recolour the slot's marker. - wiresheet.draw_knob(self.canvas, knob_x, focus_y, False, True) + wiresheet.draw_knob(self.canvas, job["knob"][0], job["knob"][1], False, True) self._make_followable(tag, entry["link"], 1) @@ -570,7 +738,7 @@ class LinkViewPanel: Two slots already at the same height need no turn at all. """ (sx, sy), (ex, ey) = start, end - if abs(sy - ey) < 0.5: + if abs(sy - ey) < EPSILON: return [(sx, sy), (ex, ey)] return [(sx, sy), (lane_x, sy), (lane_x, ey), (ex, ey)] @@ -593,7 +761,8 @@ class LinkViewPanel: (right_lane, below), (left_lane, below), (left_lane, in_y), (focus_box["x"], in_y)] - tag = f"lv_link_{id(entry['link'])}_self_{out_rank}" + self._wire_seq += 1 + tag = f"lv_link_{id(entry['link'])}_{self._wire_seq}" self._draw_route(points, tag, not entry["same_sheet"]) self._make_followable(tag, entry["link"], 1)