Initial public release
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user