"""Wire/connection drawing and logic for node editor. Each Connection renders as a stack of short line segments, each colored with an interpolation between the two connected nodes' header colors and blended at 50% opacity over the canvas background (simulated alpha since Tkinter's Canvas has no native alpha channel). Routing (smart — picks a curve shape based on the geometry): 1. ``straight`` — ports almost perfectly aligned horizontally with a clear forward path; a nearly-straight line with a tiny bulge. 2. ``s_curve`` — ordinary forward connection (target ahead, ports face each other); cubic S-curve with tangent length scaled by the dominant axis (handles both horizontal-dominant and vertical-dominant cases naturally). 3. ``vertical`` — target is mostly above/below the source with little horizontal distance; pulls the tangent much further vertically so the curve doesn't "bow out" awkwardly. 4. ``detour`` — forward-facing ports but the target is behind the source exit direction (i.e. wire would cross back through its own node body). Routes out-then-down-then-back like a squared-off hook. 5. ``horseshoe`` — typical loop-back case (ports facing the same direction or pointing away from each other). Chooses above vs below the nodes based on which side has more clearance so the wire doesn't cross node bodies when possible. """ from math import comb from utils.constants import ( WIRE_COLOR, WIRE_SELECTED_COLOR, CANVAS_BG, NODE_TYPES, NODE_UPSTREAM_BORDER, NODE_DOWNSTREAM_BORDER, ) # Higher = smoother gradient/curve, but more canvas items. 22 keeps redraws # responsive even on macros with 150+ connections. _SEGMENT_COUNT = 22 # Simulated alpha (Tkinter has no native alpha) for blending wire colors # with the canvas background. _WIRE_ALPHA = 0.5 # Highlight colors mirror the node-border palette: # green = incoming (feeds the selected node's input) # red = outgoing (driven from the selected node's output) _WIRE_INPUT_COLOR = NODE_UPSTREAM_BORDER # green _WIRE_OUTPUT_COLOR = NODE_DOWNSTREAM_BORDER # red # Separate tag so canvas.py can stack highlighted wires above normal ones # (but still below nodes). TAG_WIRE_NORMAL = "wire" TAG_WIRE_HIGHLIGHT = "wire_hl" class Connection: """Visual wire connecting two ports.""" def __init__(self, canvas, from_node, from_port, to_node, to_port, conn_data): self.canvas = canvas self.from_node = from_node self.from_port = from_port self.to_node = to_node self.to_port = to_port self.data = conn_data self._line_ids: list[int] = [] self.selected = False # Selection-adjacency highlight; one of: # None — default faded node-color gradient # "input" — green (fully opaque); wire enters a selected node # "output" — red (fully opaque); wire leaves a selected node # "fade" — red→green gradient; wire connects two selected nodes # Highlighted wires are also raised above normal wires. self.highlight_kind: str | None = None self._draw() def update(self): self._draw() def set_selected(self, selected: bool): self.selected = selected self._draw() def set_highlight(self, kind: str | None): """Set the selection-adjacency highlight. No-op if unchanged so bulk selection updates don't thrash the canvas. """ if self.highlight_kind == kind: return self.highlight_kind = kind self._draw() def destroy(self): for cid in self._line_ids: self.canvas.delete(cid) self._line_ids = [] def hit_test(self, x: int, y: int, threshold: int = 8) -> bool: """Check if (x, y) is within ``threshold`` pixels of the wire.""" for lid in self._line_ids: coords = self.canvas.coords(lid) if len(coords) < 4: continue for i in range(0, len(coords) - 2, 2): x1, y1 = coords[i], coords[i + 1] x2, y2 = coords[i + 2], coords[i + 3] if self._point_line_dist(x, y, x1, y1, x2, y2) < threshold: return True return False def _draw(self): for cid in self._line_ids: self.canvas.delete(cid) self._line_ids = [] x1, y1 = self.from_port.x, self.from_port.y x2, y2 = self.to_port.x, self.to_port.y zoom = self._get_zoom() points = self._curve_points(x1, y1, x2, y2, zoom, _SEGMENT_COUNT) highlighted = self.highlight_kind is not None if self.selected: colors = [WIRE_SELECTED_COLOR] * len(points) base_w = 5 elif self.highlight_kind == "input": colors = [_WIRE_INPUT_COLOR] * len(points) base_w = 5 elif self.highlight_kind == "output": colors = [_WIRE_OUTPUT_COLOR] * len(points) base_w = 5 elif self.highlight_kind == "fade": # Wire between two selected nodes — fade red → green so each # end matches the port color it terminates at. colors = [] n = max(1, len(points) - 1) for i in range(len(points)): t = i / n colors.append(self._lerp_color(_WIRE_OUTPUT_COLOR, _WIRE_INPUT_COLOR, t)) base_w = 5 else: # Faded gradient between the two nodes' header colors. from_c = self._node_color(self.from_node) to_c = self._node_color(self.to_node) colors = [] n = max(1, len(points) - 1) for i in range(len(points)): t = i / n rgb = self._lerp_color(from_c, to_c, t) rgb = self._blend_with_bg(rgb, _WIRE_ALPHA) colors.append(rgb) base_w = 4 width = max(1, int(round(base_w * zoom))) tag = TAG_WIRE_HIGHLIGHT if highlighted else TAG_WIRE_NORMAL # Each segment uses the color at its starting endpoint, producing # the visual gradient along the wire. for i in range(len(points) - 1): ax, ay = points[i] bx, by = points[i + 1] color = colors[i] lid = self.canvas.create_line( ax, ay, bx, by, fill=color, width=width, capstyle="round", tags=(tag,), ) self._line_ids.append(lid) # Both kinds of wires stay below nodes; canvas._reorder_wire_layers # handles the finer "highlighted on top of normal" layering. try: self.canvas.tag_lower(tag, "node") except Exception: pass def _curve_points(self, x1, y1, x2, y2, zoom, n_samples): """Sample points along a smart-routed curve between the two ports. Picks a curve style based on the relative geometry of the two ports (see module docstring). Takes into account each port's ``side`` ('L' or 'R') so wires always exit/enter in the direction away from the node body, even when a node is flipped. """ # Exit direction: +1 = right of node, -1 = left of node from_side = getattr(self.from_port, "side", "R") to_side = getattr(self.to_port, "side", "L") from_dir = 1 if from_side == "R" else -1 to_dir = 1 if to_side == "R" else -1 dx = x2 - x1 dy = y2 - y1 adx = abs(dx) ady = abs(dy) facing = (from_dir != to_dir) # Target lies ahead of the source exit side in_exit_direction = (dx * from_dir) > 0 if dx != 0 else True if not facing: # Same-side ports → horseshoe loop return self._bezier_samples( self._horseshoe_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom), n_samples, ) if not in_exit_direction: # Naive cubic would loop through the source node body; use a tall hook return self._bezier_samples( self._detour_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom), n_samples, ) # Perfectly (or almost) aligned: pure straight line if ady <= 4 * zoom: # Tiny offset preserves smooth port joins off = max(10 * zoom, adx * 0.08) return self._bezier_samples([ (x1, y1), (x1 + off * from_dir, y1), (x2 + off * to_dir, y2), (x2, y2), ], n_samples) # Vertical-dominant: kick in early (ratio 1.3) so stacked-node layouts # use this shape instead of the generic S-curve. if ady > adx * 1.3 and ady > 60 * zoom: return self._bezier_samples( self._vertical_dominant_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom, adx, ady), n_samples, ) # Nearly-aligned horizontal: minimal bulge if ady < 40 * zoom and adx > 40 * zoom: return self._bezier_samples( self._straight_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom), n_samples, ) # Very short hop: tight tangents so the wire doesn't overshoot if adx + ady < 80 * zoom: off = max(12 * zoom, (adx + ady) * 0.25) return self._bezier_samples([ (x1, y1), (x1 + off * from_dir, y1), (x2 + off * to_dir, y2), (x2, y2), ], n_samples) # Default horizontal-dominant S-curve. Tangent length grows with the # horizontal gap but is capped so huge horizontal separations still # produce a tidy curve rather than a sagging one. offset = max(40 * zoom, 0.55 * adx + 0.15 * ady) offset = min(offset, 300 * zoom + 0.25 * adx) ctrl = [ (x1, y1), (x1 + offset * from_dir, y1), (x2 + offset * to_dir, y2), (x2, y2), ] return self._bezier_samples(ctrl, n_samples) # ---------- Individual routing styles ---------- @staticmethod def _straight_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom): """Nearly-aligned horizontal pair — very small tangent so the line reads as essentially straight with gentle port blending.""" off = max(14 * zoom, abs(x2 - x1) * 0.10) return [ (x1, y1), (x1 + off * from_dir, y1), (x2 + off * to_dir, y2), (x2, y2), ] @staticmethod def _vertical_dominant_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom, adx, ady): """Target mostly above/below. Quintic Bezier with middle control points along the vertical line between the two ports — produces a vertical "S on its side" instead of the horizontal bow a plain cubic would draw. """ # Short horizontal escape off each port; then the curve runs vertical escape = max(18 * zoom, 20 * zoom + adx * 0.15) # Strong vertical stretch so the S leans vertical rather than diagonal vstretch = max(50 * zoom, ady * 0.55) vdir = 1 if y2 > y1 else -1 return [ (x1, y1), (x1 + escape * from_dir, y1), (x1 + escape * from_dir, y1 + vstretch * vdir), (x2 + escape * to_dir, y2 - vstretch * vdir), (x2 + escape * to_dir, y2), (x2, y2), ] # Vertical gap (canvas px at zoom=1) below which two vertically-offset # node bodies overlap, so we route around instead of through. Node body # is roughly 54 px + padding. _CORRIDOR_MIN_CLEARANCE = 90 @classmethod def _detour_ctrl(cls, x1, y1, x2, y2, from_dir, to_dir, zoom): """Facing ports but target is behind source's exit side. Picks the shortest viable route: 1. **Corridor** — when the two ports are vertically separated by more than a node's height, there's a clear horizontal strip between them. Route through that corridor (mid_y between the two ports). This is dramatically shorter than arcing under or over both nodes and is the common case for "output up-right of input" wiring. 2. **Arc above / below** — when ports are too close vertically to have a corridor, loop around the side that matches the natural direction of travel. Target below source → arc under target. Target above → arc over source. Arc size is just big enough to clear one node, not both. """ ady = abs(y2 - y1) h_off = max(55 * zoom, abs(x2 - x1) * 0.18 + 50 * zoom) # Corridor routing (preferred when there's room) if ady > cls._CORRIDOR_MIN_CLEARANCE * zoom: mid_y = (y1 + y2) / 2 return [ (x1, y1), (x1 + h_off * from_dir, y1), (x1 + h_off * from_dir, mid_y), (x2 + h_off * to_dir, mid_y), (x2 + h_off * to_dir, y2), (x2, y2), ] # No corridor — arc around one side. v_off only needs to clear one # node body's height, not two. v_off = max(60 * zoom, ady * 0.5 + 45 * zoom) if y2 < y1: mid_y = min(y1, y2) - v_off else: mid_y = max(y1, y2) + v_off return [ (x1, y1), (x1 + h_off * from_dir, y1), (x1 + h_off * from_dir, mid_y), (x2 + h_off * to_dir, mid_y), (x2 + h_off * to_dir, y2), (x2, y2), ] @classmethod def _horseshoe_ctrl(cls, x1, y1, x2, y2, from_dir, to_dir, zoom): """Same-side ports (both exiting right, or both left). Prefers the horizontal corridor between the two ports when there's vertical clearance, falls back to arcing around one side. Arc direction follows natural vertical travel to avoid bouncing backwards. """ ady = abs(y2 - y1) h_off = max(55 * zoom, abs(x2 - x1) * 0.18 + 50 * zoom) if ady > cls._CORRIDOR_MIN_CLEARANCE * zoom: mid_y = (y1 + y2) / 2 return [ (x1, y1), (x1 + h_off * from_dir, y1), (x1 + h_off * from_dir, mid_y), (x2 + h_off * to_dir, mid_y), (x2 + h_off * to_dir, y2), (x2, y2), ] v_off = max(55 * zoom, ady * 0.5 + 40 * zoom) if y2 < y1: mid_y = min(y1, y2) - v_off else: mid_y = max(y1, y2) + v_off return [ (x1, y1), (x1 + h_off * from_dir, y1), (x1 + h_off * from_dir, mid_y), (x2 + h_off * to_dir, mid_y), (x2 + h_off * to_dir, y2), (x2, y2), ] @staticmethod def _bezier_samples(ctrl, n): """Sample a Bezier of any degree at n+1 equally-spaced t values.""" deg = len(ctrl) - 1 if deg < 1: return list(ctrl) pts = [] for i in range(n + 1): t = i / n if n else 0 u = 1 - t x = y = 0.0 for k, (cx, cy) in enumerate(ctrl): b = comb(deg, k) * (u ** (deg - k)) * (t ** k) x += cx * b y += cy * b pts.append((x, y)) return pts def _get_zoom(self) -> float: node = self.from_node if self.from_node else self.to_node if node is not None and getattr(node, "canvas_ref", None) is not None: return getattr(node.canvas_ref, "_zoom_level", 1.0) return 1.0 @staticmethod def _node_color(node_widget) -> str: if node_widget is not None and getattr(node_widget, "data", None): info = NODE_TYPES.get(node_widget.data.type, {}) return info.get("color", WIRE_COLOR) return WIRE_COLOR @staticmethod def _parse_hex(c: str): return int(c[1:3], 16), int(c[3:5], 16), int(c[5:7], 16) @staticmethod def _to_hex(r, g, b) -> str: r = max(0, min(255, int(round(r)))) g = max(0, min(255, int(round(g)))) b = max(0, min(255, int(round(b)))) return f"#{r:02X}{g:02X}{b:02X}" @classmethod def _lerp_color(cls, c1, c2, t): r1, g1, b1 = cls._parse_hex(c1) r2, g2, b2 = cls._parse_hex(c2) return cls._to_hex( r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t, ) @classmethod def _blend_with_bg(cls, color, alpha): """Simulate alpha by blending ``color`` at ``alpha`` over the canvas bg.""" r1, g1, b1 = cls._parse_hex(color) r2, g2, b2 = cls._parse_hex(CANVAS_BG) return cls._to_hex( r1 * alpha + r2 * (1 - alpha), g1 * alpha + g2 * (1 - alpha), b1 * alpha + b2 * (1 - alpha), ) @staticmethod def _point_line_dist(px, py, x1, y1, x2, y2) -> float: dx, dy = x2 - x1, y2 - y1 if dx == 0 and dy == 0: return ((px - x1) ** 2 + (py - y1) ** 2) ** 0.5 t = max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))) proj_x = x1 + t * dx proj_y = y1 + t * dy return ((px - proj_x) ** 2 + (py - proj_y) ** 2) ** 0.5