@worca/app 1.0.0 → 1.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// src/shared/graph/ports.mjs
|
|
2
|
+
// Port resolution for the v2 graph model — the ONE place that answers "what
|
|
3
|
+
// ports does this node have?" for the engine, the validator, the composer and
|
|
4
|
+
// the run monitor. Pure: no IO, no state, and NOTHING here throws on a
|
|
5
|
+
// malformed template — an unknown agent key (V4), a dangling endpoint (V5) or
|
|
6
|
+
// an unknown kind (V3) is an error for the validator to COLLECT, so every
|
|
7
|
+
// lookup guards instead of crashing.
|
|
8
|
+
import { AWAIT_PORT, FLOW_KINDS, gatePorts, TASK_PORTS, END_PORTS } from './constants.mjs';
|
|
9
|
+
import { hasBlocking } from './verdict.mjs';
|
|
10
|
+
|
|
11
|
+
/** Engine flow-card ports. `undefined` for an unknown kind — V3's error. */
|
|
12
|
+
export function flowPorts(node) {
|
|
13
|
+
const kind = node?.kind;
|
|
14
|
+
if (!FLOW_KINDS.includes(kind)) return undefined; // FLOW_KINDS is a frozen ARRAY (P1)
|
|
15
|
+
if (kind === 'task') return { known: true, ported: true, inputs: [], outputs: [...TASK_PORTS.outputs] };
|
|
16
|
+
if (kind === 'end') return { known: true, ported: true, inputs: [...END_PORTS.inputs], outputs: [] };
|
|
17
|
+
if (kind === 'and' || kind === 'or' || kind === 'combine') {
|
|
18
|
+
const arity = Number.isInteger(node?.config?.arity) ? node.config.arity : 2;
|
|
19
|
+
const { inputs, outputs } = gatePorts(kind, Math.max(2, arity));
|
|
20
|
+
return { known: true, ported: true, inputs, outputs };
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the ports function over the merged agent registry (an object or a Map
|
|
27
|
+
* keyed by agent key). Agent nodes get their sidecar's typed ports PLUS the
|
|
28
|
+
* engine-synthesized `await` gate appended LAST; flow cards get flowPorts.
|
|
29
|
+
* Three outcomes, and V4 tells them apart:
|
|
30
|
+
* unknown key -> undefined (known:false)
|
|
31
|
+
* key without v2 ports -> {known:true, ported:false}
|
|
32
|
+
* ported v2 sidecar -> {known:true, ported:true, inputs:[...meta, await]}
|
|
33
|
+
*/
|
|
34
|
+
export function portsFnFor(agentsByKey) {
|
|
35
|
+
const index = agentsByKey instanceof Map
|
|
36
|
+
? agentsByKey
|
|
37
|
+
: new Map(Object.entries(agentsByKey && typeof agentsByKey === 'object' ? agentsByKey : {}));
|
|
38
|
+
return (node) => {
|
|
39
|
+
if (!node || typeof node !== 'object') return undefined;
|
|
40
|
+
if (node.kind !== 'agent') return flowPorts(node);
|
|
41
|
+
const meta = index.get(node.key);
|
|
42
|
+
if (!meta) return undefined;
|
|
43
|
+
if (!Array.isArray(meta.inputs) || !Array.isArray(meta.outputs)) {
|
|
44
|
+
return { ...meta, known: true, ported: false, inputs: [], outputs: [] };
|
|
45
|
+
}
|
|
46
|
+
return { ...meta, known: true, ported: true, inputs: [...meta.inputs, AWAIT_PORT], outputs: [...meta.outputs] };
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve one node's ports defensively. NEVER throws — a portsFn that throws,
|
|
52
|
+
* a missing key and an unknown kind all collapse to `known:false`, and a
|
|
53
|
+
* registry entry with no v2 ports reports `known:true, ported:false` so V4 can
|
|
54
|
+
* say "port its sidecar" instead of "unknown agent".
|
|
55
|
+
* @returns {{known:boolean, ported:boolean, inputs:Array, outputs:Array, meta:object|null}}
|
|
56
|
+
*/
|
|
57
|
+
export function portsOf(portsFn, node) {
|
|
58
|
+
let resolved = null;
|
|
59
|
+
try { resolved = typeof portsFn === 'function' ? portsFn(node) : null; } catch { resolved = null; }
|
|
60
|
+
if (!resolved || typeof resolved !== 'object') {
|
|
61
|
+
return { known: false, ported: false, inputs: [], outputs: [], meta: null };
|
|
62
|
+
}
|
|
63
|
+
const ported = resolved.ported !== false && Array.isArray(resolved.inputs);
|
|
64
|
+
return {
|
|
65
|
+
known: resolved.known !== false,
|
|
66
|
+
ported,
|
|
67
|
+
inputs: ported && Array.isArray(resolved.inputs) ? resolved.inputs : [],
|
|
68
|
+
outputs: ported && Array.isArray(resolved.outputs) ? resolved.outputs : [],
|
|
69
|
+
meta: resolved,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One port of a RESOLVED ports object (`portsFn(node)` or `portsOf(...)`). */
|
|
74
|
+
export function findPort(ports, portId, dir) {
|
|
75
|
+
const list = dir === 'out' ? ports?.outputs : ports?.inputs;
|
|
76
|
+
return (Array.isArray(list) ? list : []).find((p) => p?.id === portId) || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Wire legality by type: `any` inputs accept everything, otherwise equality.
|
|
80
|
+
* A null/undefined source type is UNRESOLVABLE (an or with no inbound source)
|
|
81
|
+
* and compatible by construction — the caller skips it. */
|
|
82
|
+
export function typeCompatible(outType, inType) {
|
|
83
|
+
if (outType === null || outType === undefined) return true;
|
|
84
|
+
return inType === 'any' || outType === inType;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The or card's payload type, resolved from its inbound wires' source outputs:
|
|
89
|
+
* the first resolvable one, walking inbound wires by `inK` index and then by
|
|
90
|
+
* wire id so the answer never depends on insertion order. Chained ors resolve
|
|
91
|
+
* THROUGH (seen-set guarded, so a cyclic or->or chain returns null instead of
|
|
92
|
+
* hanging). `null` = unresolvable (an unwired `inK` is already V12's error).
|
|
93
|
+
*/
|
|
94
|
+
export function resolveOrOutType(tpl, portsFn, orId, seen = new Set()) {
|
|
95
|
+
if (!orId || seen.has(orId)) return null;
|
|
96
|
+
seen.add(orId);
|
|
97
|
+
const nodes = Array.isArray(tpl?.nodes) ? tpl.nodes : [];
|
|
98
|
+
const byId = new Map(nodes.filter((n) => n && typeof n === 'object').map((n) => [n.id, n]));
|
|
99
|
+
const inbound = inboundWires(tpl, orId)
|
|
100
|
+
.sort((a, b) => portIndex(a.to.port) - portIndex(b.to.port) || compareIds(a.id, b.id));
|
|
101
|
+
for (const w of inbound) {
|
|
102
|
+
const src = byId.get(w?.from?.node);
|
|
103
|
+
if (!src) continue; // dangling endpoint — V5's error
|
|
104
|
+
const out = findPort(portsOf(portsFn, src), w.from.port, 'out');
|
|
105
|
+
if (!out) continue;
|
|
106
|
+
if (out.type && out.type !== 'any') return out.type;
|
|
107
|
+
if (src.kind === 'or') {
|
|
108
|
+
const through = resolveOrOutType(tpl, portsFn, src.id, seen);
|
|
109
|
+
if (through) return through;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Live-or-not, every wire whose `to` (resp. `from`) matches, in template order. */
|
|
116
|
+
export function inboundWires(tpl, nodeId, portId) {
|
|
117
|
+
return (Array.isArray(tpl?.wires) ? tpl.wires : []).filter((w) => w?.to?.node === nodeId
|
|
118
|
+
&& (portId === undefined || w?.to?.port === portId));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function outboundWires(tpl, nodeId, portId) {
|
|
122
|
+
return (Array.isArray(tpl?.wires) ? tpl.wires : []).filter((w) => w?.from?.node === nodeId
|
|
123
|
+
&& (portId === undefined || w?.from?.port === portId));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Conditional routing (base spec §2): every `when: 'always'` output plus
|
|
128
|
+
* EXACTLY one conditional side — `blocking` iff the verdict carries a critical
|
|
129
|
+
* or major issue, `clean` otherwise. Declared order is preserved and the PORT
|
|
130
|
+
* OBJECTS come back, because the executor needs filename/store/artifactKind.
|
|
131
|
+
* Accepts an outputs array or a resolved ports object.
|
|
132
|
+
*/
|
|
133
|
+
export function firedOutputs(ports, verdict) {
|
|
134
|
+
const declared = Array.isArray(ports) ? ports : (Array.isArray(ports?.outputs) ? ports.outputs : []);
|
|
135
|
+
const side = hasBlocking(verdict) ? 'blocking' : 'clean';
|
|
136
|
+
return declared.filter((o) => {
|
|
137
|
+
const when = o?.when || 'always';
|
|
138
|
+
return when === 'always' || when === side;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `in3` -> 3; anything else sorts last (deterministic, never NaN). */
|
|
143
|
+
function portIndex(port) {
|
|
144
|
+
const m = /^in(\d+)$/.exec(String(port ?? ''));
|
|
145
|
+
return m ? Number(m[1]) : Number.MAX_SAFE_INTEGER;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Total order on wire ids (undefined ids sort last, never NaN-compare). */
|
|
149
|
+
function compareIds(a, b) {
|
|
150
|
+
const x = String(a ?? '');
|
|
151
|
+
const y = String(b ?? '');
|
|
152
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
153
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// src/shared/graph/route.mjs
|
|
2
|
+
// THE wire router: one orthogonal, obstacle-avoiding path generator for every
|
|
3
|
+
// surface (composer canvas, run monitor, static host, thumbnail) and for the hit
|
|
4
|
+
// test. Pure, DOM-free and deterministic — same template in, byte-identical
|
|
5
|
+
// routes out — so the whole shape of a wire is unit-testable without jsdom and
|
|
6
|
+
// a repaint never measures.
|
|
7
|
+
//
|
|
8
|
+
// A route is an orthogonal polyline: the port anchor, a mandatory straight stub,
|
|
9
|
+
// an A*-found middle section over a sparse channel grid, the entry stub, the
|
|
10
|
+
// target anchor. Corners are rounded at paint time (routePathD), never in the
|
|
11
|
+
// model, so hit testing reads the same vertices the painter drew.
|
|
12
|
+
import { WIRE_HIT_TOL } from './geometry.mjs';
|
|
13
|
+
|
|
14
|
+
/** obstacle inflation: the minimum gap between a wire and a card edge. 24 keeps
|
|
15
|
+
* the mid-wire `N×` pill (18px tall, centred on the line → ±9px) clear of the
|
|
16
|
+
* ornaments overhanging a card's top edge (.nrun at top:-9px, .npip/.ngate at
|
|
17
|
+
* -7px) with 6px of air. */
|
|
18
|
+
export const ROUTE_CLEARANCE = 24;
|
|
19
|
+
/** straight exit/entry length at a port (> CLEARANCE, so stub tips clear their own card's inflated rect). */
|
|
20
|
+
export const ROUTE_STUB = 28;
|
|
21
|
+
/** corner rounding radius (clamped to half of the adjacent segments). */
|
|
22
|
+
export const ROUTE_RADIUS = 8;
|
|
23
|
+
/** separation between parallel wires sharing a channel. */
|
|
24
|
+
export const ROUTE_LANE_GAP = 7;
|
|
25
|
+
/** A* bend penalty, in px-equivalent. */
|
|
26
|
+
const TURN_COST = 40;
|
|
27
|
+
const EPS = 0.01;
|
|
28
|
+
|
|
29
|
+
// The direction priority is NORMATIVE, not cosmetic: it is the FINAL tie-break
|
|
30
|
+
// key, and it is what resolves a true above/below tie BELOW the cards (both goal
|
|
31
|
+
// entries agree on every positional key, so only the arrival direction is left —
|
|
32
|
+
// `up` means arriving from below and must outrank `down`).
|
|
33
|
+
const DIRS = ['right', 'up', 'left', 'down'];
|
|
34
|
+
const DIR_RANK = new Map(DIRS.map((d, i) => [d, i]));
|
|
35
|
+
const STEP = { right: [1, 0], up: [0, -1], left: [-1, 0], down: [0, 1] };
|
|
36
|
+
|
|
37
|
+
const inflate = (r, m) => ({ x: r.x - m, y: r.y - m, w: r.w + 2 * m, h: r.h + 2 * m });
|
|
38
|
+
const containsStrict = (r, p) =>
|
|
39
|
+
p.x > r.x + EPS && p.x < r.x + r.w - EPS && p.y > r.y + EPS && p.y < r.y + r.h - EPS;
|
|
40
|
+
|
|
41
|
+
/** True when the axis-aligned segment p→q overlaps a rect INTERIOR. Running
|
|
42
|
+
* exactly along an inflated boundary is legal — wires hug the clearance line. */
|
|
43
|
+
function segBlocked(p, q, rects) {
|
|
44
|
+
const x0 = Math.min(p.x, q.x); const x1 = Math.max(p.x, q.x);
|
|
45
|
+
const y0 = Math.min(p.y, q.y); const y1 = Math.max(p.y, q.y);
|
|
46
|
+
for (const r of rects) {
|
|
47
|
+
if (x1 < r.x + EPS || x0 > r.x + r.w - EPS) continue;
|
|
48
|
+
if (y1 < r.y + EPS || y0 > r.y + r.h - EPS) continue;
|
|
49
|
+
return true; // axis-aligned segment overlaps the rect interior in both axes
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Candidate coordinates on one axis: the given values (rect edges + stub tips),
|
|
55
|
+
* deduped and sorted, plus the midline between each adjacent pair. */
|
|
56
|
+
function axisCoords(values) {
|
|
57
|
+
const uniq = [...new Set(values)].sort((p, q) => p - q);
|
|
58
|
+
const out = [];
|
|
59
|
+
for (let i = 0; i < uniq.length; i += 1) {
|
|
60
|
+
out.push(uniq[i]);
|
|
61
|
+
if (i + 1 < uniq.length) out.push((uniq[i] + uniq[i + 1]) / 2);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A binary min-heap over the fully-ordered comparator below. */
|
|
67
|
+
function createHeap(better) {
|
|
68
|
+
const items = [];
|
|
69
|
+
const swap = (i, j) => { const t = items[i]; items[i] = items[j]; items[j] = t; };
|
|
70
|
+
return {
|
|
71
|
+
get size() { return items.length; },
|
|
72
|
+
push(node) {
|
|
73
|
+
items.push(node);
|
|
74
|
+
let i = items.length - 1;
|
|
75
|
+
while (i > 0) {
|
|
76
|
+
const parent = (i - 1) >> 1;
|
|
77
|
+
if (!better(items[i], items[parent])) break;
|
|
78
|
+
swap(i, parent);
|
|
79
|
+
i = parent;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
pop() {
|
|
83
|
+
const top = items[0];
|
|
84
|
+
const last = items.pop();
|
|
85
|
+
if (items.length) {
|
|
86
|
+
items[0] = last;
|
|
87
|
+
let i = 0;
|
|
88
|
+
for (;;) {
|
|
89
|
+
const l = 2 * i + 1; const r = l + 1;
|
|
90
|
+
let m = i;
|
|
91
|
+
if (l < items.length && better(items[l], items[m])) m = l;
|
|
92
|
+
if (r < items.length && better(items[r], items[m])) m = r;
|
|
93
|
+
if (m === i) break;
|
|
94
|
+
swap(i, m);
|
|
95
|
+
i = m;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return top;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A* from stub tip `sa` to stub tip `sb` over the sparse channel grid, in states
|
|
105
|
+
* `(xi, yi, dir)`. Returns the polyline INCLUDING sa and sb, or null when there
|
|
106
|
+
* is no corridor (the caller falls back to an elbow).
|
|
107
|
+
*
|
|
108
|
+
* Normative rules (two independent implementations must converge on these):
|
|
109
|
+
* · the goal is detected when its state is POPPED, never at push time — the
|
|
110
|
+
* tie-break can only arbitrate between goal entries that are both in the heap;
|
|
111
|
+
* · the closed set is keyed per (xi, yi, dir), never per coordinate alone;
|
|
112
|
+
* · relaxation needs a STRICT g improvement;
|
|
113
|
+
* · arrival rules apply only when `strictArrival` (a committed wire): a vertical
|
|
114
|
+
* arrival costs TURN_COST/2 (added to g, NOT to the `turns` tie-break key) and
|
|
115
|
+
* a leftward arrival is banned (no U-turn into the input's rightward stub).
|
|
116
|
+
* A loose cursor end is approachable from any direction at no cost.
|
|
117
|
+
*/
|
|
118
|
+
function astar(sa, sb, rects, { startDir = 'right', strictArrival = true } = {}) {
|
|
119
|
+
const xs = axisCoords([sa.x, sb.x, ...rects.flatMap((r) => [r.x, r.x + r.w])]);
|
|
120
|
+
const ys = axisCoords([sa.y, sb.y, ...rects.flatMap((r) => [r.y, r.y + r.h])]);
|
|
121
|
+
const xi0 = xs.indexOf(sa.x); const yi0 = ys.indexOf(sa.y);
|
|
122
|
+
const xg = xs.indexOf(sb.x); const yg = ys.indexOf(sb.y);
|
|
123
|
+
if (xi0 < 0 || yi0 < 0 || xg < 0 || yg < 0) return null;
|
|
124
|
+
const midX = (sa.x + sb.x) / 2; const midY = (sa.y + sb.y) / 2;
|
|
125
|
+
const heuristic = (xi, yi) => Math.abs(xs[xi] - sb.x) + Math.abs(ys[yi] - sb.y);
|
|
126
|
+
const better = (p, q) => {
|
|
127
|
+
if (p.f !== q.f) return p.f < q.f;
|
|
128
|
+
if (p.turns !== q.turns) return p.turns < q.turns;
|
|
129
|
+
const dp = Math.abs(xs[p.xi] - midX) + Math.abs(ys[p.yi] - midY);
|
|
130
|
+
const dq = Math.abs(xs[q.xi] - midX) + Math.abs(ys[q.yi] - midY);
|
|
131
|
+
if (dp !== dq) return dp < dq;
|
|
132
|
+
if (ys[p.yi] !== ys[q.yi]) return ys[p.yi] > ys[q.yi]; // y DESC (biases below)
|
|
133
|
+
if (xs[p.xi] !== xs[q.xi]) return xs[p.xi] < xs[q.xi]; // x ASC
|
|
134
|
+
return DIR_RANK.get(p.dir) < DIR_RANK.get(q.dir); // right < up < left < down
|
|
135
|
+
};
|
|
136
|
+
const heap = createHeap(better);
|
|
137
|
+
const closed = new Set();
|
|
138
|
+
const bestG = new Map();
|
|
139
|
+
heap.push({ xi: xi0, yi: yi0, dir: startDir, g: 0, f: heuristic(xi0, yi0), turns: 0, parent: null });
|
|
140
|
+
bestG.set(`${xi0},${yi0},${startDir}`, 0);
|
|
141
|
+
while (heap.size) {
|
|
142
|
+
const cur = heap.pop();
|
|
143
|
+
const key = `${cur.xi},${cur.yi},${cur.dir}`;
|
|
144
|
+
if (closed.has(key)) continue;
|
|
145
|
+
closed.add(key);
|
|
146
|
+
if (cur.xi === xg && cur.yi === yg) { // goal at POP time
|
|
147
|
+
const pts = [];
|
|
148
|
+
for (let n = cur; n; n = n.parent) pts.push({ x: xs[n.xi], y: ys[n.yi] });
|
|
149
|
+
return simplify(pts.reverse());
|
|
150
|
+
}
|
|
151
|
+
const p = { x: xs[cur.xi], y: ys[cur.yi] };
|
|
152
|
+
for (const dir of DIRS) {
|
|
153
|
+
const [dx, dy] = STEP[dir];
|
|
154
|
+
const nx = cur.xi + dx; const ny = cur.yi + dy;
|
|
155
|
+
if (nx < 0 || nx >= xs.length || ny < 0 || ny >= ys.length) continue;
|
|
156
|
+
const goal = nx === xg && ny === yg;
|
|
157
|
+
if (goal && strictArrival && dir === 'left') continue; // no U-turn into the entry stub
|
|
158
|
+
const q = { x: xs[nx], y: ys[ny] };
|
|
159
|
+
if (segBlocked(p, q, rects)) continue;
|
|
160
|
+
const turned = dir !== cur.dir;
|
|
161
|
+
const turns = cur.turns + (turned ? 1 : 0);
|
|
162
|
+
const arrival = goal && strictArrival && (dir === 'up' || dir === 'down') ? TURN_COST / 2 : 0;
|
|
163
|
+
const g = cur.g + Math.abs(q.x - p.x) + Math.abs(q.y - p.y) + (turned ? TURN_COST : 0) + arrival;
|
|
164
|
+
const k = `${nx},${ny},${dir}`;
|
|
165
|
+
if (closed.has(k)) continue;
|
|
166
|
+
const seen = bestG.get(k);
|
|
167
|
+
if (seen !== undefined && seen <= g) continue; // STRICT improvement only
|
|
168
|
+
bestG.set(k, g);
|
|
169
|
+
heap.push({ xi: nx, yi: ny, dir, g, f: g + heuristic(nx, ny), turns, parent: cur });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Route ONE wire a→b around `obstacles` (RAW card rects; inflated here).
|
|
177
|
+
* mirror = the ghost drag STARTED on an input, so the stub leaves leftward.
|
|
178
|
+
* looseEnd = b is a free cursor point (ghost): no entry stub, no arrival
|
|
179
|
+
* direction rules, and any obstacle containing b is dropped so
|
|
180
|
+
* hovering a target card still routes.
|
|
181
|
+
* stub = per-wire exit stub length (routeAll staggers fan-out siblings).
|
|
182
|
+
* Returns an orthogonal polyline INCLUDING a and b. NEVER throws, never NaN:
|
|
183
|
+
* an unroutable pair (jammed/overlapping cards, or an anchor stub tip engulfed
|
|
184
|
+
* by an overlapping card's inflated rect) falls back to a plain elbow.
|
|
185
|
+
* NOTE the exit leg a→sa and entry leg sb→b intentionally pass through their
|
|
186
|
+
* OWN card's clearance band (the anchor sits ON the card edge) — that is the
|
|
187
|
+
* port exit, not a violation.
|
|
188
|
+
*/
|
|
189
|
+
export function routeWire(a, b, obstacles, { mirror = false, looseEnd = false, stub = ROUTE_STUB } = {}) {
|
|
190
|
+
const s = mirror ? -1 : 1;
|
|
191
|
+
const sa = { x: a.x + s * stub, y: a.y };
|
|
192
|
+
const sb = looseEnd ? { x: b.x, y: b.y } : { x: b.x - ROUTE_STUB, y: b.y };
|
|
193
|
+
const inflated = (Array.isArray(obstacles) ? obstacles : [])
|
|
194
|
+
.filter((r) => r && r.w > 0 && r.h > 0)
|
|
195
|
+
.map((r) => inflate(r, ROUTE_CLEARANCE));
|
|
196
|
+
const rects = looseEnd ? inflated.filter((r) => !containsStrict(r, sb)) : inflated;
|
|
197
|
+
const jammed = rects.some((r) => containsStrict(r, sa)) || (!looseEnd && rects.some((r) => containsStrict(r, sb)));
|
|
198
|
+
const core = jammed ? null : astar(sa, sb, rects, { startDir: mirror ? 'left' : 'right', strictArrival: !looseEnd });
|
|
199
|
+
const mid = core || fallbackElbow(sa, sb, mirror);
|
|
200
|
+
return simplify([a, ...mid, ...(looseEnd ? [] : [b])]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Straight when possible, one mid-channel elbow forward, a dive-below detour backward. */
|
|
204
|
+
function fallbackElbow(sa, sb, mirror) {
|
|
205
|
+
const fwd = mirror ? sb.x <= sa.x : sb.x >= sa.x;
|
|
206
|
+
if (fwd && sa.y === sb.y) return [sa, sb];
|
|
207
|
+
if (fwd) {
|
|
208
|
+
const mx = (sa.x + sb.x) / 2;
|
|
209
|
+
return [sa, { x: mx, y: sa.y }, { x: mx, y: sb.y }, sb];
|
|
210
|
+
}
|
|
211
|
+
const my = Math.max(sa.y, sb.y) + 3 * ROUTE_CLEARANCE;
|
|
212
|
+
return [sa, { x: sa.x, y: my }, { x: sb.x, y: my }, sb];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Drop repeated + collinear points so corners are real corners. */
|
|
216
|
+
function simplify(pts) {
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const p of pts) {
|
|
219
|
+
const q = out[out.length - 1];
|
|
220
|
+
if (q && q.x === p.x && q.y === p.y) continue;
|
|
221
|
+
const r = out[out.length - 2];
|
|
222
|
+
if (q && r && ((r.x === q.x && q.x === p.x) || (r.y === q.y && q.y === p.y))) out[out.length - 1] = p;
|
|
223
|
+
else out.push(p);
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Strict AABB overlap — rectangles that merely touch edges count as disjoint. */
|
|
229
|
+
const overlaps = (p, q) => p.x < q.x + q.w && q.x < p.x + p.w && p.y < q.y + q.h && q.y < p.y + p.h;
|
|
230
|
+
|
|
231
|
+
/** The drag fast path's reuse contract: the SAME array instance comes back when
|
|
232
|
+
* the endpoints are unchanged (VALUE equality — anchors are rebuilt every frame)
|
|
233
|
+
* and the cached route's inflated bbox is disjoint from the dirty world rect. */
|
|
234
|
+
function reusable(prevPts, w, dirty) {
|
|
235
|
+
if (!Array.isArray(prevPts) || prevPts.length < 2) return null;
|
|
236
|
+
const first = prevPts[0]; const last = prevPts[prevPts.length - 1];
|
|
237
|
+
if (first.x !== w.a.x || first.y !== w.a.y) return null;
|
|
238
|
+
if (last.x !== w.b.x || last.y !== w.b.y) return null;
|
|
239
|
+
const m = ROUTE_CLEARANCE + 2 * ROUTE_LANE_GAP;
|
|
240
|
+
let x0 = Infinity; let y0 = Infinity; let x1 = -Infinity; let y1 = -Infinity;
|
|
241
|
+
for (const p of prevPts) {
|
|
242
|
+
x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
|
|
243
|
+
x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
|
|
244
|
+
}
|
|
245
|
+
const box = { x: x0 - m, y: y0 - m, w: x1 - x0 + 2 * m, h: y1 - y0 + 2 * m };
|
|
246
|
+
return overlaps(box, dirty) ? null : prevPts;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Route a whole template's wires with separation. `wires` = [{id, a, b}].
|
|
251
|
+
* Deterministic: processing order and lane assignment key on the wire id sort,
|
|
252
|
+
* never on Map/insertion order.
|
|
253
|
+
* `prev`/`dirty` are the drag fast path: a wire whose endpoints are unchanged
|
|
254
|
+
* and whose cached RAW route's bbox (inflated by CLEARANCE + 2·LANE_GAP) does
|
|
255
|
+
* not touch the `dirty` world-rect reuses its cached raw route instead of
|
|
256
|
+
* re-running A*. The cache is DRAG-ONLY: callers must finish every drag with
|
|
257
|
+
* a full `dirty = null` pass (D15) so committed state is always canonical.
|
|
258
|
+
*/
|
|
259
|
+
export function routeAll(wires, obstacles, { prev = null, dirty = null } = {}) {
|
|
260
|
+
const sorted = [...wires].sort((p, q) => (p.id < q.id ? -1 : p.id > q.id ? 1 : 0));
|
|
261
|
+
// Fan-out stagger: wires leaving the SAME output anchor peel off at different
|
|
262
|
+
// stub lengths (outward only, so every tip stays clear of the inflated rect).
|
|
263
|
+
// For same-row fan-outs A*'s late-bend preference + the collinear simplify
|
|
264
|
+
// collapse the staggered tips back onto one line — there the visible
|
|
265
|
+
// peel-apart is delivered by separateRoutes shifting the siblings' first-bend
|
|
266
|
+
// legs. The stagger still differentiates routes that bend early.
|
|
267
|
+
const groups = new Map(); // `${a.x},${a.y}` -> ordered wire ids
|
|
268
|
+
for (const w of sorted) {
|
|
269
|
+
const k = `${w.a.x},${w.a.y}`;
|
|
270
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
271
|
+
groups.get(k).push(w.id);
|
|
272
|
+
}
|
|
273
|
+
const raw = new Map();
|
|
274
|
+
for (const w of sorted) {
|
|
275
|
+
const lane = groups.get(`${w.a.x},${w.a.y}`).indexOf(w.id);
|
|
276
|
+
const stub = ROUTE_STUB + Math.min(lane * ROUTE_LANE_GAP, 24);
|
|
277
|
+
const cached = prev && dirty ? reusable(prev.get(w.id), w, dirty) : null;
|
|
278
|
+
raw.set(w.id, cached || routeWire(w.a, w.b, obstacles, { stub }));
|
|
279
|
+
}
|
|
280
|
+
return { raw, routes: separateRoutes(raw) };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Spread wires apart where they share a channel. Only INTERIOR segments move —
|
|
285
|
+
* the anchor legs at each end are pinned so ports stay honest (stub tips may
|
|
286
|
+
* slide sideways within the clearance budget; anchors never move).
|
|
287
|
+
* Offsets are centered lanes ordered by wire id; the total spread is clamped to
|
|
288
|
+
* ±(ROUTE_CLEARANCE − 2) so a nudged wire can never cross a REAL card edge
|
|
289
|
+
* (the clearance inflation is the budget it moves within).
|
|
290
|
+
*/
|
|
291
|
+
export function separateRoutes(raw, { gap = ROUTE_LANE_GAP } = {}) {
|
|
292
|
+
const segs = []; // {id, i, axis, coord, lo, hi}
|
|
293
|
+
for (const [id, pts] of [...raw.entries()].sort(([p], [q]) => (p < q ? -1 : 1))) {
|
|
294
|
+
for (let i = 2; i <= pts.length - 2; i += 1) { // every leg except the two anchor legs
|
|
295
|
+
const p = pts[i - 1]; const q = pts[i];
|
|
296
|
+
if (p.x === q.x) segs.push({ id, i, axis: 'v', coord: p.x, lo: Math.min(p.y, q.y), hi: Math.max(p.y, q.y) });
|
|
297
|
+
else if (p.y === q.y) segs.push({ id, i, axis: 'h', coord: p.y, lo: Math.min(p.x, q.x), hi: Math.max(p.x, q.x) });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const shifts = new Map(); // `${id}:${i}` -> { axis, off }
|
|
301
|
+
const byLine = new Map();
|
|
302
|
+
for (const s of segs) {
|
|
303
|
+
const k = `${s.axis}:${s.coord}`;
|
|
304
|
+
if (!byLine.has(k)) byLine.set(k, []);
|
|
305
|
+
byLine.get(k).push(s);
|
|
306
|
+
}
|
|
307
|
+
for (const line of byLine.values()) {
|
|
308
|
+
line.sort((p, q) => p.lo - q.lo);
|
|
309
|
+
let cluster = [];
|
|
310
|
+
let end = -Infinity;
|
|
311
|
+
const flush = () => {
|
|
312
|
+
if (cluster.length < 2) { cluster = []; return; }
|
|
313
|
+
cluster.sort((p, q) => (p.id < q.id ? -1 : p.id > q.id ? 1 : p.i - q.i));
|
|
314
|
+
const n = cluster.length;
|
|
315
|
+
const g = Math.min(gap, (2 * (ROUTE_CLEARANCE - 2)) / (n - 1));
|
|
316
|
+
cluster.forEach((s, k) => shifts.set(`${s.id}:${s.i}`, { axis: s.axis, off: (k - (n - 1) / 2) * g }));
|
|
317
|
+
cluster = [];
|
|
318
|
+
};
|
|
319
|
+
for (const s of line) {
|
|
320
|
+
if (s.lo > end) flush();
|
|
321
|
+
cluster.push(s);
|
|
322
|
+
end = Math.max(end, s.hi);
|
|
323
|
+
}
|
|
324
|
+
flush();
|
|
325
|
+
}
|
|
326
|
+
const out = new Map();
|
|
327
|
+
for (const [id, pts] of raw) {
|
|
328
|
+
const copy = pts.map((p) => ({ ...p }));
|
|
329
|
+
for (let i = 2; i <= copy.length - 2; i += 1) { // SAME range as collection
|
|
330
|
+
const sh = shifts.get(`${id}:${i}`);
|
|
331
|
+
if (!sh || !sh.off) continue;
|
|
332
|
+
if (sh.axis === 'v') { copy[i - 1].x += sh.off; copy[i].x += sh.off; }
|
|
333
|
+
else { copy[i - 1].y += sh.off; copy[i].y += sh.off; }
|
|
334
|
+
}
|
|
335
|
+
out.set(id, copy);
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const num = (v) => String(Math.round(v * 100) / 100);
|
|
341
|
+
const dist = (p, q) => Math.abs(q.x - p.x) + Math.abs(q.y - p.y); // legs are axis-aligned
|
|
342
|
+
const towards = (from, to, r) => {
|
|
343
|
+
const l = dist(from, to) || 1;
|
|
344
|
+
return { x: from.x + ((to.x - from.x) / l) * r, y: from.y + ((to.y - from.y) / l) * r };
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/** Rounded-corner SVG path for an orthogonal polyline. Radius clamps to half
|
|
348
|
+
* of each adjacent leg so short jogs never overshoot. */
|
|
349
|
+
export function routePathD(pts, radius = ROUTE_RADIUS) {
|
|
350
|
+
if (!Array.isArray(pts) || pts.length < 2) return '';
|
|
351
|
+
let d = `M ${num(pts[0].x)} ${num(pts[0].y)}`;
|
|
352
|
+
for (let i = 1; i < pts.length - 1; i += 1) {
|
|
353
|
+
const p = pts[i - 1]; const c = pts[i]; const n = pts[i + 1];
|
|
354
|
+
const r = Math.min(radius, dist(p, c) / 2, dist(c, n) / 2);
|
|
355
|
+
const tin = towards(c, p, r); const tout = towards(c, n, r);
|
|
356
|
+
d += ` L ${num(tin.x)} ${num(tin.y)} Q ${num(c.x)} ${num(c.y)} ${num(tout.x)} ${num(tout.y)}`;
|
|
357
|
+
}
|
|
358
|
+
const last = pts[pts.length - 1];
|
|
359
|
+
return `${d} L ${num(last.x)} ${num(last.y)}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Point at half the polyline's arc length — where the wire badge sits. */
|
|
363
|
+
export function routeMid(pts) {
|
|
364
|
+
if (!Array.isArray(pts) || !pts.length) return { x: 0, y: 0 };
|
|
365
|
+
let total = 0;
|
|
366
|
+
for (let i = 1; i < pts.length; i += 1) total += dist(pts[i - 1], pts[i]);
|
|
367
|
+
let acc = 0;
|
|
368
|
+
for (let i = 1; i < pts.length; i += 1) {
|
|
369
|
+
const leg = dist(pts[i - 1], pts[i]);
|
|
370
|
+
if (acc + leg >= total / 2) {
|
|
371
|
+
const t = leg ? (total / 2 - acc) / leg : 0;
|
|
372
|
+
return { x: pts[i - 1].x + (pts[i].x - pts[i - 1].x) * t, y: pts[i - 1].y + (pts[i].y - pts[i - 1].y) * t };
|
|
373
|
+
}
|
|
374
|
+
acc += leg;
|
|
375
|
+
}
|
|
376
|
+
return { ...pts[pts.length - 1] };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Exact point-to-segment distance per leg — no sampling, no gap (the MAJ-18
|
|
380
|
+
* class of bug stays fixed). Painted corners are rounded by ≤ r·(1−1/√2) ≈
|
|
381
|
+
* 2.35px relative to this sharp polyline — well inside WIRE_HIT_TOL = 6. */
|
|
382
|
+
export function hitRoute(pts, pt, tol = WIRE_HIT_TOL) {
|
|
383
|
+
for (let i = 1; i < (pts?.length || 0); i += 1) {
|
|
384
|
+
if (distanceToSegment(pt, pts[i - 1], pts[i]) <= tol) return true;
|
|
385
|
+
}
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
390
|
+
|
|
391
|
+
function distanceToSegment(p, a, b) {
|
|
392
|
+
const vx = b.x - a.x;
|
|
393
|
+
const vy = b.y - a.y;
|
|
394
|
+
const len2 = vx * vx + vy * vy;
|
|
395
|
+
const t = len2 === 0 ? 0 : clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len2, 0, 1);
|
|
396
|
+
return Math.hypot(p.x - (a.x + t * vx), p.y - (a.y + t * vy));
|
|
397
|
+
}
|