diff --git a/.bumpversion.toml b/.bumpversion.toml index f7bc313..0d10635 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "1.1.2" +current_version = "1.2.0" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" serialize = ["{major}.{minor}.{patch}"] search = "{current_version}" diff --git a/.gitignore b/.gitignore index cfda7bd..7e1e887 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,4 @@ cookiecutter-pypackage-env/ node.zip -.claude/ \ No newline at end of file +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d7abd5b..3545cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2789275..8ddd3ad 100644 --- a/README.md +++ b/README.md @@ -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.
@@ -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 diff --git a/__init__.py b/__init__.py index a12b850..63ec5b9 100644 --- a/__init__.py +++ b/__init__.py @@ -8,7 +8,7 @@ __author__ = 'Edoardo Carmignani' __email__ = 'edoardo.carmignani@gmail.com' -__version__ = '1.1.2' +__version__ = '1.2.0' WEB_DIRECTORY = './web' diff --git a/pyproject.toml b/pyproject.toml index 5bbbd14..b4a3cdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/web/js/ExtraLinks.js b/web/js/ExtraLinks.js index ba97aab..a72c1ea 100644 --- a/web/js/ExtraLinks.js +++ b/web/js/ExtraLinks.js @@ -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 { } } } -}; \ No newline at end of file +}; diff --git a/web/js/NodeBBoxIndex.js b/web/js/NodeBBoxIndex.js new file mode 100644 index 0000000..53de45a --- /dev/null +++ b/web/js/NodeBBoxIndex.js @@ -0,0 +1,242 @@ +/** + * Trace logic adapted from 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; +} diff --git a/web/js/Trace.js b/web/js/Trace.js new file mode 100644 index 0000000..15bb2cc --- /dev/null +++ b/web/js/Trace.js @@ -0,0 +1,721 @@ +/** + * Trace logic adapted from 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); + }, +}; + diff --git a/web/js/extralinks_register.js b/web/js/extralinks_register.js index d3d5ca4..190c331 100644 --- a/web/js/extralinks_register.js +++ b/web/js/extralinks_register.js @@ -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) => {