Files
2026-07-17 15:29:53 -04:00

466 lines
17 KiB
Python

"""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