Merge pull request #13 from edoardocarmignani/trace-mode

trace mode
This commit is contained in:
Edoardo Carmignani
2026-06-23 17:49:53 +02:00
committed by GitHub
10 changed files with 1059 additions and 41 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "1.1.2"
current_version = "1.2.0"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
+1 -1
View File
@@ -102,4 +102,4 @@ cookiecutter-pypackage-env/
node.zip
.claude/
.claude/
+15
View File
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
---
## [1.2.0] - 2026-06-23
### Added
- Added **Trace** link style with node-aware routing, 90°/45° path constraints, rounded corners, and no center marker.
- Added a node bbox spatial index to reduce routing intersection checks on large graphs.
### Changed
- Implemented Trace in `web/js/Trace.js` with bbox indexing in `web/js/NodeBBoxIndex.js`.
- Improved custom link rendering for backward links and collapsed-node layouts.
- Added attribution for routing logic adapted from `quick-connections`.
---
## [1.1.2] - 2026-01-19
### Fixed
+12 -4
View File
@@ -17,7 +17,7 @@ A one-click collection of alternate connection styles for ComfyUI.
------------------------------------------------------------------
Different types of **link connections** and **geometry awareness**.
Different types of **link connections**, **geometry awareness**, and routed **Trace** links.
<div align="center">
<img width=250 src="imgs/curved.png">
@@ -69,13 +69,17 @@ Restart ComfyUI.
Open **Settings** → **Extra Links** and configure:
- **Enable**: toggle on/off
- **Shape**: `Curved` | `Manhattan` | `Subway`
- **Shape**: `Curved` | `Manhattan` | `Subway` | `Trace`
- **Radius**: corner smoothing
- **Offset**: horizontal separation for multi-outputs
- **Curvature**: strength of turn-in/out
Changes apply immediately to the canvas.
### Trace
`Trace` routes links with mostly 90° and 45° segments, rounded corners, and node-aware obstacle routing. It avoids the center marker used by the other link styles.
------------------------------------------------------------------
## Compatibility
@@ -111,6 +115,7 @@ If issues persist, please open an issue with:
## Versioning & Releases
- **1.2.0** - Added Trace mode with node-aware 90°/45° routing.
- **1.1.2** - Fixed links inside subgraphs.
- **1.1.1** - Fixed collapsed state node links.
- **1.1.0** - Added subway style and geometry awareness.
@@ -129,8 +134,10 @@ Tags:
## Development
- Main code lives in `extralinks/web/ExtraLinks.js`
- Settings/registration live in `extralinks/web/extralinks_register.js`
- Main code lives in `extralinks/web/js/ExtraLinks.js`
- Trace routing lives in `extralinks/web/js/Trace.js`
- Node bbox indexing lives in `extralinks/web/js/NodeBBoxIndex.js`
- Settings/registration live in `extralinks/web/js/extralinks_register.js`
- Python exposes `WEB_DIRECTORY` via `extralinks/__init__.py`
Typical flow:
@@ -161,4 +168,5 @@ MIT (see [`LICENSE`](LICENSE))
## Credits
- Built by [@edoardocarmignani](https://github.com/edoardocarmignani)
- Trace routing is adapted from [quick-connections](https://github.com/niknah/quick-connections) by [@niknah](https://github.com/niknah)
- Thanks to the ComfyUI community for the incredible work
+1 -1
View File
@@ -8,7 +8,7 @@
__author__ = 'Edoardo Carmignani'
__email__ = 'edoardo.carmignani@gmail.com'
__version__ = '1.1.2'
__version__ = '1.2.0'
WEB_DIRECTORY = './web'
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "extralinks"
version = "1.1.2"
version = "1.2.0"
description = "More connection links."
authors = [
{name = "Edoardo Carmignani", email = "edoardo.carmignani@gmail.com"}
+62 -31
View File
@@ -1,4 +1,15 @@
import { app } from "../../../scripts/app.js"
import { traceRenderer } from "./Trace.js"
function _getGraphLink(graph, linkId) {
return graph?.getLink?.(linkId)
?? graph?.getLink?.(Number(linkId))
?? graph?.links?.[linkId]
?? graph?.links?.[String(linkId)]
?? graph?.links?.get?.(linkId)
?? graph?.links?.get?.(Number(linkId))
?? null;
}
const LinkRenderers = {
curved: {
@@ -21,7 +32,7 @@ const LinkRenderers = {
const oX = Math.min(offsetX, (dx * 0.5))
const x1 = x0 + oX;
const r = Math.min(radius, Math.abs(dx * 0.5), Math.abs(dy * 0.5), Math.abs(x0 - x1));
const r = dx > 0 ? Math.min(radius, Math.abs(dx * 0.5), Math.abs(dy * 0.5), Math.abs(x0 - x1)) : radius;
path.moveTo(x0, y1);
@@ -51,11 +62,11 @@ const LinkRenderers = {
return;
}
const nodeHeight = start_node.collapsed ? start_node.height / 2 : start_node.size[1];
const sourceBottom = start_node.collapsed ? start_node.pos[1] - (nodeHeight - offset) / 2 : nodeHeight + start_node.pos[1];
const sourceLeft = (start_node.pos[1] + start_node.size[1] < y2) ? start_node.pos[0] + start_node.size[0] : start_node.pos[0] + 10;
const sourceBottom = start_node.collapsed ? y1 + offset / 2 : start_node.pos[1] + start_node.size[1];
const targetBottom = end_node ? (end_node.collapsed ? y2 + offset / 2 : end_node.pos[1] + end_node.size[1]) : sourceBottom;
const sourceLeft = (sourceBottom < y2) ? start_node.pos[0] + start_node.size[0] : start_node.pos[0] + 10;
const trayY = sourceBottom + ((slot_id + 1) * offset / 2);
const trayY = Math.max(y1, Math.max(sourceBottom, targetBottom) + ((slot_id + 1) * offset / 2));
const turnX1 = x0 + offset;
let turnX2 = x2 - offset;
@@ -64,10 +75,13 @@ const LinkRenderers = {
turnX2 = sourceLeft - offset;
}
path.arcTo(turnX1, y1, turnX1, trayY, r);
path.arcTo(turnX1, trayY, turnX2, trayY, r);
path.arcTo(turnX2, trayY, turnX2, y2, r);
path.arcTo(turnX2, y2, x2, y2, r);
const r12 = Math.min(r, (trayY - y1) / 2);
const r34 = Math.min(r, Math.abs(y2 - trayY) / 2);
path.arcTo(turnX1, y1, turnX1, trayY, r12);
path.arcTo(turnX1, trayY, turnX2, trayY, r12);
path.arcTo(turnX2, trayY, turnX2, y2, r34);
path.arcTo(turnX2, y2, x2, y2, r34);
path.lineTo(x2, y2);
pos[0] = (x2 > sourceLeft) ? (turnX2 + turnX1) / 2 : (x0 + x2) / 2;
@@ -91,7 +105,7 @@ const LinkRenderers = {
const dirX = Math.sign(dx);
const limitedOffset = offset * (outputs > 1 ? Math.log(outputs) : 1);
const fanOffset = Math.max((limitedOffset - slot_id * curvature) * dirX, 0);
const r = Math.min(radius, Math.abs(dx) * 0.5, Math.abs(dy) * 0.5);
const r = dx > 0 ? Math.min(radius, Math.abs(dx) * 0.5, Math.abs(dy) * 0.5) : radius;
path.moveTo(x0, y1);
@@ -116,11 +130,11 @@ const LinkRenderers = {
return;
}
const nodeHeight = start_node.collapsed ? start_node.height / 2 : start_node.size[1];
const sourceBottom = start_node.collapsed ? start_node.pos[1] - (nodeHeight - offset) / 2 : nodeHeight + start_node.pos[1];
const sourceLeft = (start_node.pos[1] + start_node.size[1] < y2) ? start_node.pos[0] + start_node.size[0] : start_node.pos[0] + 10;
const sourceBottom = start_node.collapsed ? y1 + offset / 2 : start_node.pos[1] + start_node.size[1];
const targetBottom = end_node ? (end_node.collapsed ? y2 + offset / 2 : end_node.pos[1] + end_node.size[1]) : sourceBottom;
const sourceLeft = (sourceBottom < y2) ? start_node.pos[0] + start_node.size[0] : start_node.pos[0] + 10;
const trayY = sourceBottom + ((slot_id + 1) * offset / 2);
const trayY = Math.max(y1, Math.max(sourceBottom, targetBottom) + ((slot_id + 1) * offset / 2));
const turnX1 = x0 + offset;
let turnX2 = x2 - offset;
@@ -129,10 +143,13 @@ const LinkRenderers = {
turnX2 = sourceLeft - offset;
}
path.arcTo(turnX1, y1, turnX1, trayY, r);
path.arcTo(turnX1, trayY, turnX2, trayY, r);
path.arcTo(turnX2, trayY, turnX2, y2, r);
path.arcTo(turnX2, y2, x2, y2, r);
const r12 = Math.min(r, (trayY - y1) / 2);
const r34 = Math.min(r, Math.abs(y2 - trayY) / 2);
path.arcTo(turnX1, y1, turnX1, trayY, r12);
path.arcTo(turnX1, trayY, turnX2, trayY, r12);
path.arcTo(turnX2, trayY, turnX2, y2, r34);
path.arcTo(turnX2, y2, x2, y2, r34);
path.lineTo(x2, y2);
pos[0] = (x2 > sourceLeft) ? (turnX2 + turnX1) / 2 : (x0 + x2) / 2;
@@ -152,7 +169,7 @@ const LinkRenderers = {
const dx = x2 - x0;
const dy = y2 - y0;
const r = Math.min(radius, offset, Math.abs(dx), Math.abs(dy));
const r = dx > 0 ? Math.min(radius, offset, Math.abs(dx), Math.abs(dy)) : radius;
const midX = (x0 + x2) * 0.5;
const hspace = (dx * 0.5);
@@ -189,21 +206,24 @@ const LinkRenderers = {
return;
}
const nodeHeight = start_node.collapsed ? start_node.height / 2 : start_node.size[1];
const sourceBottom = start_node.collapsed ? start_node.pos[1] - (nodeHeight - offset) / 2 : nodeHeight + start_node.pos[1];
const sourceLeft = (start_node.pos[1] + nodeHeight < y2) ? start_node.pos[0] + start_node.size[0] : start_node.pos[0] + 10;
const sourceBottom = start_node.collapsed ? y0 + offset / 2 : start_node.pos[1] + start_node.size[1];
const targetBottom = end_node ? (end_node.collapsed ? y2 + offset / 2 : end_node.pos[1] + end_node.size[1]) : sourceBottom;
const sourceLeft = (sourceBottom < y2) ? start_node.pos[0] + start_node.size[0] : start_node.pos[0] + 10;
const trayY = sourceBottom + ((slot_id + 1) * offset / 2);
const trayY = Math.max(y0, Math.max(sourceBottom, targetBottom) + ((slot_id + 1) * offset / 2));
let turnX2 = x2 - offset;
if (Math.floor(x2) > Math.floor(sourceLeft)) {
turnX2 = sourceLeft - offset;
}
path.arcTo(turnX1, y0, turnX1, trayY, r);
path.arcTo(turnX1, trayY, turnX2, trayY, r);
path.arcTo(turnX2, trayY, turnX2, y2, r);
path.arcTo(turnX2, y2, x2, y2, r);
const r12 = Math.min(r, (trayY - y0) / 2);
const r34 = Math.min(r, Math.abs(y2 - trayY) / 2);
path.arcTo(turnX1, y0, turnX1, trayY, r12);
path.arcTo(turnX1, trayY, turnX2, trayY, r12);
path.arcTo(turnX2, trayY, turnX2, y2, r34);
path.arcTo(turnX2, y2, x2, y2, r34);
path.lineTo(x2, y2);
pos[0] = (x2 > sourceLeft) ? (turnX2 + x0 + offset) / 2 : (x0 + x2) / 2;
@@ -212,6 +232,7 @@ const LinkRenderers = {
}
}
},
trace: traceRenderer,
};
export class ExtraLinks {
@@ -245,7 +266,7 @@ export class ExtraLinks {
if (!app.extensionManager.setting.get("Extra Links.General.Enable")) {
pathRendererConstructor.prototype.drawLinkPath = _originalDrawLinkPath;
pathRendererConstructor.prototype.calculateCenterPoint = _originalCalculateCenterPoint;
return;
return _originalDrawLinkPath.call(this, ctx, path, link2, context, lineWidth, color2);
}
const _graph = app.canvas.graph;
@@ -255,13 +276,13 @@ export class ExtraLinks {
const CURVATURE = app.extensionManager.setting.get("Extra Links.Shapes.Curvature") ?? 5;
const SHAPE = app.extensionManager.setting.get("Extra Links.General.Shape") ?? "curved";
const full_link_object = _graph.links[link2?.id];
const full_link_object = _getGraphLink(_graph, link2?.id);
const outputId = full_link_object?.origin_slot ?? 0;
const start_node = _graph.getNodeById(full_link_object?.origin_id);
const end_node = _graph.getNodeById(full_link_object?.target_id);
const is_dragging = (link2?.id == 'temp') || !_graph.links[link2.id];
const is_dragging = (link2?.id == 'temp') || !full_link_object;
const startPos = link2.startPoint;
const endPos = link2.endPoint;
@@ -274,6 +295,10 @@ export class ExtraLinks {
const renderer = LinkRenderers[SHAPE] || LinkRenderers.curved;
const _linkId = link2?.id ?? null;
const _isStale = _linkId != null && !full_link_object && !is_dragging;
traceRenderer.prepare(_linkId, _graph, _isStale);
renderer.draw(
path,
startPos,
@@ -291,6 +316,12 @@ export class ExtraLinks {
ctx.stroke(path);
pathRendererConstructor.prototype.calculateCenterPoint = (...args) => {
if (SHAPE === "trace") {
delete link2.centerPos;
delete link2.centerAngle;
return null;
}
link2.centerPos = { x: pos[0], y: pos[1] }
if (context.style.centerMarkerShape === 'arrow') {
link2.centerAngle = pos[2];
@@ -300,4 +331,4 @@ export class ExtraLinks {
}
}
}
};
};
+242
View File
@@ -0,0 +1,242 @@
/**
* Trace logic adapted from quick-connections <https://github.com/niknah/quick-connections>
*/
const EPSILON = 1e-6;
const INSIDE = 1;
const DEFAULT_CELL_SIZE = 256;
function clipT(num, denom, c) {
const tE = c[0], tL = c[1];
if (Math.abs(denom) < EPSILON) {
return num < 0;
}
const t = num / denom;
if (denom > 0) {
if (t > tL) {
return 0;
}
if (t > tE) {
c[0] = t;
}
} else {
if (t < tE) {
return 0;
}
if (t < tL) {
c[1] = t;
}
}
return 1;
}
function liangBarsky(a, b, box, da, db) {
const x1 = a[0], y1 = a[1];
const x2 = b[0], y2 = b[1];
const dx = x2 - x1;
const dy = y2 - y1;
if (da === undefined || db === undefined) {
da = a;
db = b;
} else {
da[0] = a[0];
da[1] = a[1];
db[0] = b[0];
db[1] = b[1];
}
if (Math.abs(dx) < EPSILON
&& Math.abs(dy) < EPSILON
&& x1 >= box[0]
&& x1 <= box[2]
&& y1 >= box[1]
&& y1 <= box[3]) {
return INSIDE;
}
const c = [0, 1];
if (clipT(box[0] - x1, dx, c)
&& clipT(x1 - box[2], -dx, c)
&& clipT(box[1] - y1, dy, c)
&& clipT(y1 - box[3], -dy, c)) {
const tE = c[0], tL = c[1];
if (tL < 1) {
db[0] = x1 + tL * dx;
db[1] = y1 + tL * dy;
}
if (tE > 0) {
da[0] += tE * dx;
da[1] += tE * dy;
}
return INSIDE;
}
return 0;
}
function getAreaFromNode(node) {
if (!node) {
return null;
}
try {
if (node.getBounding) {
const area = new Float32Array(4);
node.getBounding(area);
return [
area[0],
area[1],
area[0] + area[2],
area[1] + area[3],
];
}
} catch (e) {
}
if (node.pos && node.size) {
return [
node.pos[0],
node.pos[1],
node.pos[0] + node.size[0],
node.pos[1] + node.size[1],
];
}
return null;
}
function normalizeBox(item, index) {
const node = item?.node || item;
const area = item?.area || getAreaFromNode(node);
if (!area) {
return null;
}
return {
id: node?.id ?? item?.id ?? index,
node,
area: Array.from(area),
linesArea: Array.from(item?.linesArea || area),
source: item,
};
}
function cellRange(area, cellSize) {
return {
x1: Math.floor(area[0] / cellSize),
y1: Math.floor(area[1] / cellSize),
x2: Math.floor(area[2] / cellSize),
y2: Math.floor(area[3] / cellSize),
};
}
function cellKey(x, y) {
return `${x},${y}`;
}
function expandedArea(area, padding) {
return [
area[0] - padding,
area[1] - padding,
area[2] + padding,
area[3] + padding,
];
}
function segmentArea(from, to, padding) {
return [
Math.min(from[0], to[0]) - padding,
Math.min(from[1], to[1]) - padding,
Math.max(from[0], to[0]) + padding,
Math.max(from[1], to[1]) + padding,
];
}
export function buildNodeBBoxIndex(items, options = {}) {
const cellSize = options.cellSize || DEFAULT_CELL_SIZE;
const padding = options.padding || 0;
const boxes = [];
const grid = new Map();
(items || []).forEach((item, index) => {
const box = normalizeBox(item, index);
if (!box) {
return;
}
box.area = expandedArea(box.area, padding);
box.linesArea = expandedArea(box.linesArea, padding);
const boxIndex = boxes.push(box) - 1;
const range = cellRange(box.area, cellSize);
for (let x = range.x1; x <= range.x2; x++) {
for (let y = range.y1; y <= range.y2; y++) {
const key = cellKey(x, y);
let bucket = grid.get(key);
if (!bucket) {
bucket = new Set();
grid.set(key, bucket);
}
bucket.add(boxIndex);
}
}
});
return { cellSize, boxes, grid };
}
export function querySegmentCandidates(index, from, to, padding = 0) {
if (!index) {
return [];
}
const seen = new Set();
const candidates = [];
const range = cellRange(segmentArea(from, to, padding), index.cellSize);
for (let x = range.x1; x <= range.x2; x++) {
for (let y = range.y1; y <= range.y2; y++) {
const bucket = index.grid.get(cellKey(x, y));
if (!bucket) {
continue;
}
for (const boxIndex of bucket) {
if (seen.has(boxIndex)) {
continue;
}
seen.add(boxIndex);
candidates.push(index.boxes[boxIndex]);
}
}
}
return candidates;
}
export function findSegmentIntersections(index, from, to, options = {}) {
const padding = options.padding || 0;
const excludeNodeIds = options.excludeNodeIds || null;
const candidates = querySegmentCandidates(index, from, to, padding);
const hits = [];
for (const candidate of candidates) {
if (excludeNodeIds?.has?.(candidate.id) || excludeNodeIds?.has?.(String(candidate.id))) {
continue;
}
const clipA = [-1, -1];
const clipB = [-1, -1];
const clipped = liangBarsky(from, to, candidate.area, clipA, clipB);
if (clipped !== INSIDE) {
continue;
}
const centerX = candidate.area[0] + ((candidate.area[2] - candidate.area[0]) / 2);
const centerY = candidate.area[1] + ((candidate.area[3] - candidate.area[1]) / 2);
const dx = centerX - from[0];
const dy = centerY - from[1];
hits.push({
start: clipA,
end: clipB,
node: candidate.source || candidate,
distance: (dx * dx) + (dy * dy),
});
}
hits.sort((a, b) => a.distance - b.distance);
return hits;
}
+721
View File
@@ -0,0 +1,721 @@
/**
* Trace logic adapted from quick-connections <https://github.com/niknah/quick-connections>
*/
import { buildNodeBBoxIndex, findSegmentIntersections } from "./NodeBBoxIndex.js"
class MapLinks {
constructor(canvas, config) {
this.canvas = canvas;
this.nodesByRight = [];
this.nodesById = [];
this.pathsByLinkId = new Map();
this.nodeBBoxIndex = null;
this.config = config;
this.maxDirectLineDistance = Number.MAX_SAFE_INTEGER;
}
findClippedNode(outputXY, inputXY) {
const intersections = findSegmentIntersections(this.nodeBBoxIndex, outputXY, inputXY);
if (intersections.length) {
return intersections[0];
}
return null;
}
testPath(path) {
const len1 = (path.length - 1);
for (let p = 0; p < len1; ++p) {
const clipped = this.findClippedNode(path[p], path[p + 1]);
if (clipped) {
return clipped;
}
}
return null;
}
mapFinalLink(outputXY, inputXY) {
const clipped = this.findClippedNode(outputXY, inputXY);
if (!clipped) {
const dist = Math.sqrt(((outputXY[0] - inputXY[0]) ** 2) + ((outputXY[1] - inputXY[1]) ** 2));
if (dist < this.maxDirectLineDistance) {
return { path: _path90Or45(outputXY, inputXY) };
}
}
const horzDistance = inputXY[0] - outputXY[0];
const vertDistance = inputXY[1] - outputXY[1];
const horzDistanceAbs = Math.abs(horzDistance);
const vertDistanceAbs = Math.abs(vertDistance);
if (horzDistanceAbs > vertDistanceAbs) {
const goingLeft = inputXY[0] < outputXY[0];
const pathStraight45 = [
[outputXY[0], outputXY[1]],
[inputXY[0] - (goingLeft ? -vertDistanceAbs : vertDistanceAbs), outputXY[1]],
[inputXY[0], inputXY[1]],
];
if (!this.testPath(pathStraight45)) {
return { path: pathStraight45 };
}
const path45Straight = [
[outputXY[0], outputXY[1]],
[outputXY[0] + (goingLeft ? -vertDistanceAbs : vertDistanceAbs), inputXY[1]],
[inputXY[0], inputXY[1]],
];
if (!this.testPath(path45Straight)) {
return { path: path45Straight };
}
} else {
const goingUp = inputXY[1] < outputXY[1];
const pathStraight45 = [
[outputXY[0], outputXY[1]],
[outputXY[0], inputXY[1] + (goingUp ? horzDistanceAbs : -horzDistanceAbs)],
[inputXY[0], inputXY[1]],
];
if (!this.testPath(pathStraight45)) {
return { path: pathStraight45 };
}
const path45Straight = [
[outputXY[0], outputXY[1]],
[inputXY[0], outputXY[1] - (goingUp ? horzDistanceAbs : -horzDistanceAbs)],
[inputXY[0], inputXY[1]],
];
if (!this.testPath(path45Straight)) {
return { path: path45Straight };
}
}
const path90Straight = [
[outputXY[0], outputXY[1]],
[outputXY[0], inputXY[1]],
[inputXY[0], inputXY[1]],
];
const clippedVert = this.testPath(path90Straight);
if (!clippedVert) {
return { path: path90Straight };
}
const pathStraight90 = [
[outputXY[0], outputXY[1]],
[inputXY[0], outputXY[1]],
[inputXY[0], inputXY[1]],
];
const clippedHorz = this.testPath(pathStraight90);
if (!clippedHorz) {
return { path: pathStraight90 };
}
return {
clippedHorz,
clippedVert,
};
}
mapLink(outputXY, inputXY, isBlocked) {
const { clippedHorz, clippedVert, path } = this.mapFinalLink(outputXY, inputXY);
if (path) {
return path;
}
const horzDistance = inputXY[0] - outputXY[0];
const vertDistance = inputXY[1] - outputXY[1];
const horzDistanceAbs = Math.abs(horzDistance);
const vertDistanceAbs = Math.abs(vertDistance);
let blockedNodeId;
let pathAroundNode;
let lastPathLocation;
let linesArea;
if (horzDistanceAbs > vertDistanceAbs) {
blockedNodeId = clippedHorz.node.node.id;
linesArea = clippedHorz.node.linesArea;
const horzEdge = horzDistance <= 0
? (linesArea[2])
: (linesArea[0] - 1);
pathAroundNode = [
[outputXY[0], outputXY[1]],
[horzEdge, outputXY[1]],
];
if (horzDistance <= 0) {
linesArea[2] += this.config.lineSpace;
} else {
linesArea[0] -= this.config.lineSpace;
}
const vertDistanceViaBlockTop =
Math.abs(inputXY[1] - linesArea[1]) +
Math.abs(linesArea[1] - outputXY[1]);
const vertDistanceViaBlockBottom =
Math.abs(inputXY[1] - linesArea[3]) +
Math.abs(linesArea[3] - outputXY[1]);
lastPathLocation = [
horzEdge,
vertDistanceViaBlockTop <= vertDistanceViaBlockBottom ?
(linesArea[1])
: (linesArea[3]),
];
const unblockNotPossible1 = this.testPath([...pathAroundNode, lastPathLocation]);
if (unblockNotPossible1) {
lastPathLocation = [
horzEdge,
vertDistanceViaBlockTop > vertDistanceViaBlockBottom ?
(linesArea[1])
: (linesArea[3]),
];
}
if (lastPathLocation[1] < outputXY[1]) {
linesArea[1] -= this.config.lineSpace;
lastPathLocation[1] -= 1;
} else {
linesArea[3] += this.config.lineSpace;
lastPathLocation[1] += 1;
}
} else {
blockedNodeId = clippedVert.node.node.id;
linesArea = clippedVert.node.linesArea;
const vertEdge =
vertDistance <= 0
? (linesArea[3] + 1)
: (linesArea[1] - 1);
pathAroundNode = [
[outputXY[0], outputXY[1]],
[outputXY[0], vertEdge],
];
if (vertDistance <= 0) {
linesArea[3] += this.config.lineSpace;
} else {
linesArea[1] -= this.config.lineSpace;
}
const horzDistanceViaBlockLeft =
Math.abs(inputXY[0] - linesArea[0]) +
Math.abs(linesArea[0] - outputXY[0]);
const horzDistanceViaBlockRight =
Math.abs(inputXY[0] - linesArea[2]) +
Math.abs(linesArea[2] - outputXY[0]);
lastPathLocation = [
horzDistanceViaBlockLeft <= horzDistanceViaBlockRight ?
(linesArea[0] - 1)
: (linesArea[2]),
vertEdge,
];
const unblockNotPossible1 = this.testPath([...pathAroundNode, lastPathLocation]);
if (unblockNotPossible1) {
lastPathLocation = [
horzDistanceViaBlockLeft > horzDistanceViaBlockRight ?
(linesArea[0])
: (linesArea[2]),
vertEdge,
];
}
if (lastPathLocation[0] < outputXY[0]) {
linesArea[0] -= this.config.lineSpace;
} else {
linesArea[2] += this.config.lineSpace;
}
}
if (isBlocked[blockedNodeId] > 3) {
isBlocked.blocked = true;
return _path90Or45(outputXY, inputXY);
}
if (isBlocked[blockedNodeId])
++isBlocked[blockedNodeId];
else
isBlocked[blockedNodeId] = 1;
const nextPath = this.mapLink(
lastPathLocation,
inputXY,
isBlocked,
);
return [...pathAroundNode, lastPathLocation, ...nextPath.slice(1)];
}
expandSourceNodeLinesArea(sourceNodeInfo, path) {
if (path.length < 3) {
return false;
}
const linesArea = sourceNodeInfo.linesArea;
if (path[1][0] === path[2][0]) {
linesArea[2] += this.config.lineSpace;
}
return true;
}
expandTargetNodeLinesArea(targetNodeInfo, path) {
if (path.length < 2) {
return false;
}
const linesArea = targetNodeInfo.linesArea;
const path2Len = path.length - 2;
if (path[path2Len - 1][0] === path[path2Len][0]) {
linesArea[0] -= this.config.lineSpace;
}
return true;
}
getNodeOnPos(xy) {
for (let i = 0; i < this.nodesByRight.length; ++i) {
const nodeI = this.nodesByRight[i];
const { linesArea } = nodeI;
if (xy[0] >= linesArea[0]
&& xy[1] >= linesArea[1]
&& xy[0] < linesArea[2]
&& xy[1] < linesArea[3]
) {
return nodeI;
}
}
return null;
}
mapLinks(nodesByExecution) {
const graphLinks = this.canvas.graph.links;
if (!graphLinks) {
console.error('Missing graph.links', this.canvas.graph);
return;
}
this.nodesByRight = [];
this.nodesById = {};
this.pathsByLinkId.clear();
this.nodesByRight = nodesByExecution.map((node) => {
const area = _getNodeArea(node);
if (!area) {
return null;
}
const linesArea = Array.from(area);
linesArea[0] += this.config.nodeSpace[0];
linesArea[1] += this.config.nodeSpace[1];
linesArea[2] += this.config.nodeSpace[2];
linesArea[3] += this.config.nodeSpace[3];
const obj = {
node,
area,
linesArea,
};
this.nodesById[node.id] = obj;
return obj;
}).filter(Boolean);
this.nodeBBoxIndex = buildNodeBBoxIndex(this.nodesByRight);
const nodesByRightId = this.nodesByRight.reduce(
(a, x) => {
a[x.node.id] = x.node;
return a;
},
{},
);
this.nodesByRight.filter((nodeI) => {
const { node } = nodeI;
const outputs = node.outputs;
if (!outputs) {
return false;
}
outputs.filter((output, slot) => {
const links = output.links;
if (!links) {
return false;
}
const outputXYConnection = _getSlotPosition(node, slot, false);
if (!outputXYConnection) {
return false;
}
const outputNodeInfo = this.nodesById[node.id];
let outputXY = Array.from(outputXYConnection);
links.filter((linkId) => {
outputXY[0] = outputNodeInfo.linesArea[2];
const link = _getGraphLink(graphLinks, linkId);
if (!link) {
return false;
}
let targetNode = this.canvas.graph.getNodeById(link.target_id);
if (!targetNode) {
targetNode = nodesByRightId[link.target_id];
}
if (!targetNode) {
return false;
}
const inputXYConnection = _getSlotPosition(targetNode, link.target_slot, true);
if (!inputXYConnection) {
return false;
}
const inputXY = Array.from(inputXYConnection);
const nodeInfo = this.nodesById[targetNode.id];
if (!nodeInfo) {
return false;
}
inputXY[0] = nodeInfo.linesArea[0] - 1;
const inputBlockedByNode =
this.getNodeOnPos(inputXY);
const outputBlockedByNode =
this.getNodeOnPos(outputXY);
let path = null;
if (!inputBlockedByNode && !outputBlockedByNode) {
const isBlocked = {};
const pathFound = this.mapLink(outputXY, inputXY, isBlocked);
if (pathFound) {
while (pathFound.length >= 2) {
const lastPathPoint1 = pathFound[pathFound.length - 1];
const lastPathPoint2 = pathFound[pathFound.length - 2];
if (lastPathPoint1[0] === lastPathPoint2[0]
&& lastPathPoint1[1] === lastPathPoint2[1]
) {
pathFound.pop();
} else {
break;
}
}
}
if (!isBlocked.blocked && pathFound && pathFound.length > 2) {
path = [outputXYConnection, ...pathFound, inputXYConnection];
this.expandTargetNodeLinesArea(nodeInfo, path);
}
}
if (!path) {
path = [outputXYConnection, outputXY, inputXY, inputXYConnection];
}
this.expandSourceNodeLinesArea(nodeI, path);
this.pathsByLinkId.set(linkId, path);
this.pathsByLinkId.set(String(linkId), path);
outputXY = [
outputXY[0] + this.config.lineSpace,
outputXY[1],
];
return false;
});
return false;
});
return false;
});
}
}
class TraceRoutes {
constructor() {
this.mapLinks = null;
this.graph = null;
this.maxDirectLineDistance = Number.MAX_SAFE_INTEGER;
this.config = {
lineSpace: Math.floor((_getLiteGraph()?.NODE_SLOT_HEIGHT ?? 20) / 2),
nodeSpace: [-8, -4, 12, 4],
};
}
recalcMapLinks() {
const canvas = { graph: this.graph };
this.mapLinks = new MapLinks(canvas, this.config);
this.mapLinks.maxDirectLineDistance = this.maxDirectLineDistance;
const nodesByExecution = _getRoutingNodes(this.graph);
try {
this.mapLinks.mapLinks(nodesByExecution);
} catch (e) {
console.error('mapLinks error', e);
}
}
}
function _getLiteGraph() {
return globalThis.LiteGraph || globalThis.window?.LiteGraph;
}
function _getGraphLink(graphLinks, linkId) {
return graphLinks?.[linkId] ?? graphLinks?.[String(linkId)] ?? graphLinks?.get?.(linkId) ?? graphLinks?.get?.(String(linkId));
}
function _appendUniqueNode(nodes, seen, node) {
if (!node) {
return;
}
const id = node.id ?? `${node.type ?? "node"}:${node.pos?.[0]},${node.pos?.[1]}`;
if (seen.has(id)) {
return;
}
seen.add(id);
nodes.push(node);
}
function _getRoutingNodes(graph) {
const nodes = [];
const seen = new Set();
let executionNodes = [];
try {
executionNodes = graph?.computeExecutionOrder?.() || [];
} catch (e) {
executionNodes = [];
}
executionNodes.forEach((node) => _appendUniqueNode(nodes, seen, node));
(graph?._nodes || []).forEach((node) => _appendUniqueNode(nodes, seen, node));
return nodes;
}
function _getNodeArea(node) {
if (!node) {
return null;
}
try {
if (node.getBounding) {
const barea = new Float32Array(4);
node.getBounding(barea);
return [
barea[0],
barea[1],
barea[0] + barea[2],
barea[1] + barea[3],
];
}
} catch (e) {
}
if (node.pos && node.size) {
return [
node.pos[0],
node.pos[1],
node.pos[0] + node.size[0],
node.pos[1] + node.size[1],
];
}
return null;
}
function _getSlotPosition(node, slot, isInput) {
try {
if (node.getSlotPosition) {
return node.getSlotPosition(slot, isInput);
}
if (isInput && node.getInputPos) {
return node.getInputPos(slot);
}
if (!isInput && node.getOutputPos) {
return node.getOutputPos(slot);
}
} catch (e) {
}
if (!node.pos || !node.size) {
return null;
}
const slotHeight = _getLiteGraph()?.NODE_SLOT_HEIGHT ?? 20;
const y = node.pos[1] + ((slot ?? 0) + 0.5) * slotHeight;
return isInput ? [node.pos[0], y] : [node.pos[0] + node.size[0], y];
}
function _drawTracePath(path2d, route, cornerRadius) {
if (route.length <= 1) {
return;
}
let isPrevDotRound = false;
for (let p = 0; p < route.length; ++p) {
const pos = route[p];
if (p === 0) {
path2d.moveTo(pos[0], pos[1]);
}
const prevPos = pos;
const cornerPos = route[p + 1];
const nextPos = route[p + 2];
let drawn = false;
if (nextPos) {
const xDiffBefore = cornerPos[0] - prevPos[0];
const yDiffBefore = cornerPos[1] - prevPos[1];
const xDiffAfter = nextPos[0] - cornerPos[0];
const yDiffAfter = nextPos[1] - cornerPos[1];
const lenBefore = Math.sqrt((xDiffBefore ** 2) + (yDiffBefore ** 2));
const lenAfter = Math.sqrt((xDiffAfter ** 2) + (yDiffAfter ** 2));
const cross = (xDiffBefore * yDiffAfter) - (yDiffBefore * xDiffAfter);
if (lenBefore > 0.001 && lenAfter > 0.001 && Math.abs(cross) > 0.001) {
const radius = Math.min(cornerRadius, lenBefore * 0.5, lenAfter * 0.5);
const beforePos = [
cornerPos[0] - ((xDiffBefore / lenBefore) * radius),
cornerPos[1] - ((yDiffBefore / lenBefore) * radius),
];
const afterPos = [
cornerPos[0] + ((xDiffAfter / lenAfter) * radius),
cornerPos[1] + ((yDiffAfter / lenAfter) * radius),
];
if (isPrevDotRound
&& Math.abs(isPrevDotRound[0] - beforePos[0]) <= cornerRadius
&& Math.abs(isPrevDotRound[1] - beforePos[1]) <= cornerRadius) {
} else {
path2d.lineTo(beforePos[0], beforePos[1]);
path2d.quadraticCurveTo(cornerPos[0], cornerPos[1], afterPos[0], afterPos[1]);
}
isPrevDotRound = beforePos;
drawn = true;
}
}
if (p > 0 && !drawn) {
if (!isPrevDotRound) {
path2d.lineTo(pos[0], pos[1]);
}
isPrevDotRound = false;
}
}
}
function _centerOf(route, pos) {
let best = -1;
for (let i = 0; i < route.length - 1; i++) {
const length = ((route[i + 1][0] - route[i][0]) ** 2) + ((route[i + 1][1] - route[i][1]) ** 2);
if (length > best) {
best = length;
pos[0] = (route[i][0] + route[i + 1][0]) * 0.5;
pos[1] = (route[i][1] + route[i + 1][1]) * 0.5;
pos[2] = Math.atan2(route[i + 1][1] - route[i][1], route[i + 1][0] - route[i][0]);
}
}
}
function _drawFallback90Or45(path, start, end, pos) {
const route = _path90Or45([start.x, start.y], [end.x, end.y]);
_centerOf(route, pos);
_drawTracePath(path, route, _traceRoutes.config.lineSpace);
}
function _samePoint(a, b) {
return a && b && Math.abs(a[0] - b[0]) < 0.001 && Math.abs(a[1] - b[1]) < 0.001;
}
function _appendUniquePoint(route, point) {
if (!_samePoint(route[route.length - 1], point)) {
route.push(point);
}
}
function _path90Or45(from, to) {
if (_samePoint(from, to)) {
return [from];
}
const dx = to[0] - from[0];
const dy = to[1] - from[1];
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (dx === 0 || dy === 0 || Math.abs(absDx - absDy) < 0.001) {
return [from, to];
}
let mid;
if (absDx > absDy) {
mid = [to[0] - (Math.sign(dx) * absDy), from[1]];
} else {
mid = [from[0], to[1] - (Math.sign(dy) * absDx)];
}
return [from, mid, to];
}
function _normalizeRoute90Or45(points) {
if (points.length <= 1) {
return points;
}
const route = [points[0]];
for (let i = 0; i < points.length - 1; i++) {
const segment = _path90Or45(points[i], points[i + 1]);
for (const point of segment.slice(1)) {
_appendUniquePoint(route, point);
}
}
return route;
}
const _traceRoutes = new TraceRoutes();
let _lastGraph = null;
let _lastHash = null;
function _graphHash(graph) {
if (!graph) {
return "";
}
let hash = "";
const nodes = graph._nodes || [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
hash += `${node.id}:${node.pos?.[0]},${node.pos?.[1]},${node.size?.[0]},${node.size?.[1]};`;
}
const links = graph.links || {};
const linkIds = Object.keys(links).sort();
for (let i = 0; i < linkIds.length; i++) {
const link = links[linkIds[i]];
hash += `link:${linkIds[i]},${link?.origin_id},${link?.origin_slot},${link?.target_id},${link?.target_slot};`;
}
return hash;
}
function _ensureTraceRoutes(graph) {
if (!graph) {
return;
}
_traceRoutes.graph = graph;
const hash = _graphHash(graph);
if (_lastGraph === graph && _lastHash === hash && _traceRoutes.mapLinks) {
return;
}
_lastGraph = graph;
_lastHash = hash;
_traceRoutes.recalcMapLinks();
}
export const traceRenderer = {
_linkId: null,
prepare(linkId, graph, isStale) {
this._linkId = linkId;
if (isStale) {
_lastHash = null;
}
_ensureTraceRoutes(graph);
},
draw(path, start, end, slot_id, start_node, end_node, radius, offset, curvature, pos, is_dragging) {
if (is_dragging || !start_node || !end_node) {
_drawFallback90Or45(path, start, end, pos);
return;
}
const route = this._linkId != null
? _traceRoutes.mapLinks?.pathsByLinkId.get(this._linkId)
: null;
if (!route) {
_drawFallback90Or45(path, start, end, pos);
return;
}
const fullRoute = _normalizeRoute90Or45([[start.x, start.y], ...route.slice(1, -1), [end.x, end.y]]);
_centerOf(fullRoute, pos);
_drawTracePath(path, fullRoute, _traceRoutes.config.lineSpace);
},
};
+3 -2
View File
@@ -47,7 +47,7 @@ app.registerExtension({
},
{
id: "Extra Links.ExtraLinks",
name: "Version 1.1.2",
name: "Version 1.2.0",
type: () => {
const container = document.createElement("span");
const btn = document.createElement("button");
@@ -80,7 +80,8 @@ app.registerExtension({
type: "combo",
options: [{text: "Curved", value: "curved"},
{text: "Manhattan", value: "manhattan"},
{text: "Subway", value: "subway"}],
{text: "Subway", value: "subway"},
{text: "Trace", value: "trace"}],
defaultValue: "curved",
category: ["Extra Links", "General", "Shape"],
onChange: (val) => {