Files
90-RED-ComfyUI-LinkRouter/web/routing.js
T
90-RED 7dbda1f1c4 fix: stale links no longer disable entire routing
- collectLinks now separates ioUnresolved (subgraph IO nodes) from silently skipped stale links
- routeAll only falls back for IO link issues; broken regular links are skipped with continue
- This fixes multi-link workflows where a single stale/deleted link would disable the entire plugin
2026-07-06 05:34:27 +08:00

290 lines
8.6 KiB
JavaScript

// routing.js — link routing core for LinkRouter.
//
// Reads shared state from M in state.js.
// Uses stubLen() / currentMargin() / resetRouter() from state.js.
// Exports routeAll for draw.js and nodeRect for ui.js.
import { app } from "../../scripts/app.js";
import { M } from "./state.js";
import { DIR } from "./router.js";
// Subgraph boundary IO virtual node ids (ComfyUI frontend convention).
const SUBGRAPH_INPUT_ID = -10;
const SUBGRAPH_OUTPUT_ID = -20;
// --- node helpers ---
export function nodeRect(node) {
node.getBounding(M.bounding);
return { x: M.bounding[0], y: M.bounding[1], w: M.bounding[2], h: M.bounding[3] };
}
function resolveNode(graph, id) {
if (id === SUBGRAPH_INPUT_ID) return graph.inputNode || null;
if (id === SUBGRAPH_OUTPUT_ID) return graph.outputNode || null;
return graph.getNodeById?.(id) || null;
}
function ioSlotPos(ioNode, slot) {
const s = ioNode?.slots?.[slot];
const p = s?.pos;
if (!p || !isFinite(p[0]) || !isFinite(p[1])) return null;
return [p[0], p[1]];
}
// --- link collection ---
// Returns { entries, ioUnresolved }. ioUnresolved counts links that
// reference subgraph virtual IO nodes (-10/-20) we failed to resolve —
// only those trigger a full fallback to the official renderer.
// Links pointing at plain missing nodes (stale links after node
// deletion, floating links with origin_id -1, etc.) are silently
// skipped, matching the official renderer's behaviour — they must NOT
// disable SmartEdge for the whole canvas.
function collectLinks(graph) {
const out = [];
let ioUnresolved = 0;
const links = graph.links;
const each = (link) => {
if (!link) return;
const a = resolveNode(graph, link.origin_id);
const b = resolveNode(graph, link.target_id);
if (!a || !b) {
if (
link.origin_id === SUBGRAPH_INPUT_ID ||
link.origin_id === SUBGRAPH_OUTPUT_ID ||
link.target_id === SUBGRAPH_INPUT_ID ||
link.target_id === SUBGRAPH_OUTPUT_ID
)
ioUnresolved++;
return;
}
out.push({ link, a, b });
};
if (links instanceof Map) for (const l of links.values()) each(l);
else for (const id in links) each(links[id]);
return { entries: out, ioUnresolved };
}
// --- endpoints ---
function endpoints(entry) {
const { link, a, b } = entry;
let p1 = null, p2 = null;
try {
p1 =
link.origin_id === SUBGRAPH_INPUT_ID
? ioSlotPos(a, link.origin_slot)
: a.getConnectionPos(false, link.origin_slot, [0, 0]);
p2 =
link.target_id === SUBGRAPH_OUTPUT_ID
? ioSlotPos(b, link.target_slot)
: b.getConnectionPos(true, link.target_slot, [0, 0]);
} catch {
return null;
}
if (!p1 || !p2 || !isFinite(p1[0]) || !isFinite(p2[0])) return null;
const st = M.stubLen();
return {
out: { x: p1[0], y: p1[1] },
inp: { x: p2[0], y: p2[1] },
stubOut: { x: p1[0] + st, y: p1[1] },
stubIn: { x: p2[0] - st, y: p2[1] },
};
}
// --- layout signature ---
function layoutSignature(nodes) {
let s = "";
for (const n of nodes) {
const r = nodeRect(n);
s += n.id + ":" + (r.x | 0) + "," + (r.y | 0) + "," + (r.w | 0) + "," + (r.h | 0) + ";";
}
return s;
}
// --- collision checks ---
function segHitsRects(x1, y1, x2, y2, rects) {
const ax = Math.min(x1, x2) - 1,
ax2 = Math.max(x1, x2) + 1;
const ay = Math.min(y1, y2) - 1,
ay2 = Math.max(y1, y2) + 1;
for (const r of rects) if (ax < r.x2 && ax2 > r.x && ay < r.y2 && ay2 > r.y) return true;
return false;
}
function pathHitsRects(pts, rects) {
for (let k = 0; k < pts.length - 1; k++)
if (segHitsRects(pts[k].x, pts[k].y, pts[k + 1].x, pts[k + 1].y, rects)) return true;
return false;
}
// --- refresh OVG ---
function refreshGraph(graph, linkEntries) {
const nodes = graph._nodes || [];
const sig = layoutSignature(nodes);
if (sig === M.graphSig) return null;
const mg = M.currentMargin();
const ml = typeof mg === "number" ? mg : mg.l,
mr = typeof mg === "number" ? mg : mg.r,
mt = typeof mg === "number" ? mg : mg.t,
mb = typeof mg === "number" ? mg : mg.b;
const rawRects = [];
const newRects = new Map();
for (const n of nodes) {
const rc = nodeRect(n);
rawRects.push(rc);
newRects.set(n.id, {
x: rc.x - ml,
y: rc.y - mt,
x2: rc.x + rc.w + mr,
y2: rc.y + rc.h + mb,
});
}
const terminals = [];
for (const e of linkEntries) {
const ep = endpoints(e);
if (!ep) continue;
terminals.push(ep.stubOut, ep.stubIn);
}
M.router.build(rawRects, terminals);
const dirty = [];
for (const [id, rc] of newRects) {
const o = M.prevRects.get(id);
if (!o || o.x !== rc.x || o.y !== rc.y || o.x2 !== rc.x2 || o.y2 !== rc.y2) {
dirty.push(rc);
if (o) dirty.push(o);
}
}
for (const [id, o] of M.prevRects) if (!newRects.has(id)) dirty.push(o);
M.prevRects = newRects;
M.graphSig = sig;
if (M.S.stickiness) {
if (M.settleTimer) clearTimeout(M.settleTimer);
M.settleTimer = setTimeout(() => {
M.settleTimer = null;
let hadSticky = false;
for (const c of M.pathCache.values())
if (c.sticky) { c.ends = null; hadSticky = true; }
if (hadSticky) app.canvas?.setDirty(true, true);
}, 180);
}
return dirty;
}
// --- path stretch (drag anti-flicker) ---
function stretchPath(oldPts, ep) {
if (!oldPts || oldPts.length < 4) return null;
const pts = oldPts.map((p) => ({ x: p.x, y: p.y }));
const n = pts.length;
pts[0] = ep.out;
pts[1] = { x: ep.stubOut.x, y: ep.stubOut.y };
pts[n - 1] = ep.inp;
pts[n - 2] = { x: ep.stubIn.x, y: ep.stubIn.y };
if (n >= 5) {
const a = pts[1], b = pts[2];
if (Math.abs(oldPts[1].y - oldPts[2].y) < 0.6) b.y = a.y;
else b.x = a.x;
const y = pts[n - 2], z = pts[n - 3];
if (Math.abs(oldPts[n - 2].y - oldPts[n - 3].y) < 0.6) z.y = y.y;
else z.x = y.x;
}
for (let k = 0; k < pts.length - 1; k++) {
if (Math.abs(pts[k].x - pts[k + 1].x) > 0.6 && Math.abs(pts[k].y - pts[k + 1].y) > 0.6)
return null;
}
if (pathHitsRects(pts.slice(1, -1), M.router.raw || [])) return null;
return pts;
}
// --- cache + precompute ---
export function setCachedPath(cached, pts) {
cached.pts = pts;
const segs = [];
let total = 0;
for (let i = 0; i < pts.length - 1; i++) {
const dx = pts[i + 1].x - pts[i].x,
dy = pts[i + 1].y - pts[i].y;
const len = Math.abs(dx) + Math.abs(dy);
segs.push({ a: pts[i], dx, dy, len, start: total, ang: Math.atan2(dy, dx) });
total += len;
}
cached.segs = segs;
cached.total = total;
}
// --- main routing entry point ---
export function routeAll(graph) {
const { entries, ioUnresolved } = collectLinks(graph);
// Subgraph boundary links we can't resolve (frontend changed its
// virtual-node structure?) — fall back fully so those links stay
// visible via the official renderer. Plain stale/floating links are
// already skipped in collectLinks and never trigger this.
if (ioUnresolved > 0) return null;
const dirty = refreshGraph(graph, entries);
const dragging = M.settleTimer !== null;
const results = [];
for (const e of entries) {
const ep = endpoints(e);
if (!ep) {
// subgraph IO link whose slot coords are unavailable -> full
// fallback; regular link with broken coords -> just skip it
const io =
e.link.origin_id === SUBGRAPH_INPUT_ID ||
e.link.target_id === SUBGRAPH_OUTPUT_ID;
if (io) return null;
continue;
}
const endsKey =
(ep.out.x | 0) + "," + (ep.out.y | 0) + "|" + (ep.inp.x | 0) + "," + (ep.inp.y | 0);
let cached = M.pathCache.get(e.link.id);
const endsMoved = !cached || cached.ends !== endsKey;
const hitDirty = dirty && cached && cached.pts && pathHitsRects(cached.pts, dirty);
if (endsMoved || hitDirty) {
let pts = null, sticky = false;
if (M.S.stickiness && dragging && cached && cached.pts) {
pts = stretchPath(cached.pts, ep);
sticky = !!pts;
}
if (!pts) {
const mid = M.router.route(ep.stubOut, DIR.E, ep.stubIn, DIR.E);
pts = mid ? [ep.out, ...mid, ep.inp] : fallbackPath(ep);
}
cached = { ends: endsKey, sticky };
setCachedPath(cached, pts);
M.pathCache.set(e.link.id, cached);
}
results.push({ entry: e, cached });
}
if (M.pathCache.size > entries.length) {
const alive = new Set(entries.map((e) => e.link.id));
for (const id of M.pathCache.keys()) if (!alive.has(id)) M.pathCache.delete(id);
}
return results;
}
function fallbackPath(ep) {
const mx = (ep.stubOut.x + ep.stubIn.x) / 2;
return [
ep.out,
ep.stubOut,
{ x: mx, y: ep.stubOut.y },
{ x: mx, y: ep.stubIn.y },
ep.stubIn,
ep.inp,
];
}