@wscytz/dsl 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * routing — 正交绕障走线求解器(走廊增强稀疏正交可见图 + 方向状态 A*)。
3
+ *
4
+ * 取代 to-svg 的四族启发式(routeAround 保留为兜底)。来源:2026-10-25 外包咨询蓝图
5
+ * (🔒 cys-stift-docs development/2026-10-25-routing-algorithm-outsourcing-brief.md),
6
+ * 落地时两处有意偏离:
7
+ * - 源/目标矩形保留为障碍(内缩 hitEpsilon:贴边合法、深入即拦),不整体剔除——
8
+ * 防路径倒穿源/目标内部;仅"严格包含贴边端点"的矩形豁免(外层容器语义)。
9
+ * - 转折代价 λ 默认 28(咨询建议 [24,32];启发式时代的 8 保留为对拍参数)。
10
+ *
11
+ * 管线:活跃障碍过滤 → 端口候选(四边投影点,引出段探针验合法)→ 局部视野内建
12
+ * 坐标轴(障碍外扩边 + 间隙中线 + 端口/引出/源目中线坐标)→ 网格节点/邻接边遮挡
13
+ * 剔除 → 多源多汇方向状态 A*(可采纳启发 = 方向集下界,终态精确;破平局按
14
+ * f/折数/中线偏/节点/方向字典序,确定性)→ 共线合并 → free 模式视线拉直(带
15
+ * 12px 安全边距的贪心 LOS 合并)→ 端点重贴边 + 量化(0.5px)+ 全路径终验。
16
+ * 同输入同输出(无随机、无时间依赖)。
17
+ */
18
+ export interface RoutingRect {
19
+ x: number;
20
+ y: number;
21
+ w: number;
22
+ h: number;
23
+ }
24
+ export interface RoutingPoint {
25
+ x: number;
26
+ y: number;
27
+ }
28
+ export interface RoutingConfig {
29
+ /** 障碍外扩安全边距(走廊到卡边的距离) */
30
+ margin: number;
31
+ /** 碰撞检测内缩容差(贴边端点免误报) */
32
+ hitEpsilon: number;
33
+ /** 最小可通行间隙(窄缝仍出中线走廊) */
34
+ minGap: number;
35
+ /** 转折视觉代价 λ(px/折);对拍可传 8,生产 28 */
36
+ bendPenalty: number;
37
+ /** 非单调折返微惩罚(远离目标的移动),抑制同代价阶梯线 */
38
+ uturnPenalty: number;
39
+ /** 端口垂直引出段长度 */
40
+ minStub: number;
41
+ /** 坐标量化步长(0.5px),确定性 */
42
+ quant: number;
43
+ /** 局部视野外扩(障碍吸收闭包半径) */
44
+ localPad: number;
45
+ }
46
+ export declare const DEFAULT_ROUTING: RoutingConfig;
47
+ /** 调试内省:重走 planRoute 的前置管线(活跃障碍/候选端口/局部视野/图轴集),
48
+ * **不跑 A\***——供调试台可视化解构中间结构,与 planRoute 严格同源(同一
49
+ * prepare)。free 直连短路不在此判定(调试态即使可直连也出结构)。
50
+ * `viewBox` 与图轴同基准;端口/引出点为坐标序列,dir = 外法线(N0 E1 S2 W3)。 */
51
+ export interface RoutingDebug {
52
+ active: RoutingRect[];
53
+ view: RoutingRect;
54
+ xs: number[];
55
+ ys: number[];
56
+ sPorts: Array<{
57
+ pt: RoutingPoint;
58
+ dir: number;
59
+ }>;
60
+ tPorts: Array<{
61
+ pt: RoutingPoint;
62
+ dir: number;
63
+ }>;
64
+ starts: RoutingPoint[];
65
+ ends: RoutingPoint[];
66
+ }
67
+ export declare function debugRoute(p1: RoutingPoint, p2: RoutingPoint, src: RoutingRect, dst: RoutingRect, obstacles: RoutingRect[], cfg?: RoutingConfig): RoutingDebug;
68
+ /**
69
+ * 主入口:规划从 src 贴边到 dst 贴边的绕障路径(整条,含端点)。
70
+ * free 模式直连已清 → 返回 null(调用方保持原直连);无解 → null(调用方退兜底)。
71
+ * orthogonal 模式恒返正交路径(除非无解)。
72
+ */
73
+ export declare function planRoute(p1: RoutingPoint, p2: RoutingPoint, src: RoutingRect, dst: RoutingRect, obstacles: RoutingRect[], mode?: 'free' | 'orthogonal', cfg?: RoutingConfig): RoutingPoint[] | null;
@@ -0,0 +1,617 @@
1
+ /**
2
+ * routing — 正交绕障走线求解器(走廊增强稀疏正交可见图 + 方向状态 A*)。
3
+ *
4
+ * 取代 to-svg 的四族启发式(routeAround 保留为兜底)。来源:2026-10-25 外包咨询蓝图
5
+ * (🔒 cys-stift-docs development/2026-10-25-routing-algorithm-outsourcing-brief.md),
6
+ * 落地时两处有意偏离:
7
+ * - 源/目标矩形保留为障碍(内缩 hitEpsilon:贴边合法、深入即拦),不整体剔除——
8
+ * 防路径倒穿源/目标内部;仅"严格包含贴边端点"的矩形豁免(外层容器语义)。
9
+ * - 转折代价 λ 默认 28(咨询建议 [24,32];启发式时代的 8 保留为对拍参数)。
10
+ *
11
+ * 管线:活跃障碍过滤 → 端口候选(四边投影点,引出段探针验合法)→ 局部视野内建
12
+ * 坐标轴(障碍外扩边 + 间隙中线 + 端口/引出/源目中线坐标)→ 网格节点/邻接边遮挡
13
+ * 剔除 → 多源多汇方向状态 A*(可采纳启发 = 方向集下界,终态精确;破平局按
14
+ * f/折数/中线偏/节点/方向字典序,确定性)→ 共线合并 → free 模式视线拉直(带
15
+ * 12px 安全边距的贪心 LOS 合并)→ 端点重贴边 + 量化(0.5px)+ 全路径终验。
16
+ * 同输入同输出(无随机、无时间依赖)。
17
+ */
18
+ export const DEFAULT_ROUTING = {
19
+ margin: 16,
20
+ hitEpsilon: 2,
21
+ minGap: 8,
22
+ bendPenalty: 28,
23
+ uturnPenalty: 4,
24
+ minStub: 16,
25
+ quant: 0.5,
26
+ localPad: 160,
27
+ };
28
+ const NORMALS = [
29
+ [0, -1],
30
+ [1, 0],
31
+ [0, 1],
32
+ [-1, 0],
33
+ ];
34
+ const OPP = (d) => ((d + 2) % 4);
35
+ const q = (v, step) => Math.round(v / step) * step;
36
+ const finiteRect = (r) => Number.isFinite(r.x) && Number.isFinite(r.y) && Number.isFinite(r.w) && Number.isFinite(r.h);
37
+ /**
38
+ * 敌意 cfg 归一(对抗性契约):非有限/越界参数回退默认,不让 quant=0 / 负 penalty
39
+ * 之类把 NaN 带进路径或扭曲代价语义。调用方(scene JSON 导入、实验滑杆)传什么都不会炸。
40
+ */
41
+ function normalizeCfg(cfg) {
42
+ const d = DEFAULT_ROUTING;
43
+ const fin = (v, min, fb) => (Number.isFinite(v) && v >= min ? v : fb);
44
+ return {
45
+ margin: fin(cfg.margin, 0, d.margin),
46
+ hitEpsilon: fin(cfg.hitEpsilon, 0, d.hitEpsilon),
47
+ minGap: fin(cfg.minGap, 0, d.minGap),
48
+ bendPenalty: fin(cfg.bendPenalty, 0, d.bendPenalty),
49
+ uturnPenalty: fin(cfg.uturnPenalty, 0, d.uturnPenalty),
50
+ minStub: fin(cfg.minStub, 0, d.minStub),
51
+ quant: fin(cfg.quant, 1e-6, d.quant),
52
+ localPad: fin(cfg.localPad, 0, d.localPad),
53
+ };
54
+ }
55
+ function inset(r, e) {
56
+ return { x: r.x + e, y: r.y + e, w: r.w - 2 * e, h: r.h - 2 * e };
57
+ }
58
+ function expand(r, m) {
59
+ return { x: r.x - m, y: r.y - m, w: r.w + 2 * m, h: r.h + 2 * m };
60
+ }
61
+ function containsStrict(p, r) {
62
+ return p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h;
63
+ }
64
+ /** 线段与矩形内域相交(Liang-Barsky;相交段需有实长,贴边擦过不算)。 */
65
+ function segHitsRect(a, b, r) {
66
+ let t0 = 0;
67
+ let t1 = 1;
68
+ const dx = b.x - a.x;
69
+ const dy = b.y - a.y;
70
+ const edges = [
71
+ [-dx, a.x - r.x],
72
+ [dx, r.x + r.w - a.x],
73
+ [-dy, a.y - r.y],
74
+ [dy, r.y + r.h - a.y],
75
+ ];
76
+ for (const [p, qq] of edges) {
77
+ if (p === 0) {
78
+ // 开内域语义:线与边共线(qq=0)不进开内域,相切允许——与图侧
79
+ // containsStrict 口径一致,否则贴边滑行的合法解被终验误杀退兜底
80
+ if (qq <= 0)
81
+ return false;
82
+ }
83
+ else {
84
+ const t = qq / p;
85
+ if (p < 0) {
86
+ if (t > t1)
87
+ return false;
88
+ if (t > t0)
89
+ t0 = t;
90
+ }
91
+ else {
92
+ if (t < t0)
93
+ return false;
94
+ if (t < t1)
95
+ t1 = t;
96
+ }
97
+ }
98
+ }
99
+ return t1 - t0 > 0.01;
100
+ }
101
+ function segClear(a, b, rects) {
102
+ for (const r of rects)
103
+ if (segHitsRect(a, b, r))
104
+ return false;
105
+ return true;
106
+ }
107
+ function pathClear(pts, rects) {
108
+ for (let i = 0; i < pts.length - 1; i++) {
109
+ if (!segClear(pts[i], pts[i + 1], rects))
110
+ return false;
111
+ }
112
+ return true;
113
+ }
114
+ /** 矩形中心到 (tx,ty) 方向的贴边点(与 to-svg.borderPoint 同式)。 */
115
+ function borderPoint(r, tx, ty) {
116
+ const cx = r.x + r.w / 2;
117
+ const cy = r.y + r.h / 2;
118
+ const dx = tx - cx;
119
+ const dy = ty - cy;
120
+ if (dx === 0 && dy === 0)
121
+ return { x: cx, y: cy };
122
+ const sx = dx !== 0 ? r.w / 2 / Math.abs(dx) : Infinity;
123
+ const sy = dy !== 0 ? r.h / 2 / Math.abs(dy) : Infinity;
124
+ const s = Math.min(sx, sy);
125
+ return { x: cx + dx * s, y: cy + dy * s };
126
+ }
127
+ /** 活跃障碍:过小矩形跳过(与启发式同口径);严格包含贴边端点的矩形豁免(容器
128
+ * 语义,咨询修补 §2-1);源/目标保留——内缩后贴边端点在外,深入即拦(防倒穿)。 */
129
+ function activeObstacles(obstacles, p0, pk, eps) {
130
+ const out = [];
131
+ for (const r of obstacles) {
132
+ if (r.w <= 12 && r.h <= 12)
133
+ continue;
134
+ const sh = inset(r, eps);
135
+ if (sh.w <= 0 || sh.h <= 0)
136
+ continue;
137
+ if (containsStrict(p0, sh) || containsStrict(pk, sh))
138
+ continue;
139
+ out.push(sh);
140
+ }
141
+ return out;
142
+ }
143
+ /** 四边候选端口:对方中心在该边上的投影,钳在边长 [20%,80%](防贴死圆角)。 */
144
+ function candidatePorts(r, toward) {
145
+ const clampSpan = (v, lo, len) => Math.min(lo + len * 0.8, Math.max(lo + len * 0.2, v));
146
+ return [
147
+ { pt: { x: clampSpan(toward.x, r.x, r.w), y: r.y }, dir: 0 },
148
+ { pt: { x: r.x + r.w, y: clampSpan(toward.y, r.y, r.h) }, dir: 1 },
149
+ { pt: { x: clampSpan(toward.x, r.x, r.w), y: r.y + r.h }, dir: 2 },
150
+ { pt: { x: r.x, y: clampSpan(toward.y, r.y, r.h) }, dir: 3 },
151
+ ];
152
+ }
153
+ function stubOf(p, len) {
154
+ const [nx, ny] = NORMALS[p.dir];
155
+ return { x: p.pt.x + nx * len, y: p.pt.y + ny * len };
156
+ }
157
+ /** 引出段探针:端口向外 stub 长度的段不得穿任何障碍(四面全堵的端口不可用)。 */
158
+ function portUsable(p, rects, stub) {
159
+ return segClear(p.pt, stubOf(p, stub), rects);
160
+ }
161
+ /** 局部视野:源/目标包围盒外扩,吸收相交障碍的外扩盒直至闭包,再补一圈边距。 */
162
+ function viewBox(src, dst, act, cfg) {
163
+ const sx = Math.min(src.x, dst.x);
164
+ const sy = Math.min(src.y, dst.y);
165
+ let b = expand({
166
+ x: sx,
167
+ y: sy,
168
+ w: Math.max(src.x + src.w, dst.x + dst.w) - sx,
169
+ h: Math.max(src.y + src.h, dst.y + dst.h) - sy,
170
+ }, cfg.localPad);
171
+ for (let round = 0; round < 4; round++) {
172
+ let changed = false;
173
+ for (const r of act) {
174
+ const hit = r.x < b.x + b.w && r.x + r.w > b.x && r.y < b.y + b.h && r.y + r.h > b.y;
175
+ if (!hit)
176
+ continue;
177
+ const re = expand(r, cfg.margin);
178
+ const nb = {
179
+ x: Math.min(b.x, re.x),
180
+ y: Math.min(b.y, re.y),
181
+ w: Math.max(b.x + b.w, re.x + re.w) - Math.min(b.x, re.x),
182
+ h: Math.max(b.y + b.h, re.y + re.h) - Math.min(b.y, re.y),
183
+ };
184
+ if (nb.x !== b.x || nb.y !== b.y || nb.w !== b.w || nb.h !== b.h) {
185
+ b = nb;
186
+ changed = true;
187
+ }
188
+ }
189
+ if (!changed)
190
+ break;
191
+ }
192
+ return expand(b, cfg.margin);
193
+ }
194
+ /** 单轴障碍区间并集的空隙中点(空隙 ≥ minGap;窄缝中线也在轴上,穿缝走正中)。 */
195
+ function gapMidlines(rects, axis, minGap) {
196
+ const ivals = rects
197
+ .map((r) => (axis === 'x' ? [r.x, r.x + r.w] : [r.y, r.y + r.h]))
198
+ .sort((a, b) => a[0] - b[0]);
199
+ const merged = [];
200
+ for (const iv of ivals) {
201
+ const last = merged[merged.length - 1];
202
+ if (last && iv[0] <= last[1])
203
+ last[1] = Math.max(last[1], iv[1]);
204
+ else
205
+ merged.push([iv[0], iv[1]]);
206
+ }
207
+ const mids = [];
208
+ for (let i = 0; i < merged.length - 1; i++) {
209
+ const start = merged[i][1];
210
+ const end = merged[i + 1][0];
211
+ if (end - start >= minGap)
212
+ mids.push((start + end) / 2);
213
+ }
214
+ return mids;
215
+ }
216
+ function buildGraph(act, src, dst, ports, cfg) {
217
+ const view = viewBox(src, dst, act, cfg);
218
+ const inView = (r) => r.x < view.x + view.w && r.x + r.w > view.x && r.y < view.y + view.h && r.y + r.h > view.y;
219
+ const local = act.filter(inView);
220
+ const xs = new Set();
221
+ const ys = new Set();
222
+ const addX = (v) => {
223
+ if (v >= view.x && v <= view.x + view.w)
224
+ xs.add(q(v, cfg.quant));
225
+ };
226
+ const addY = (v) => {
227
+ if (v >= view.y && v <= view.y + view.h)
228
+ ys.add(q(v, cfg.quant));
229
+ };
230
+ for (const r of local) {
231
+ addX(r.x - cfg.margin);
232
+ addX(r.x + r.w + cfg.margin);
233
+ addY(r.y - cfg.margin);
234
+ addY(r.y + r.h + cfg.margin);
235
+ }
236
+ for (const m of gapMidlines(local, 'x', cfg.minGap))
237
+ addX(m);
238
+ for (const m of gapMidlines(local, 'y', cfg.minGap))
239
+ addY(m);
240
+ for (const p of ports) {
241
+ addX(p.pt.x);
242
+ addY(p.pt.y);
243
+ const sp = stubOf(p, cfg.minStub);
244
+ addX(sp.x);
245
+ addY(sp.y);
246
+ }
247
+ addX((src.x + src.w / 2 + dst.x + dst.w / 2) / 2);
248
+ addY((src.y + src.h / 2 + dst.y + dst.h / 2) / 2);
249
+ const xsArr = [...xs].sort((a, b) => a - b);
250
+ const ysArr = [...ys].sort((a, b) => a - b);
251
+ const blocked = new Uint8Array(xsArr.length * ysArr.length);
252
+ for (let iy = 0; iy < ysArr.length; iy++) {
253
+ for (let ix = 0; ix < xsArr.length; ix++) {
254
+ const p = { x: xsArr[ix], y: ysArr[iy] };
255
+ for (const r of local) {
256
+ if (containsStrict(p, r)) {
257
+ blocked[iy * xsArr.length + ix] = 1;
258
+ break;
259
+ }
260
+ }
261
+ }
262
+ }
263
+ const hCover = ysArr.map((y) => local.filter((r) => y > r.y && y < r.y + r.h));
264
+ const vCover = xsArr.map((x) => local.filter((r) => x > r.x && x < r.x + r.w));
265
+ return { xs: xsArr, ys: ysArr, blocked, hCover, vCover };
266
+ }
267
+ /** 二叉堆,比较器按 (⌊f·100⌋, bends, bias, u, dir) 字典序——确定性破平局。
268
+ * bias 只在 (f, bends) 全等时生效,不影响最优性,仅让等代价赛跑先探索
269
+ * 贴近源目中线的走廊(避免选中贴卡外扩走廊这类几何正确但难看的解)。 */
270
+ class Heap {
271
+ a = [];
272
+ less(i, j) {
273
+ const A = this.a[i];
274
+ const B = this.a[j];
275
+ const fa = Math.floor(A.f * 100);
276
+ const fb = Math.floor(B.f * 100);
277
+ return fa !== fb
278
+ ? fa < fb
279
+ : A.bends !== B.bends
280
+ ? A.bends < B.bends
281
+ : A.bias !== B.bias
282
+ ? A.bias < B.bias
283
+ : A.u !== B.u
284
+ ? A.u < B.u
285
+ : A.dir < B.dir;
286
+ }
287
+ push(it) {
288
+ this.a.push(it);
289
+ let i = this.a.length - 1;
290
+ while (i > 0) {
291
+ const p = (i - 1) >> 1;
292
+ if (this.less(i, p)) {
293
+ ;
294
+ [this.a[i], this.a[p]] = [this.a[p], this.a[i]];
295
+ i = p;
296
+ }
297
+ else
298
+ break;
299
+ }
300
+ }
301
+ pop() {
302
+ const top = this.a[0];
303
+ const last = this.a.pop();
304
+ if (this.a.length > 0 && last) {
305
+ this.a[0] = last;
306
+ let i = 0;
307
+ for (;;) {
308
+ const l = 2 * i + 1;
309
+ const r = l + 1;
310
+ let m = i;
311
+ if (l < this.a.length && this.less(l, m))
312
+ m = l;
313
+ if (r < this.a.length && this.less(r, m))
314
+ m = r;
315
+ if (m === i)
316
+ break;
317
+ [this.a[i], this.a[m]] = [this.a[m], this.a[i]];
318
+ i = m;
319
+ }
320
+ }
321
+ return top;
322
+ }
323
+ get size() {
324
+ return this.a.length;
325
+ }
326
+ }
327
+ function prepare(p1, p2, src, dst, obstacles, cfg) {
328
+ const act = activeObstacles(obstacles, p1, p2, cfg.hitEpsilon);
329
+ const dstCenter = { x: dst.x + dst.w / 2, y: dst.y + dst.h / 2 };
330
+ const srcCenter = { x: src.x + src.w / 2, y: src.y + src.h / 2 };
331
+ const sPorts = candidatePorts(src, dstCenter).filter((p) => portUsable(p, act, cfg.minStub));
332
+ const tPorts = candidatePorts(dst, srcCenter).filter((p) => portUsable(p, act, cfg.minStub));
333
+ const g = buildGraph(act, src, dst, [...sPorts, ...tPorts], cfg);
334
+ const W = g.xs.length;
335
+ const H = g.ys.length;
336
+ const nodeIndexOf = (p) => {
337
+ const x = q(p.x, cfg.quant);
338
+ const y = q(p.y, cfg.quant);
339
+ const ix = g.xs.indexOf(x);
340
+ const iy = g.ys.indexOf(y);
341
+ return ix < 0 || iy < 0 ? -1 : iy * W + ix;
342
+ };
343
+ const ends = tPorts
344
+ .map((p) => {
345
+ const stub = stubOf(p, cfg.minStub);
346
+ const u = nodeIndexOf(stub);
347
+ return u >= 0 && !g.blocked[u] ? { port: p, stub, u } : null;
348
+ })
349
+ .filter((e) => e !== null);
350
+ const starts = sPorts
351
+ .map((p) => {
352
+ const stub = stubOf(p, cfg.minStub);
353
+ const u = nodeIndexOf(stub);
354
+ return u >= 0 && !g.blocked[u] ? { port: p, stub, u } : null;
355
+ })
356
+ .filter((s) => s !== null);
357
+ return { act, sPorts, tPorts, srcCenter, dstCenter, g, W, H, starts, ends };
358
+ }
359
+ export function debugRoute(p1, p2, src, dst, obstacles, cfg = DEFAULT_ROUTING) {
360
+ cfg = normalizeCfg(cfg);
361
+ obstacles = obstacles.filter(finiteRect); // 非有限障碍不可推理 → 丢弃(与 planRoute 同规)
362
+ const P = prepare(p1, p2, src, dst, obstacles, cfg);
363
+ return {
364
+ active: P.act,
365
+ view: viewBox(src, dst, P.act, cfg),
366
+ xs: P.g.xs,
367
+ ys: P.g.ys,
368
+ sPorts: P.sPorts.map((p) => ({ pt: p.pt, dir: p.dir })),
369
+ tPorts: P.tPorts.map((p) => ({ pt: p.pt, dir: p.dir })),
370
+ starts: P.starts.map((s) => s.stub),
371
+ ends: P.ends.map((e) => e.stub),
372
+ };
373
+ }
374
+ /**
375
+ * 主入口:规划从 src 贴边到 dst 贴边的绕障路径(整条,含端点)。
376
+ * free 模式直连已清 → 返回 null(调用方保持原直连);无解 → null(调用方退兜底)。
377
+ * orthogonal 模式恒返正交路径(除非无解)。
378
+ */
379
+ export function planRoute(p1, p2, src, dst, obstacles, mode = 'free', cfg = DEFAULT_ROUTING) {
380
+ cfg = normalizeCfg(cfg);
381
+ // 敌意几何契约:坐标非有限 → 无解(null),不产 NaN 路径;非有限障碍不可推理 → 丢弃
382
+ if (![p1.x, p1.y, p2.x, p2.y].every(Number.isFinite) ||
383
+ !finiteRect(src) ||
384
+ !finiteRect(dst)) {
385
+ return null;
386
+ }
387
+ obstacles = obstacles.filter(finiteRect);
388
+ const act = activeObstacles(obstacles, p1, p2, cfg.hitEpsilon);
389
+ if (mode === 'free' && segClear(p1, p2, act))
390
+ return null; // V1 直连短路
391
+ const P = prepare(p1, p2, src, dst, obstacles, cfg);
392
+ const { starts, ends, srcCenter, dstCenter } = P;
393
+ if (P.sPorts.length === 0 || P.tPorts.length === 0)
394
+ return null;
395
+ const g = P.g;
396
+ const W = P.W;
397
+ const H = P.H;
398
+ // 美观偏好看:节点到源目中心连线的垂距(无符号,上下对称场景自动落回 u 键)
399
+ const lineDx = dstCenter.x - srcCenter.x;
400
+ const lineDy = dstCenter.y - srcCenter.y;
401
+ const lineLen = Math.hypot(lineDx, lineDy) || 1;
402
+ const biasOf = (u) => {
403
+ const bx = g.xs[u % W] - srcCenter.x;
404
+ const by = g.ys[Math.floor(u / W)] - srcCenter.y;
405
+ return Math.abs(bx * lineDy - by * lineDx) / lineLen;
406
+ };
407
+ if (starts.length === 0 || ends.length === 0)
408
+ return null;
409
+ // 可采纳启发(方向集下界):从当前状态到目标引出点,路径必须为
410
+ // 「朝目标的水平向 / 垂直向 / 入汇向」各跑至少一段正长程(净位移为零长程
411
+ // 凑不出),折数 ≥ |方向集| − 1,起始方向已在集合内再省 1。该下界对每条边、
412
+ // 每种入汇永不高估;终态 dx=dy=0 → h = 精确剩余 → 首个出堆终态即全路径最优。
413
+ // (前一版按 mb/terminalBend 分支计折,「一弯两用」——末弯同时完成对齐与
414
+ // 入汇——会被重复计一折,h 虚高 λ:实测交错 elbow 场景 688 最优解全程被
415
+ // 抬价,696 解抢先出堆。下界必须全域不高估,不能只修末弯分支。)
416
+ const hFn = (u, dir) => {
417
+ const ux = g.xs[u % W];
418
+ const uy = g.ys[Math.floor(u / W)];
419
+ let best = Infinity;
420
+ for (const e of ends) {
421
+ const dx = Math.abs(ux - e.stub.x);
422
+ const dy = Math.abs(uy - e.stub.y);
423
+ const entry = OPP(e.port.dir);
424
+ // 方向集 bitmask:N=1 E=2 S=4 W=8(入汇向必占一位,与朝向位去重)
425
+ let mask = 1 << entry;
426
+ if (dx > 1e-6)
427
+ mask |= e.stub.x > ux ? 2 : 8;
428
+ if (dy > 1e-6)
429
+ mask |= e.stub.y > uy ? 4 : 1;
430
+ let cnt = 0;
431
+ for (let m = mask; m; m &= m - 1)
432
+ cnt++;
433
+ // dir 不在方向集 → 首个正长程段必转向,再 +1。钳 0 防 cnt=1 且 dir∉集合
434
+ // 之外的退化;终态(dir 对齐/不对齐)由此精确报价——此前两版(分支计折、
435
+ // 纯 cnt−1)分别在一弯两用与终态转身上高估/低估 λ,都会让错误终态抢跑。
436
+ const bendsLb = Math.max(0, cnt - 1 + ((mask >> dir) & 1 ? 0 : 1));
437
+ const cand = dx + dy + bendsLb * cfg.bendPenalty + cfg.minStub;
438
+ if (cand < best)
439
+ best = cand;
440
+ }
441
+ return best === Infinity ? 0 : best;
442
+ };
443
+ const size = W * H * 4;
444
+ const dist = new Float64Array(size).fill(Infinity);
445
+ const parent = new Int32Array(size).fill(-1);
446
+ const bendsArr = new Int16Array(size).fill(0);
447
+ const endSet = new Set(ends.map((e) => e.u));
448
+ const heap = new Heap();
449
+ for (const s of starts) {
450
+ const key = s.u * 4 + s.port.dir;
451
+ if (cfg.minStub < dist[key]) {
452
+ dist[key] = cfg.minStub;
453
+ bendsArr[key] = 0;
454
+ parent[key] = -1;
455
+ heap.push({ f: cfg.minStub + hFn(s.u, s.port.dir), g: cfg.minStub, bends: 0, bias: biasOf(s.u), u: s.u, dir: s.port.dir });
456
+ }
457
+ }
458
+ let goalKey = -1;
459
+ let goalEnd = null;
460
+ while (heap.size > 0) {
461
+ const it = heap.pop();
462
+ const key = it.u * 4 + it.dir;
463
+ if (it.g > dist[key] + 1e-9)
464
+ continue; // 惰性删除:过期堆项
465
+ if (endSet.has(it.u)) {
466
+ // 终态 h 已含入汇精确成本,首个出堆即最优
467
+ goalKey = key;
468
+ goalEnd = ends.find((e) => e.u === it.u);
469
+ break;
470
+ }
471
+ const ux = it.u % W;
472
+ const uy = Math.floor(it.u / W);
473
+ const px = g.xs[ux];
474
+ const py = g.ys[uy];
475
+ for (let nd = 0; nd < 4; nd++) {
476
+ const [nx, ny] = NORMALS[nd];
477
+ const tx = ux + nx;
478
+ const ty = uy + ny;
479
+ if (tx < 0 || ty < 0 || tx >= W || ty >= H)
480
+ continue;
481
+ const v = ty * W + tx;
482
+ if (g.blocked[v])
483
+ continue;
484
+ // 邻接边遮挡:横边查行覆盖,竖边查列覆盖(端点在 shrunk 边界上=允许)
485
+ if (nd === 1 || nd === 3) {
486
+ const cover = g.hCover[ty];
487
+ const xLo = Math.min(px, g.xs[tx]);
488
+ const xHi = Math.max(px, g.xs[tx]);
489
+ let blockedEdge = false;
490
+ for (const r of cover) {
491
+ if (r.x < xHi - 0.01 && r.x + r.w > xLo + 0.01) {
492
+ blockedEdge = true;
493
+ break;
494
+ }
495
+ }
496
+ if (blockedEdge)
497
+ continue;
498
+ }
499
+ else {
500
+ const cover = g.vCover[tx];
501
+ const yLo = Math.min(py, g.ys[ty]);
502
+ const yHi = Math.max(py, g.ys[ty]);
503
+ let blockedEdge = false;
504
+ for (const r of cover) {
505
+ if (r.y < yHi - 0.01 && r.y + r.h > yLo + 0.01) {
506
+ blockedEdge = true;
507
+ break;
508
+ }
509
+ }
510
+ if (blockedEdge)
511
+ continue;
512
+ }
513
+ const len = Math.abs(g.xs[tx] - px) + Math.abs(g.ys[ty] - py);
514
+ const bend = nd !== it.dir ? cfg.bendPenalty : 0;
515
+ const away = nx * (dstCenter.x - px) + ny * (dstCenter.y - py) < 0 ? cfg.uturnPenalty : 0;
516
+ const ng = it.g + len + bend + away;
517
+ const nkey = v * 4 + nd;
518
+ if (ng < dist[nkey] - 1e-9) {
519
+ dist[nkey] = ng;
520
+ parent[nkey] = key;
521
+ bendsArr[nkey] = bendsArr[key] + (bend > 0 ? 1 : 0);
522
+ heap.push({ f: ng + hFn(v, nd), g: ng, bends: bendsArr[nkey], bias: biasOf(v), u: v, dir: nd });
523
+ }
524
+ }
525
+ }
526
+ if (goalKey < 0 || !goalEnd)
527
+ return null;
528
+ // 回溯节点序列(stub_s → … → stub_t)
529
+ const seq = [];
530
+ let k = goalKey;
531
+ while (k >= 0) {
532
+ seq.push(Math.floor(k / 4));
533
+ k = parent[k];
534
+ }
535
+ seq.reverse();
536
+ const pts = seq.map((u) => ({ x: g.xs[u % W], y: g.ys[Math.floor(u / W)] }));
537
+ const start = starts.find((s) => s.u === seq[0]);
538
+ const full = [start.port.pt, start.stub, ...pts.slice(1), goalEnd.port.pt];
539
+ const merged = mergeCollinear(full);
540
+ const validate = (pts) => {
541
+ // 量化后再合并一次:取整可能让原本微歪的相邻段变共线,留冗余点
542
+ const cand = mergeCollinear(quantizePath(pts, cfg.quant));
543
+ return pathClear(cand, act) ? cand : null;
544
+ };
545
+ if (mode === 'free') {
546
+ // 斜线合法 → 视线拉直出斜线捷径。两个候选取几何代价低者(平手取拉直版,
547
+ // 确定性):拉直原样(端口本就贴边)vs 重贴边版(端点朝新首段方向滑动——
548
+ // 拉直常吃掉引出段,重贴边让出段朝向合理;但 borderPoint 沿边滑动可能
549
+ // 更长,不盲取)。正交模式禁重贴(端口钳位点不在中心射线上,重贴会把
550
+ // 首段扭成斜线,破坏正交契约)
551
+ const pulled = stringPull(merged, act);
552
+ const cost = (pts) => {
553
+ let len = 0;
554
+ let bends = 0;
555
+ for (let i = 0; i < pts.length - 1; i++) {
556
+ len += Math.abs(pts[i + 1].x - pts[i].x) + Math.abs(pts[i + 1].y - pts[i].y);
557
+ if (i > 0) {
558
+ const t1x = Math.sign(pts[i].x - pts[i - 1].x);
559
+ const t1y = Math.sign(pts[i].y - pts[i - 1].y);
560
+ const t2x = Math.sign(pts[i + 1].x - pts[i].x);
561
+ const t2y = Math.sign(pts[i + 1].y - pts[i].y);
562
+ if (t1x !== t2x || t1y !== t2y)
563
+ bends++;
564
+ }
565
+ }
566
+ return len + bends * cfg.bendPenalty;
567
+ };
568
+ const plain = validate(pulled);
569
+ const reattached = validate([
570
+ borderPoint(src, pulled[1].x, pulled[1].y),
571
+ ...pulled.slice(1, -1),
572
+ borderPoint(dst, pulled[pulled.length - 2].x, pulled[pulled.length - 2].y),
573
+ ]);
574
+ const best = plain && reattached ? (cost(reattached) < cost(plain) ? reattached : plain) : (plain ?? reattached);
575
+ if (best)
576
+ return best;
577
+ }
578
+ return validate(merged);
579
+ }
580
+ function mergeCollinear(pts) {
581
+ if (pts.length < 3)
582
+ return pts.slice();
583
+ const out = [pts[0]];
584
+ for (let i = 1; i < pts.length - 1; i++) {
585
+ const a = out[out.length - 1];
586
+ const b = pts[i];
587
+ const c = pts[i + 1];
588
+ const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
589
+ if (Math.abs(cross) > 1e-9)
590
+ out.push(b);
591
+ }
592
+ out.push(pts[pts.length - 1]);
593
+ return out;
594
+ }
595
+ function quantizePath(pts, step) {
596
+ return pts.map((p) => ({ x: q(p.x, step), y: q(p.y, step) }));
597
+ }
598
+ /** V3 视线拉直:中间点带安全边距(12px)的贪心合并,从最远可直视点回缩。 */
599
+ function stringPull(pts, act) {
600
+ if (pts.length < 3)
601
+ return pts.slice();
602
+ const pad = act.map((r) => expand(r, 12));
603
+ const out = [pts[0]];
604
+ let i = 0;
605
+ while (i < pts.length - 1) {
606
+ let far = i + 1;
607
+ for (let j = pts.length - 1; j > i + 1; j--) {
608
+ if (segClear(pts[i], pts[j], pad)) {
609
+ far = j;
610
+ break;
611
+ }
612
+ }
613
+ out.push(pts[far]);
614
+ i = far;
615
+ }
616
+ return out;
617
+ }