v1.4.3 — faster progressive reveal + fix links vanishing after drag

Worker prewarm, viewport-first stable batches, and adaptive reveal pacing
(default on, clamp 6–14ms). On drag settle force OVG rebuild and never drop
the last legal path when route/stretch fail.
This commit is contained in:
90-RED
2026-07-30 10:44:53 +08:00
parent eaa2a81586
commit 734ce4173f
13 changed files with 358 additions and 17 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-linkrouter"
version = "1.4.2"
version = "1.4.3"
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"
+37
View File
@@ -3,8 +3,10 @@ import assert from "node:assert/strict";
import {
orderHeldRouteCandidates,
orderRouteCandidates,
pathBounds,
pauseRevealCount,
shouldForceGraphRebuildAfterSettle,
shouldFreezeHiddenModeLink,
shouldQueueIdleCleanup,
shouldRacePauseLink,
@@ -34,6 +36,24 @@ test("held reroutes prioritize self, viewport, then expand outward", () => {
});
});
test("stable route candidates prioritize viewport then distance", () => {
const viewport = { x: 0, y: 0, x2: 100, y2: 100 };
const ordered = orderRouteCandidates(
[
{ id: "far", bounds: { x: 300, y: 0, x2: 320, y2: 20 } },
{ id: "visible-b", bounds: { x: 50, y: 50, x2: 70, y2: 70 } },
{ id: "visible-a", bounds: { x: 10, y: 10, x2: 30, y2: 30 } },
{ id: "near", bounds: { x: 120, y: 0, x2: 140, y2: 20 } },
],
viewport,
);
// visible first (stable original index among visibles), then near, then far
assert.deepEqual(
ordered.map((item) => item.id),
["visible-b", "visible-a", "near", "far"],
);
});
test("a held direct link bypasses the unrelated-link freeze branch", () => {
assert.equal(shouldFreezeHiddenModeLink(true, true, false), false);
assert.equal(shouldFreezeHiddenModeLink(false, true, false), true);
@@ -151,3 +171,20 @@ test("held-pause race only takes predicted-cheap links", () => {
assert.equal(shouldRacePauseLink(undefined, Infinity, 10), false);
assert.equal(shouldRacePauseLink(3, Infinity, 10), true);
});
// Regression guard for "line vanishes after drag release" (profiler capture
// LinkRouter_20260730-094654.json: settle frame had routeFailures=7 with
// aStarPops=0 — layoutSignature truncates node coords with x|0, so a drag
// ending at a sub-pixel offset kept the stale OVG and every endpoint lookup
// missed). After a settle that invalidated any path, the next refreshGraph
// must rebuild the graph even when the signature is unchanged. Profiler
// regression condition: the first post-settle route frame must show
// graphRebuilt=true and must NOT show routeFailures>0 together with
// aStarPops=0; failed links must keep their last legal pts on screen
// (prefer a wrong-looking path over a vanishing link).
test("settle with stale paths forces a graph rebuild", () => {
assert.equal(shouldForceGraphRebuildAfterSettle(true), true);
// Nothing invalidated → keep the cheap signature comparison.
assert.equal(shouldForceGraphRebuildAfterSettle(false), false);
assert.equal(shouldForceGraphRebuildAfterSettle(undefined), false);
});
+66
View File
@@ -2,11 +2,13 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
adaptiveBudgetMs,
orderedRouteResults,
processRouteSlice,
progressiveItemLimit,
shouldProgressivelyRoute,
} from "../web/progressive.js";
import { orderRouteCandidates } from "../web/drag-policy.js";
const entry = (id) => ({ link: { id } });
@@ -55,3 +57,67 @@ test("a route slice yields after its time budget", () => {
);
assert.deepEqual(slice, { processed: 2, done: false, remaining: 2 });
});
test("adaptiveBudgetMs clamps to [6, 14] and falls back without measures", () => {
// light draw → headroom hits the 14ms cap
assert.equal(
adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: 0.5 }),
14,
);
// heavy draw → floor at 6ms (never starves to 0)
assert.equal(
adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: 20 }),
6,
);
// mid value passes through
assert.ok(
Math.abs(adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: 4 }) - 10.6) < 1e-9,
);
// invalid / missing measures → fixed fallback 12
assert.equal(adaptiveBudgetMs({ frameIntervalMs: NaN, drawMsP95: 2 }), 12);
assert.equal(adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: NaN }), 12);
assert.equal(
adaptiveBudgetMs({ frameIntervalMs: 0, drawMsP95: 1, fallbackMs: 12 }),
12,
);
// never exceeds maxMs even on 30fps headroom
assert.equal(
adaptiveBudgetMs({ frameIntervalMs: 33, drawMsP95: 2, maxMs: 14 }),
14,
);
});
test("viewport job reorder does not change orderedRouteResults id sequence", () => {
const entries = [entry(1), entry(2), entry(3), entry(4)];
const viewport = { x: 0, y: 0, x2: 100, y2: 100 };
// Jobs intentionally listed off-screen first; after viewport order, on-screen
// jobs are processed first, but results still emit in entry order.
const jobsRaw = [
{ entry: entries[2], bounds: { x: 500, y: 0, x2: 520, y2: 20 } },
{ entry: entries[0], bounds: { x: 10, y: 10, x2: 30, y2: 30 } },
{ entry: entries[3], bounds: { x: 200, y: 0, x2: 220, y2: 20 } },
{ entry: entries[1], bounds: { x: 40, y: 40, x2: 60, y2: 60 } },
];
const orderedJobs = orderRouteCandidates(jobsRaw, viewport);
// Visible first (ids 1,2), then near off-screen (4), then far (3).
assert.deepEqual(
orderedJobs.map((j) => j.entry.link.id),
[1, 2, 4, 3],
);
const batch = {
entries,
jobs: orderedJobs,
index: 0,
resultsById: new Map(),
};
processRouteSlice(batch, (job) => ({ entry: job.entry, cached: { pts: [] } }), {
maxItems: 99,
budgetMs: Infinity,
now: () => 0,
});
// Final emit order follows entries, not job processing order.
assert.deepEqual(
orderedRouteResults(entries, batch.resultsById).map((r) => r.entry.link.id),
[1, 2, 3, 4],
);
});
+16
View File
@@ -2,6 +2,8 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
WARMUP_JOB_REV,
buildWorkerWarmupPayload,
watchdogTimeoutAction,
workerErrorAction,
} from "../web/worker-policy.js";
@@ -79,3 +81,17 @@ test("no live batch at timeout: nothing to do", () => {
"ignore",
);
});
test("worker warmup payload uses reserved negative jobRev and tiny jobs", () => {
assert.equal(WARMUP_JOB_REV, -1);
assert.ok(WARMUP_JOB_REV < 0);
const payload = buildWorkerWarmupPayload();
assert.equal(payload.type, "route");
assert.equal(payload.jobRev, WARMUP_JOB_REV);
assert.ok(payload.jobs.length >= 1 && payload.jobs.length <= 2);
assert.equal(payload.rects.length % 4, 0);
assert.ok(payload.rects.length / 4 >= 2);
// Warmup errors must not kill the worker: rev never matches live (≥0).
assert.equal(workerErrorAction(WARMUP_JOB_REV, 0, 0), "ignore");
assert.equal(workerErrorAction(WARMUP_JOB_REV, 5, 2), "ignore");
});
+34 -7
View File
@@ -44,6 +44,15 @@ export function shouldQueueIdleCleanup(effectiveMode, affected, primary, hasCach
);
}
// After a drag-settle that invalidated paths, the next frame must rebuild
// the OVG even when layoutSignature is unchanged: the signature truncates
// node coords (x|0), so a drag ending at a sub-pixel offset keeps the old
// graph while the fractional endpoints no longer match any terminal (idxOf
// misses, A* pops 0, every route fails and the link vanishes).
export function shouldForceGraphRebuildAfterSettle(hadStale) {
return !!hadStale;
}
export function pathBounds(points) {
if (!points?.length) return null;
let x = Infinity, y = Infinity, x2 = -Infinity, y2 = -Infinity;
@@ -97,16 +106,19 @@ export function shouldRacePauseLink(costMs, fallbackMs, maxMs) {
return (costMs === undefined ? fallbackMs : costMs) < maxMs;
}
// Freeze this order when a held pause begins: direct links first, then links
// visible in the current viewport, then off-screen links expanding outward.
export function orderHeldRouteCandidates(candidates, viewport) {
// Shared viewport-first ordering: candidates are bucketed by priority tier,
// then by distance from the viewport, with the original index as the final
// tiebreak (stable within a tier). directOf marks candidates that always
// sort first (held-pause direct links); stable batches pass none.
function orderByViewportPriority(candidates, viewport, directOf) {
return candidates
.map((candidate, index) => {
const direct = directOf(candidate);
const visible = intersects(candidate.bounds, viewport);
return {
...candidate,
_priority: candidate.direct ? 0 : visible ? 1 : 2,
_distance: candidate.direct ? 0 : distanceFromViewport(candidate.bounds, viewport),
candidate,
_priority: direct ? 0 : visible ? 1 : 2,
_distance: direct ? 0 : distanceFromViewport(candidate.bounds, viewport),
_index: index,
};
})
@@ -115,5 +127,20 @@ export function orderHeldRouteCandidates(candidates, viewport) {
a._distance - b._distance ||
a._index - b._index
)
.map(({ _priority, _distance, _index, ...candidate }) => candidate);
.map((item) => item.candidate);
}
// Freeze this order when a held pause begins: direct links first, then links
// visible in the current viewport, then off-screen links expanding outward.
export function orderHeldRouteCandidates(candidates, viewport) {
return orderByViewportPriority(candidates, viewport, (c) => c.direct);
}
// Stable (non-drag) route jobs: links visible in the current viewport first,
// then off-screen links expanding outward, so progressive slices and worker
// results appear where the user is looking. Final results are rebuilt from
// the entries order (orderedRouteResults), so reordering jobs never changes
// the emitted id sequence.
export function orderRouteCandidates(candidates, viewport) {
return orderByViewportPriority(candidates, viewport, () => false);
}
+21
View File
@@ -467,6 +467,13 @@ function hoverNodeId(canvas) {
// --- main draw hook ---
// Exponential moving average that ignores invalid samples and seeds itself
// from the first valid one (NaN until then).
function emaSample(prev, sample, alpha = 0.2) {
if (!Number.isFinite(sample) || sample < 0) return prev;
return Number.isFinite(prev) ? prev + alpha * (sample - prev) : sample;
}
// The routing failsafe disables the plugin for the rest of the session.
// Tell the user visibly (frontend toast when available) and flag the
// floating-bar toggle until the plugin is re-enabled — a silent console.warn
@@ -525,6 +532,9 @@ export function drawAll(canvas, ctx) {
profiler.endFrame(profileFrame, { fallback: true });
return false;
}
// Routing is done for this frame; the remaining drawAll work is the pure
// draw cost that the adaptive progressive budget subtracts from the frame.
const routeEndedAt = performance.now();
const selRaw = canvas.selected_nodes;
// Frontend 1.47 made NodeId a runtime string ("12"); older builds use
@@ -728,5 +738,16 @@ export function drawAll(canvas, ctx) {
else clearOverlay();
}
profiler.endFrame(profileFrame, { links: routed.length, strokeCalls, batched: !!batches });
// Frame pacing EMAs for the adaptive progressive budget (routing.js):
// updated every frame, independent of the opt-in profiler. Draw time is
// measured after routeAll so the routing slice itself is not double
// counted as draw cost.
const frameEndedAt = performance.now();
M._drawMsEma = emaSample(M._drawMsEma, frameEndedAt - routeEndedAt);
M._frameIntervalMsEma = emaSample(
M._frameIntervalMsEma,
M._lastFrameEndedAt ? frameEndedAt - M._lastFrameEndedAt : NaN,
);
M._lastFrameEndedAt = frameEndedAt;
return true;
}
+27
View File
@@ -13,6 +13,33 @@ export function progressiveItemLimit(total, percent) {
return Math.max(1, Math.ceil(total * pct / 100));
}
// Spare-time budget for one progressive reveal slice: the frame interval
// minus the measured draw cost and a safety margin, clamped to [minMs, maxMs]
// so a slice never starves the frame (min) nor blows past it (max) — the
// no-dropped-frames constraint is structural, not tuned. Without a valid
// draw/frame measurement the fixed fallback applies (first frames behave
// exactly like the old fixed budget).
export function adaptiveBudgetMs({
frameIntervalMs,
drawMsP95,
minMs = 6,
maxMs = 14,
safetyMs = 2,
fallbackMs = 12,
}) {
if (
!Number.isFinite(frameIntervalMs) ||
frameIntervalMs <= 0 ||
!Number.isFinite(drawMsP95) ||
drawMsP95 < 0
)
return fallbackMs;
return Math.min(
maxMs,
Math.max(minMs, frameIntervalMs - drawMsP95 - safetyMs),
);
}
export function processRouteSlice(batch, routeOne, options = {}) {
const now = options.now || (() => performance.now());
const maxItems = options.maxItems ?? Infinity;
+76 -7
View File
@@ -11,8 +11,10 @@ import { stretchPathPure } from "./stretch.js";
import { profiler } from "./profiler.js";
import {
orderHeldRouteCandidates,
orderRouteCandidates,
pathBounds,
pauseRevealCount,
shouldForceGraphRebuildAfterSettle,
shouldFreezeHiddenModeLink,
shouldQueueIdleCleanup,
shouldRacePauseLink,
@@ -30,6 +32,7 @@ import {
updateRouteCost,
} from "./adaptive-policy.js";
import {
adaptiveBudgetMs,
orderedRouteResults,
processRouteSlice,
progressiveItemLimit,
@@ -469,6 +472,15 @@ function refreshGraph(graph, linkEntries, rectCache, deferBuild = false) {
if (hadStale) {
M.routeFastSig = "";
}
if (shouldForceGraphRebuildAfterSettle(hadStale)) {
// layoutSignature truncates coords (x|0), so the final sub-pixel
// position may leave graphSig unchanged while the OVG terminals no
// longer match the fractional endpoints (idxOf misses → A* pops 0 →
// every route fails). Force the next refreshGraph to rebuild with
// the current endpoints; deferred worker builds stay deferred and
// are forced fresh by ensureMainRouterFresh when routing needs it.
M.graphSig = "";
}
// Redraw even when all routes remain valid: unrelated selected links
// switch from drag opacity back to the normal selected opacity.
app.canvas?.setDirty(false, true);
@@ -518,6 +530,20 @@ function predictedRouteCost(linkIds) {
const PROGRESSIVE_BUDGET_MS = 12;
// Per-slice budget for main-thread progressive reveal. Adaptive (default):
// spare frame time after drawing, clamped to [6, 14]ms by adaptiveBudgetMs;
// the draw/frame EMAs come from draw.js every frame, so this works without
// the opt-in profiler. Setting off: the fixed 12ms budget. Worker batches
// are message-driven and never read this.
function progressiveBudgetMs() {
if (M.S.adaptiveRevealPacing === false) return PROGRESSIVE_BUDGET_MS;
return adaptiveBudgetMs({
frameIntervalMs: M._frameIntervalMsEma,
drawMsP95: M._drawMsEma,
fallbackMs: PROGRESSIVE_BUDGET_MS,
});
}
function cancelProgressiveBatch() {
clearWorkerRescue();
if (M.routeBatch?.worker) cancelWorkerBatch();
@@ -863,6 +889,13 @@ initWorkerClient({
// The worker already attempted its own sticky stretch before failing.
batch.routeFailures++;
rememberRouteFailure(msg.id, job.endsKey, job.ep);
// Prefer a wrong-looking path over a vanishing link: keep the last
// legal pts on screen (old ends key retained so a later frame still
// re-routes after the failure backoff).
const prev = M.pathCache.get(msg.id);
if (prev?.pts) {
batch.resultsById.set(msg.id, { entry: job.entry, cached: prev });
}
}
scheduleProgressiveBatch(); // throttled repaint with the new partial set
armWorkerRescue(batch); // progress: push the rescue deadline out
@@ -1034,16 +1067,20 @@ function executeRouteJob(batch, job) {
// Degrade gracefully: keep the last legal path on screen, stretched
// to the current endpoints, instead of making the link vanish.
// stretchPath validates that no unexpected node body is crossed.
const stickyPts = stretchPath(
M.pathCache.get(job.entry.link.id)?.pts,
job.ep,
);
const prev = M.pathCache.get(job.entry.link.id);
const stickyPts = stretchPath(prev?.pts, job.ep);
if (stickyPts) {
const sticky = { ends: job.endsKey, sticky: true };
setCachedPath(sticky, stickyPts);
M.pathCache.set(job.entry.link.id, sticky);
return { result: { entry: job.entry, cached: sticky }, elapsed };
}
// Last resort: prefer a wrong-looking path over a vanishing link. Emit
// the previous legal pts untouched, keeping their old ends key so the
// link is still re-routed once the failure backoff elapses.
if (prev?.pts) {
return { result: { entry: job.entry, cached: prev }, elapsed };
}
return { result: null, elapsed };
}
M.failedRoutes.delete(job.entry.link.id);
@@ -1070,7 +1107,7 @@ function continueStableRoutes(batch, profileStarted) {
: Infinity;
const slice = processRouteSlice(batch, routeOne, {
maxItems,
budgetMs: batch.progressive ? PROGRESSIVE_BUDGET_MS : Infinity,
budgetMs: batch.progressive ? progressiveBudgetMs() : Infinity,
});
const results = orderedRouteResults(batch.entries, batch.resultsById);
const profile = batch.firstFrame ? batch.profile : { graphRebuilt: false };
@@ -1151,6 +1188,9 @@ function prepareStableRoutes(graph, fastSig, entries, rectCache, dirty, profileS
setCachedPath(sticky, stickyPts);
M.pathCache.set(entry.link.id, sticky);
resultsById.set(entry.link.id, { entry, cached: sticky });
} else if (cached?.pts) {
// Stretch failed too: prefer the stale path over a vanishing link.
resultsById.set(entry.link.id, { entry, cached });
}
continue;
}
@@ -1158,6 +1198,25 @@ function prepareStableRoutes(graph, fastSig, entries, rectCache, dirty, profileS
} else if (cached?.pts) resultsById.set(entry.link.id, { entry, cached });
}
// Viewport-first job order: progressive slices and streamed worker results
// appear where the user is looking first. Bounds come from the cached path
// when present, else the endpoint bbox. orderedRouteResults rebuilds the
// output from entries, so reordering jobs never changes the emitted order.
if (jobs.length > 1) {
const viewport = currentViewportRect();
const ordered = orderRouteCandidates(
jobs.map((job) => ({
job,
bounds:
pathBounds(M.pathCache.get(job.entry.link.id)?.pts) ||
failureBounds(job.ep),
})),
viewport,
);
jobs.length = 0;
for (const item of ordered) jobs.push(item.job);
}
const progressive = shouldProgressivelyRoute(false, forceSync, jobs.length);
const batch = {
graph,
@@ -1689,7 +1748,10 @@ export function routeAll(graph) {
!pauseRoute &&
shouldSkipFailedRoute(e.link.id, endsKey, collisionRects || dirty._rects)
) {
finishPauseRoute(false);
// Backoff means "do not re-route yet", not "show nothing": keep the
// last legal (stale) path on screen if we have one.
finishPauseRoute(!!cached?.pts, cached);
if (cached?.pts) results.push({ entry: e, cached });
continue;
}
reroutedLinks++;
@@ -1733,7 +1795,14 @@ export function routeAll(graph) {
// Never replace that with a fallback which cuts through a node.
if (!pts) {
rememberRouteFailure(e.link.id, endsKey, ep);
finishPauseRoute(false);
if (cached?.pts) {
// Prefer the last legal (now stale) path over a vanishing link;
// endsMoved stays true so routing retries on the next frames.
finishPauseRoute(true, cached);
results.push({ entry: e, cached });
} else {
finishPauseRoute(false);
}
continue;
}
cached = { ends: endsKey, sticky };
+7 -1
View File
@@ -200,11 +200,17 @@ const SETTINGS = {
],
routeBatchPercent: [
"LinkRouter.Routing.RouteBatchPercent",
"Gradual reveal: % of links per frame (lower = smoother, higher = faster)",
"Gradual reveal: % of links per frame (main-thread fallback, when background worker is off)",
"slider",
10,
{ min: 2, max: 100, step: 1 },
],
adaptiveRevealPacing: [
"LinkRouter.Routing.AdaptiveRevealPacing",
"Adaptive reveal pacing (fit each reveal batch into spare frame time, no dropped frames)",
"boolean",
true,
],
workerRouting: [
"LinkRouter.Routing.WorkerRouting",
"Background worker routing (large batches off the main thread)",
+5
View File
@@ -13,6 +13,7 @@ import { registerSettings, setRefreshBar } from "./settings.js";
import { drawAll } from "./draw.js";
import { buildUI, refreshBar, watchHover, linksHidden } from "./ui.js";
import { cancelDragPauseWorker } from "./routing.js";
import { prewarmWorker } from "./worker-client.js";
// Wire the refresh-bar callback (settings.js => ui.js without circular import)
setRefreshBar(refreshBar);
@@ -44,6 +45,10 @@ app.registerExtension({
buildUI();
watchHover();
watchExecution();
// Settings are loaded by now: spin up the background worker and warm its
// router JIT with a tiny synthetic batch (no-op when worker routing is
// off/unavailable). See prewarmWorker in worker-client.js.
prewarmWorker();
// Capture-phase tracking still sees gestures captured by Nodes 2.0.
const beginPointer = () => {
// A new gesture orphans the previous gesture's in-flight pause batch:
+5
View File
@@ -62,6 +62,11 @@ export const M = {
routeCostByLink: new Map(),
routeCostByLinkMT: new Map(), // main-thread-measured costs only (pause race gate)
routeCostAverage: NaN,
// Frame pacing EMAs (draw.js): feed the adaptive progressive slice budget.
// NaN until measured — the budget helper falls back to the fixed 12ms then.
_drawMsEma: NaN,
_frameIntervalMsEma: NaN,
_lastFrameEndedAt: 0,
prevRects: new Map(),
pathCache: new Map(), // linkId -> {ends, pts, sticky, segs, total}
failedRoutes: new Map(), // linkId -> {ends, fails, retryAt, bounds}
+28 -1
View File
@@ -11,7 +11,11 @@
// never needs to import routing.js (no import cycle).
import { M } from "./state.js";
import { watchdogTimeoutAction, workerErrorAction } from "./worker-policy.js";
import {
buildWorkerWarmupPayload,
watchdogTimeoutAction,
workerErrorAction,
} from "./worker-policy.js";
const WATCHDOG_MS = 3000;
@@ -128,3 +132,26 @@ export function cancelWorkerBatch() {
worker?.postMessage({ type: "cancel" });
} catch {}
}
// Prewarm the worker at startup: construct it (async module fetch/compile)
// and post a tiny synthetic batch so the OrthoRouter JIT is warm before the
// first real batch. Warmup messages carry the reserved negative WARMUP_JOB_REV
// (worker-policy.js), so handleMessage drops them via the normal stale-rev
// check — the watchdog is never armed and pathCache is never touched.
// Skipped when a real batch may already be in flight: the worker's
// newest-route-wins pump would otherwise supersede it.
export function prewarmWorker() {
if (!workerRoutingUsable()) return false;
if (M.routeBatch?.worker || M._dragPauseWorker) return false;
const w = ensureWorker();
if (!w) return false;
try {
w.postMessage(buildWorkerWarmupPayload());
return true;
} catch (e) {
// A failed prewarm must not failWorker (that would disable worker routing
// for the whole session); the first real dispatch retries anyway.
console.warn("[LinkRouter] worker prewarm skipped:", e?.message || e);
return false;
}
}
+35
View File
@@ -38,3 +38,38 @@ export function watchdogTimeoutAction({
if (pauseWorkerBatch) return "drop-pause";
return "ignore";
}
// Worker prewarm (Phase D1). A tiny synthetic batch is posted right after
// worker creation so the module load and the OrthoRouter JIT/build path are
// warm before the first real batch. It uses a reserved NEGATIVE jobRev:
// real revs start at 0 and only increase (dispatch/cancel/resetRouter), so
// the existing stale-rev drop in handleMessage discards every warmup
// result/done/error — no watchdog, no failWorker, no pathCache writes, and
// no risk of staling a user batch.
export const WARMUP_JOB_REV = -1;
// Two nodes with a clear straight corridor, one exact (weight 1) job pair —
// the same code path a real stable batch exercises. Kept dependency-free so
// node tests can feed it straight into router-worker-core's engine.
export function buildWorkerWarmupPayload(margin = 16, bendPenalty = 40) {
const pts = [
100, 50, // out
100, 50, // bodyOut
116, 50, // stubOut
384, 50, // stubIn
400, 50, // bodyIn
400, 50, // inp
];
const job = (id) => ({ id, endsKey: "warmup", opts: null, oldPts: null, pts });
return {
type: "route",
jobRev: WARMUP_JOB_REV,
graphRev: "warmup",
configKey: "warmup",
margin,
bendPenalty,
rects: new Float64Array([0, 0, 100, 100, 400, 0, 100, 100]),
terminals: new Float64Array([116, 50, 384, 50, 100, 50, 400, 50]),
jobs: [job(1), job(2)],
};
}