v1.4.2 — fix drag hysteresis body-cross rect shape

Convert nodeRect {x,y,w,h} to {x,y,x2,y2} in dragPathStillClear so
corridor stickiness keeps anti-flicker while live avoidance fires when
a node body actually hits a link. Add contract tests; ignore local 進度.md.
This commit is contained in:
90-RED
2026-07-30 09:01:27 +08:00
parent 3ee77b9d33
commit eaa2a81586
5 changed files with 66 additions and 3 deletions
+4
View File
@@ -3,3 +3,7 @@ __pycache__/
*.pyo
.DS_Store
Thumbs.db
# Local agent notes (keep out of GitHub)
進度.md
**/進度.md
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-linkrouter"
version = "1.4.1"
version = "1.4.2"
description = "Object-avoiding orthogonal link routing for ComfyUI — links automatically detour around nodes instead of passing through them."
license = "Apache-2.0"
readme = "README.md"
+43
View File
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import {
DIR,
OrthoRouter,
nodeBodyRectFromWH,
pathCrossesRects,
stretchedPathCrossesUnexpectedNode,
} from "../web/router.js";
@@ -37,6 +38,48 @@ test("sticky drag paths allow only their own endpoint bodies", () => {
assert.equal(stretchedPathCrossesUnexpectedNode(throughThirdNode, rects, 0, 2), true);
});
// Contract test for the drag-hysteresis call site (routing.js dragPathStillClear):
// nodeRect yields {x, y, w, h}; the crossing check reads x2/y2. The conversion
// must happen at the call site or the check silently passes everything.
test("nodeBodyRectFromWH converts nodeRect shape to x2/y2 crossing rects", () => {
const body = nodeBodyRectFromWH({ x: 450, y: 100, w: 200, h: 100 });
assert.deepEqual(body, { x: 450, y: 100, x2: 650, y2: 200 });
// Non-finite inputs degrade to finite zeros instead of poisoning comparisons.
const fallback = nodeBodyRectFromWH({ x: NaN, y: undefined, w: null, h: Infinity });
assert.ok(
Number.isFinite(fallback.x) && Number.isFinite(fallback.y) &&
Number.isFinite(fallback.x2) && Number.isFinite(fallback.y2),
);
});
test("drag hysteresis crossing check requires the x2/y2 rect shape", () => {
// The dragged node body sits right on the link's horizontal segment.
const pts = [
{ x: 300, y: 130 },
{ x: 316, y: 130 },
{ x: 884, y: 130 },
{ x: 900, y: 130 },
];
const source = { x: 100, y: 100, w: 200, h: 100 };
const target = { x: 900, y: 100, w: 200, h: 100 };
const draggedWH = { x: 450, y: 100, w: 200, h: 100 };
const withConversion = [source, target, draggedWH].map(nodeBodyRectFromWH);
// Body crosses the path → hysteresis must report "not clear" (force re-route).
assert.equal(stretchedPathCrossesUnexpectedNode(pts, withConversion, 0, 1), true);
// Regression lock: the raw w/h shape misses the very same crossing —
// this is exactly what made live avoidance never fire.
const rawShape = [source, target, draggedWH].map((r) => ({ ...r }));
assert.equal(stretchedPathCrossesUnexpectedNode(pts, rawShape, 0, 1), false);
// Margin-zone proximity without body contact stays "clear" (anti-flicker).
const nearMiss = [source, target, { x: 450, y: 240, w: 200, h: 100 }].map(
nodeBodyRectFromWH,
);
assert.equal(stretchedPathCrossesUnexpectedNode(pts, nearMiss, 0, 1), false);
});
test("A* treats every node body as a hard obstacle", () => {
const rects = [
{ x: 0, y: 0, w: 100, h: 100 },
+12
View File
@@ -862,6 +862,18 @@ function dedupePoints(pts) {
return out;
}
// Convert a nodeRect-shaped rect ({x, y, w, h}) to the {x, y, x2, y2} shape
// segmentCrossesRect consumes. Feeding the w/h shape in directly makes every
// x2/y2 comparison NaN-false, silently disabling the crossing check.
export function nodeBodyRectFromWH(r) {
const f = (v) => (Number.isFinite(+v) ? +v : 0);
const x = f(r.x),
y = f(r.y),
w = f(r.w),
h = f(r.h);
return { x, y, x2: x + w, y2: y + h };
}
// True only when an orthogonal segment enters a rectangle's open interior.
// Travelling exactly along a node edge remains legal for endpoint escapes.
function segmentCrossesRect(a, b, r) {
+6 -2
View File
@@ -6,7 +6,7 @@
import { app } from "../../scripts/app.js";
import { M } from "./state.js";
import { stretchedPathCrossesUnexpectedNode } from "./router.js";
import { nodeBodyRectFromWH, stretchedPathCrossesUnexpectedNode } from "./router.js";
import { stretchPathPure } from "./stretch.js";
import { profiler } from "./profiler.js";
import {
@@ -1531,7 +1531,11 @@ export function routeAll(graph) {
const dragPathStillClear = (entry, pts) => {
if (!hysteresisRects) {
hysteresisNodes = graph._nodes || [];
hysteresisRects = hysteresisNodes.map((n) => cachedNodeRect(n, rectCache));
// nodeRect is {x, y, w, h}; the crossing check reads x2/y2 — convert,
// or the comparison goes NaN-false and every path counts as "clear".
hysteresisRects = hysteresisNodes.map((n) =>
nodeBodyRectFromWH(cachedNodeRect(n, rectCache)),
);
}
return !stretchedPathCrossesUnexpectedNode(
pts,