@qualisoft/ai-skills 1.0.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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +234 -0
  3. package/bin/cli.mjs +331 -0
  4. package/package.json +53 -0
  5. package/skills/erd-visual/SKILL.md +294 -0
  6. package/skills/erd-visual/build.mjs +404 -0
  7. package/skills/erd-visual/engine/dbml.mjs +133 -0
  8. package/skills/erd-visual/engine/ingest.mjs +273 -0
  9. package/skills/erd-visual/engine/layout.mjs +165 -0
  10. package/skills/erd-visual/engine/overview.mjs +314 -0
  11. package/skills/erd-visual/engine/render.mjs +144 -0
  12. package/skills/erd-visual/engine/router.mjs +401 -0
  13. package/skills/erd-visual/engine/verify.mjs +138 -0
  14. package/skills/erd-visual/engine/wire.mjs +115 -0
  15. package/skills/erd-visual/fixtures/crm-large.dbml +1337 -0
  16. package/skills/erd-visual/fixtures/edge-cases.dbml +43 -0
  17. package/skills/erd-visual/fixtures/map-dynamics.json +32 -0
  18. package/skills/erd-visual/fixtures/shop-basic.dbml +78 -0
  19. package/skills/erd-visual/fixtures/src-json/api.json +38 -0
  20. package/skills/erd-visual/fixtures/src-prisma/schema.prisma +44 -0
  21. package/skills/erd-visual/fixtures/src-sql/shop.sql +62 -0
  22. package/skills/erd-visual/readers/csv.mjs +81 -0
  23. package/skills/erd-visual/readers/index.mjs +55 -0
  24. package/skills/erd-visual/readers/jsonschema.mjs +86 -0
  25. package/skills/erd-visual/readers/prisma.mjs +102 -0
  26. package/skills/erd-visual/readers/sql.mjs +205 -0
  27. package/skills/erd-visual/readers/xlsx.mjs +220 -0
  28. package/skills/erd-visual/readers/xml.mjs +130 -0
  29. package/skills/erd-visual/readers/zip.mjs +74 -0
  30. package/skills/erd-visual/viewer/index.html +539 -0
  31. package/skills/project-build/SKILL.md +171 -0
  32. package/skills/project-build/templates/build-rules.md +88 -0
  33. package/skills/project-build/templates/change-log.md +29 -0
  34. package/skills/project-build/templates/coverage.md +49 -0
  35. package/skills/project-build/templates/parity.html +375 -0
  36. package/skills/project-build/templates/theme.css +45 -0
  37. package/skills/project-design/MEDIUMS.md +104 -0
  38. package/skills/project-design/QUESTIONS.md +126 -0
  39. package/skills/project-design/SKILL.md +365 -0
  40. package/skills/project-design/templates/audit.html +689 -0
  41. package/skills/project-design/templates/change-log.md +53 -0
  42. package/skills/project-design/templates/design-rules.md +118 -0
  43. package/skills/project-design/templates/mockup-app.html +96 -0
  44. package/skills/project-design/templates/mockup-slides.html +82 -0
  45. package/skills/project-design/templates/mockup-web.html +45 -0
  46. package/skills/project-design/templates/styleguide.html +436 -0
  47. package/skills/project-design/templates/tokens.css +69 -0
  48. package/skills/project-design/templates/tone-options.html +138 -0
  49. package/skills/project-init/GUIDE.md +370 -0
  50. package/skills/project-init/RUNBOOK.md +192 -0
  51. package/skills/project-init/SKILL.md +382 -0
  52. package/skills/project-init/build.mjs +2898 -0
  53. package/skills/project-init/evals/RUBRIC.md +81 -0
  54. package/skills/project-init/evals/cases/conflicting.expect.json +17 -0
  55. package/skills/project-init/evals/cases/conflicting.md +9 -0
  56. package/skills/project-init/evals/cases/vague-idea.expect.json +11 -0
  57. package/skills/project-init/evals/cases/vague-idea.md +7 -0
  58. package/skills/project-init/evals/cases/well-formed.expect.json +18 -0
  59. package/skills/project-init/evals/cases/well-formed.md +28 -0
  60. package/skills/project-init/markdown.mjs +0 -0
  61. package/skills/project-init/modules/a11y.json +46 -0
  62. package/skills/project-init/modules/ai.json +77 -0
  63. package/skills/project-init/modules/audience.json +48 -0
  64. package/skills/project-init/modules/backend.json +129 -0
  65. package/skills/project-init/modules/brand.json +78 -0
  66. package/skills/project-init/modules/core.json +132 -0
  67. package/skills/project-init/modules/design.json +72 -0
  68. package/skills/project-init/modules/engineering.json +86 -0
  69. package/skills/project-init/modules/mobile.json +22 -0
  70. package/skills/project-init/modules/ops.json +122 -0
  71. package/skills/project-init/modules/process.json +104 -0
  72. package/skills/project-init/modules/product.json +37 -0
  73. package/skills/project-init/modules/ux.json +52 -0
  74. package/skills/project-init/modules/web.json +129 -0
  75. package/skills/project-init/presets/ai-product.json +12 -0
  76. package/skills/project-init/presets/internal-system.json +15 -0
  77. package/skills/project-init/presets/mobile-app.json +20 -0
  78. package/skills/project-init/presets/web-corporate.json +116 -0
  79. package/skills/project-init/schema.json +80 -0
  80. package/skills/project-init/templates/log.md +54 -0
  81. package/skills/project-init/templates/readme.md +62 -0
  82. package/skills/project-init/templates/reference.md +34 -0
  83. package/skills/project-init/templates/register.md +63 -0
  84. package/skills/project-init/templates/spec.md +46 -0
  85. package/skills/project-interview/INTERVIEW.md +221 -0
  86. package/skills/project-interview/SKILL.md +156 -0
  87. package/skills/project-interview/fixtures/brief.md +49 -0
  88. package/skills/project-interview/fixtures/decisions.md +13 -0
  89. package/skills/project-interview/fixtures/open-questions.md +9 -0
  90. package/skills/project-interview/templates/brief.md +179 -0
  91. package/skills/project-interview/templates/decisions.md +55 -0
  92. package/skills/project-interview/templates/open-questions.md +40 -0
  93. package/skills/project-interview/validate.mjs +49 -0
@@ -0,0 +1,401 @@
1
+ /* 직각 배선 라우터 — Hanan 격자 + 굴곡 페널티 A* + 혼잡도 + 채널 분산
2
+ *
3
+ * 혼잡도 인식이 핵심이다. 없으면 A* 가 모든 선을 같은 격자선에 몰아넣어
4
+ * 완전히 겹친다. 이미 지나간 선이 쓴 레인은 비싸지고, 뒤에 오는 선이
5
+ * 알아서 옆 레인으로 우회한다.
6
+ */
7
+
8
+ /** 이진 힙 — 삽입 순서를 tiebreak 으로 써서 결과를 결정적으로 만든다 */
9
+ class Heap {
10
+ constructor() { this.a = []; this.n = 0; }
11
+ push(f, v) {
12
+ this.a.push([f, this.n++, v]);
13
+ let i = this.a.length - 1;
14
+ while (i > 0) {
15
+ const p = (i - 1) >> 1;
16
+ if (this.a[p][0] < this.a[i][0] || (this.a[p][0] === this.a[i][0] && this.a[p][1] <= this.a[i][1])) break;
17
+ [this.a[p], this.a[i]] = [this.a[i], this.a[p]];
18
+ i = p;
19
+ }
20
+ }
21
+ pop() {
22
+ const top = this.a[0], last = this.a.pop();
23
+ if (this.a.length) {
24
+ this.a[0] = last;
25
+ let i = 0;
26
+ for (;;) {
27
+ const l = 2 * i + 1, r = l + 1;
28
+ let s = i;
29
+ if (l < this.a.length && (this.a[l][0] < this.a[s][0] ||
30
+ (this.a[l][0] === this.a[s][0] && this.a[l][1] < this.a[s][1]))) s = l;
31
+ if (r < this.a.length && (this.a[r][0] < this.a[s][0] ||
32
+ (this.a[r][0] === this.a[s][0] && this.a[r][1] < this.a[s][1]))) s = r;
33
+ if (s === i) break;
34
+ [this.a[s], this.a[i]] = [this.a[i], this.a[s]];
35
+ i = s;
36
+ }
37
+ }
38
+ return top;
39
+ }
40
+ get size() { return this.a.length; }
41
+ }
42
+
43
+ export function simplify(pts) {
44
+ const out = [pts[0]];
45
+ for (const p of pts.slice(1)) {
46
+ if (out.length >= 2) {
47
+ const a = out[out.length - 2], b = out[out.length - 1];
48
+ if ((a[0] === b[0] && b[0] === p[0]) || (a[1] === b[1] && b[1] === p[1])) {
49
+ out[out.length - 1] = p;
50
+ continue;
51
+ }
52
+ }
53
+ const last = out[out.length - 1];
54
+ if (p[0] !== last[0] || p[1] !== last[1]) out.push(p);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** 채널 분산이 남긴 미세 꺾임(1~2px 계단)을 없앤다. 직각은 유지한다. */
60
+ export function snap(pts, eps = 3.0) {
61
+ if (!pts || pts.length < 3) return pts;
62
+ const q = pts.map(p => [p[0], p[1]]);
63
+ for (let i = 0; i < q.length - 1; i++) {
64
+ const dx = Math.abs(q[i + 1][0] - q[i][0]);
65
+ const dy = Math.abs(q[i + 1][1] - q[i][1]);
66
+ if (dx > 0 && dx < eps && dy < 0.01) {
67
+ if (i + 1 < q.length - 1) q[i + 1][0] = q[i][0]; else q[i][0] = q[i + 1][0];
68
+ } else if (dy > 0 && dy < eps && dx < 0.01) {
69
+ if (i + 1 < q.length - 1) q[i + 1][1] = q[i][1]; else q[i][1] = q[i + 1][1];
70
+ }
71
+ }
72
+ return simplify(q.map(p => [p[0], p[1]]));
73
+ }
74
+
75
+ /** 경로가 사각형 내부를 지나는가. skip 은 출발/도착 인덱스 집합. */
76
+ export function pierces(pts, rects, skip, inset = 1.5) {
77
+ for (let i = 0; i < pts.length - 1; i++) {
78
+ const [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
79
+ const loX = Math.min(x1, x2), hiX = Math.max(x1, x2);
80
+ const loY = Math.min(y1, y2), hiY = Math.max(y1, y2);
81
+ for (let j = 0; j < rects.length; j++) {
82
+ if (skip && skip.has(j)) continue;
83
+ const r = rects[j];
84
+ if (hiX <= r.x + inset || loX >= r.x + r.w - inset) continue;
85
+ if (hiY <= r.y + inset || loY >= r.y + r.h - inset) continue;
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+
92
+ /** 직각 꺾임을 둥글게 — SVG path d */
93
+ export function rounded(pts, r = 9) {
94
+ if (!pts || pts.length < 2) return '';
95
+ const f = n => n.toFixed(1);
96
+ const d = ['M' + f(pts[0][0]) + ',' + f(pts[0][1])];
97
+ for (let i = 1; i < pts.length - 1; i++) {
98
+ const a = pts[i - 1], b = pts[i], c = pts[i + 1];
99
+ const v1 = [b[0] - a[0], b[1] - a[1]], v2 = [c[0] - b[0], c[1] - b[1]];
100
+ const l1 = Math.abs(v1[0]) + Math.abs(v1[1]);
101
+ const l2 = Math.abs(v2[0]) + Math.abs(v2[1]);
102
+ const rr = Math.min(r, l1 / 2, l2 / 2);
103
+ if (rr < 1) { d.push('L' + f(b[0]) + ',' + f(b[1])); continue; }
104
+ const u1 = [l1 ? v1[0] / l1 : 0, l1 ? v1[1] / l1 : 0];
105
+ const u2 = [l2 ? v2[0] / l2 : 0, l2 ? v2[1] / l2 : 0];
106
+ const p1 = [b[0] - u1[0] * rr, b[1] - u1[1] * rr];
107
+ const p2 = [b[0] + u2[0] * rr, b[1] + u2[1] * rr];
108
+ d.push('L' + f(p1[0]) + ',' + f(p1[1]));
109
+ d.push('Q' + f(b[0]) + ',' + f(b[1]) + ' ' + f(p2[0]) + ',' + f(p2[1]));
110
+ }
111
+ const e = pts[pts.length - 1];
112
+ d.push('L' + f(e[0]) + ',' + f(e[1]));
113
+ return d.join(' ');
114
+ }
115
+
116
+ export class Ortho {
117
+ constructor(rects, W, H, { margin = 16, clear = 9, bounds = null } = {}) {
118
+ this.R = rects.map(r => ({ x: r.x - clear, y: r.y - clear, w: r.w + 2 * clear, h: r.h + 2 * clear }));
119
+ this.W = W; this.H = H; this.margin = margin;
120
+ // 배선이 돌아다닐 수 있는 범위. 지정하지 않으면 캔버스 가장자리 8px 안쪽.
121
+ // 굵은 선(번들)은 가장자리에 붙으면 테두리에 잘려 깨져 보인다.
122
+ this.b = bounds || { x1: 8, y1: 8, x2: W - 8, y2: H - 8 };
123
+ // 열 단위 공간 색인 — 같은 x/w 를 쓰는 카드를 묶어 충돌 검사를 줄인다
124
+ const band = new Map();
125
+ this.R.forEach((r, i) => {
126
+ const k = r.x + '|' + r.w;
127
+ if (!band.has(k)) band.set(k, { x: r.x, w: r.w, m: [] });
128
+ band.get(k).m.push([i, r.y, r.y + r.h]);
129
+ });
130
+ this.bands = [...band.values()];
131
+ this.lanesX = []; this.lanesY = [];
132
+ }
133
+
134
+ lines(extraX, extraY) {
135
+ const xs = new Set([this.b.x1, this.b.x2]), ys = new Set([this.b.y1, this.b.y2]);
136
+ for (const r of this.R) {
137
+ xs.add(r.x - this.margin); xs.add(r.x + r.w + this.margin);
138
+ ys.add(r.y - this.margin); ys.add(r.y + r.h + this.margin);
139
+ }
140
+ for (const v of extraX) xs.add(v);
141
+ for (const v of this.lanesX) xs.add(v);
142
+ for (const v of extraY) ys.add(v);
143
+ for (const v of this.lanesY) ys.add(v);
144
+ // 포트는 사각형 변이라 범위 밖일 수 있다 — 그건 통과시킨다
145
+ const keepX = new Set(extraX), keepY = new Set(extraY);
146
+ return [
147
+ [...xs].filter(v => keepX.has(v) || (v >= this.b.x1 && v <= this.b.x2)).sort((a, b) => a - b),
148
+ [...ys].filter(v => keepY.has(v) || (v >= this.b.y1 && v <= this.b.y2)).sort((a, b) => a - b),
149
+ ];
150
+ }
151
+
152
+ blocked(x1, y1, x2, y2, skip) {
153
+ const loX = Math.min(x1, x2), hiX = Math.max(x1, x2);
154
+ const loY = Math.min(y1, y2), hiY = Math.max(y1, y2);
155
+ for (const b of this.bands) {
156
+ if (hiX <= b.x || loX >= b.x + b.w) continue;
157
+ for (const [i, ry1, ry2] of b.m) {
158
+ if (skip && skip.has(i)) continue;
159
+ if (hiY <= ry1 || loY >= ry2) continue;
160
+ return true;
161
+ }
162
+ }
163
+ return false;
164
+ }
165
+
166
+ segCost() { return 0; } // 하위 클래스가 혼잡도를 얹는다
167
+
168
+ route(p0, p1, skip, bend = 300) {
169
+ const [xs, ys] = this.lines([p0[0], p1[0]], [p0[1], p1[1]]);
170
+ const xi = new Map(xs.map((v, i) => [v, i]));
171
+ const yi = new Map(ys.map((v, i) => [v, i]));
172
+ if (!xi.has(p0[0]) || !xi.has(p1[0]) || !yi.has(p0[1]) || !yi.has(p1[1])) return null;
173
+ const S = [xi.get(p0[0]), yi.get(p0[1])];
174
+ const T = [xi.get(p1[0]), yi.get(p1[1])];
175
+ const NX = xs.length, NY = ys.length;
176
+ const D = [[1, 0], [-1, 0], [0, 1], [0, -1]];
177
+
178
+ const key = (x, y, dx, dy) => ((y * NX + x) * 9) + ((dx + 1) * 3 + (dy + 1));
179
+ const g = new Map(), prev = new Map();
180
+ const start = key(S[0], S[1], 0, 0);
181
+ g.set(start, 0);
182
+ const pq = new Heap();
183
+ pq.push(0, [S[0], S[1], 0, 0, start]);
184
+ let goal = null;
185
+
186
+ while (pq.size) {
187
+ const [f, , st] = pq.pop();
188
+ const [cx, cy, ddx, ddy, sk] = st;
189
+ if (cx === T[0] && cy === T[1]) { goal = st; break; }
190
+ const h0 = Math.abs(xs[T[0]] - xs[cx]) + Math.abs(ys[T[1]] - ys[cy]);
191
+ if ((g.get(sk) ?? Infinity) + 1e-6 < f - h0) continue;
192
+ for (const [dx, dy] of D) {
193
+ const nx = cx + dx, ny = cy + dy;
194
+ if (nx < 0 || nx >= NX || ny < 0 || ny >= NY) continue;
195
+ const x1 = xs[cx], y1 = ys[cy], x2 = xs[nx], y2 = ys[ny];
196
+ if (this.blocked(x1, y1, x2, y2, skip)) continue;
197
+ let cost = Math.abs(x2 - x1) + Math.abs(y2 - y1);
198
+ cost += this.segCost(dx, dy, x1, y1, x2, y2);
199
+ if ((ddx !== 0 || ddy !== 0) && (dx !== ddx || dy !== ddy)) cost += bend;
200
+ const nk = key(nx, ny, dx, dy);
201
+ const ng = g.get(sk) + cost;
202
+ if (ng < (g.get(nk) ?? Infinity) - 1e-9) {
203
+ g.set(nk, ng);
204
+ prev.set(nk, st);
205
+ const h = Math.abs(xs[T[0]] - x2) + Math.abs(ys[T[1]] - y2);
206
+ pq.push(ng + h, [nx, ny, dx, dy, nk]);
207
+ }
208
+ }
209
+ }
210
+ if (!goal) return null;
211
+ const pts = [];
212
+ let st = goal;
213
+ while (prev.has(st[4])) { pts.push([xs[st[0]], ys[st[1]]]); st = prev.get(st[4]); }
214
+ pts.push(p0);
215
+ pts.reverse();
216
+ return simplify(pts);
217
+ }
218
+ }
219
+
220
+ /** 혼잡도 인식 라우터 */
221
+ export class OrthoC extends Ortho {
222
+ constructor(rects, W, H, opt = {}) {
223
+ super(rects, W, H, opt);
224
+ this.lanesX = opt.lanesX || [];
225
+ this.lanesY = opt.lanesY || [];
226
+ this.tol = opt.tol ?? 7.0;
227
+ this.congw = opt.cong ?? 90.0;
228
+ this.used = new Map();
229
+ }
230
+
231
+ cong(axis, coord, lo, hi) {
232
+ if (hi <= lo) return 0;
233
+ let tot = 0;
234
+ const b0 = Math.round(coord / this.tol);
235
+ for (let b = b0 - 1; b <= b0 + 1; b++) {
236
+ const list = this.used.get(axis + '|' + b);
237
+ if (!list) continue;
238
+ for (const [a0, a1, c] of list) {
239
+ if (Math.abs(c - coord) > this.tol) continue;
240
+ const ov = Math.min(hi, a1) - Math.max(lo, a0);
241
+ if (ov > 0) tot += ov;
242
+ }
243
+ }
244
+ return tot;
245
+ }
246
+
247
+ segCost(dx, dy, x1, y1, x2, y2) {
248
+ if (dx === 0) return this.congw * this.cong('v', x1, Math.min(y1, y2), Math.max(y1, y2)) / 100;
249
+ return this.congw * this.cong('h', y1, Math.min(x1, x2), Math.max(x1, x2)) / 100;
250
+ }
251
+
252
+ /** 배선된 경로를 혼잡도에 기록한다 */
253
+ commit(pts) {
254
+ for (let i = 0; i < pts.length - 1; i++) {
255
+ const [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
256
+ const add = (axis, coord, lo, hi) => {
257
+ const k = axis + '|' + Math.round(coord / this.tol);
258
+ if (!this.used.has(k)) this.used.set(k, []);
259
+ this.used.get(k).push([lo, hi, coord]);
260
+ };
261
+ if (Math.abs(x1 - x2) < 0.5 && Math.abs(y1 - y2) >= 0.5)
262
+ add('v', x1, Math.min(y1, y2), Math.max(y1, y2));
263
+ else if (Math.abs(y1 - y2) < 0.5 && Math.abs(x1 - x2) >= 0.5)
264
+ add('h', y1, Math.min(x1, x2), Math.max(x1, x2));
265
+ }
266
+ }
267
+ }
268
+
269
+ /** 엣지마다 출발/도착 변과 포트 좌표를 배정한다.
270
+ * 같은 변을 쓰는 엣지들은 상대편 위치 순으로 정렬해 변 위에 고르게 벌린다. */
271
+ export function ports(edges, rects, minGap = 13) {
272
+ const sideOf = [];
273
+ for (const [ai, bi] of edges) {
274
+ const A = rects[ai], B = rects[bi];
275
+ const acx = A.x + A.w / 2, acy = A.y + A.h / 2;
276
+ const bcx = B.x + B.w / 2, bcy = B.y + B.h / 2;
277
+ const dx = bcx - acx, dy = bcy - acy;
278
+ const ox = Math.min(A.x + A.w, B.x + B.w) - Math.max(A.x, B.x);
279
+ const oy = Math.min(A.y + A.h, B.y + B.h) - Math.max(A.y, B.y);
280
+ let sa, sb;
281
+ if (oy > 24 && Math.abs(dx) > 8) { sa = dx > 0 ? 'R' : 'L'; sb = dx > 0 ? 'L' : 'R'; }
282
+ else if (ox > 24 && Math.abs(dy) > 8) { sa = dy > 0 ? 'B' : 'T'; sb = dy > 0 ? 'T' : 'B'; }
283
+ else if (Math.abs(dx) >= Math.abs(dy)) { sa = dx > 0 ? 'R' : 'L'; sb = dx > 0 ? 'L' : 'R'; }
284
+ else { sa = dy > 0 ? 'B' : 'T'; sb = dy > 0 ? 'T' : 'B'; }
285
+ sideOf.push([sa, sb]);
286
+ }
287
+
288
+ const grp = new Map();
289
+ edges.forEach(([ai, bi], k) => {
290
+ const [sa, sb] = sideOf[k];
291
+ const ka = ai + '|' + sa, kb = bi + '|' + sb;
292
+ if (!grp.has(ka)) grp.set(ka, []);
293
+ if (!grp.has(kb)) grp.set(kb, []);
294
+ grp.get(ka).push([k, 'a']);
295
+ grp.get(kb).push([k, 'b']);
296
+ });
297
+
298
+ const pos = new Map();
299
+ for (const [gk, members] of grp) {
300
+ const ni = +gk.split('|')[0], side = gk.split('|')[1];
301
+ const R = rects[ni];
302
+ const keyOf = ([k, end]) => {
303
+ const oi = end === 'a' ? edges[k][1] : edges[k][0];
304
+ const O = rects[oi];
305
+ return (side === 'L' || side === 'R') ? O.y + O.h / 2 : O.x + O.w / 2;
306
+ };
307
+ members.sort((p, q) => keyOf(p) - keyOf(q));
308
+ const n = members.length;
309
+ const span = (side === 'L' || side === 'R') ? R.h : R.w;
310
+ const usable = Math.max(span - 18, 8);
311
+ const step = Math.min(minGap, usable / Math.max(n, 1));
312
+ const base = (side === 'L' || side === 'R') ? R.y + R.h / 2 : R.x + R.w / 2;
313
+ members.forEach((m, idx) => {
314
+ const off = (idx - (n - 1) / 2) * step;
315
+ let p;
316
+ if (side === 'L') p = [R.x, base + off];
317
+ else if (side === 'R') p = [R.x + R.w, base + off];
318
+ else if (side === 'T') p = [base + off, R.y];
319
+ else p = [base + off, R.y + R.h];
320
+ pos.set(m[0] + '|' + m[1], p);
321
+ });
322
+ }
323
+ return { sideOf, port: pos };
324
+ }
325
+
326
+ function laneOffsets(paths, gap, tol) {
327
+ const buckets = new Map();
328
+ paths.forEach((pts, pi) => {
329
+ if (!pts || pts.length < 4) return;
330
+ for (let i = 1; i < pts.length - 2; i++) {
331
+ const [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
332
+ if (Math.abs(x1 - x2) < 0.5 && Math.abs(y1 - y2) >= 0.5) {
333
+ const k = 'v|' + Math.round(x1 / tol);
334
+ if (!buckets.has(k)) buckets.set(k, []);
335
+ buckets.get(k).push([pi, i, Math.min(y1, y2), Math.max(y1, y2), 'v']);
336
+ } else if (Math.abs(y1 - y2) < 0.5 && Math.abs(x1 - x2) >= 0.5) {
337
+ const k = 'h|' + Math.round(y1 / tol);
338
+ if (!buckets.has(k)) buckets.set(k, []);
339
+ buckets.get(k).push([pi, i, Math.min(x1, x2), Math.max(x1, x2), 'h']);
340
+ }
341
+ }
342
+ });
343
+ const want = new Map();
344
+ const bump = (pi, i, axis, v) => {
345
+ const k = pi + '|' + i + '|' + axis;
346
+ want.set(k, (want.get(k) || 0) + v);
347
+ };
348
+ for (const items of buckets.values()) {
349
+ if (items.length < 2) continue;
350
+ items.sort((a, b) => a[2] - b[2] || a[3] - b[3]);
351
+ const laneEnd = [], assign = [];
352
+ items.forEach(([, , lo, hi], k) => {
353
+ let put = -1;
354
+ for (let li = 0; li < laneEnd.length; li++) {
355
+ if (lo >= laneEnd[li] - 0.5) { laneEnd[li] = hi; put = li; break; }
356
+ }
357
+ if (put < 0) { laneEnd.push(hi); put = laneEnd.length - 1; }
358
+ assign[k] = put;
359
+ });
360
+ const n = laneEnd.length;
361
+ if (n < 2) continue;
362
+ items.forEach(([pi, i, , , axis], k) => {
363
+ const off = (assign[k] - (n - 1) / 2) * gap;
364
+ bump(pi, i, axis, off);
365
+ bump(pi, i + 1, axis, off);
366
+ });
367
+ }
368
+ return want;
369
+ }
370
+
371
+ /** 겹치는 평행선을 레인으로 벌린다.
372
+ * 통째로 되돌리지 않고 경로별로 배율을 낮춰가며 안 뚫리는 최대치를 쓴다.
373
+ * 분산은 라우팅 다음에 점을 옮기므로 관통 검사를 여기서 반드시 해야 한다. */
374
+ export function spread(paths, rects, skips, opt = {}) {
375
+ const { gap = 9, tol = 6.0, rounds = 3, eps = 3.0, bounds = null } = opt;
376
+ // 분산은 라우팅 다음에 점을 옮긴다. 범위 검사를 안 하면
377
+ // 가장자리를 타던 선이 캔버스 밖으로 밀려나 잘려 보인다 — 실제로 그랬다.
378
+ const inBounds = pts => !bounds || pts.every(([x, y]) =>
379
+ x >= bounds.x1 - 0.5 && x <= bounds.x2 + 0.5 && y >= bounds.y1 - 0.5 && y <= bounds.y2 + 0.5);
380
+ const scales = opt.scales || [1.0, 0.78, 0.56, 0.34, 0.16];
381
+ let cur = paths.map(p => (p || []).map(q => [q[0], q[1]]));
382
+ for (let r = 0; r < rounds; r++) {
383
+ const want = laneOffsets(cur, gap, tol);
384
+ cur = cur.map((pts, pi) => {
385
+ if (!pts || pts.length < 4) return pts;
386
+ for (const sc of scales) {
387
+ const q = pts.map(p => [p[0], p[1]]);
388
+ for (let j = 1; j < q.length - 1; j++) {
389
+ const dv = (want.get(pi + '|' + j + '|v') || 0) * sc;
390
+ const dh = (want.get(pi + '|' + j + '|h') || 0) * sc;
391
+ if (dv) q[j][0] += dv;
392
+ if (dh) q[j][1] += dh;
393
+ }
394
+ const cand = snap(q, eps);
395
+ if (!pierces(cand, rects, skips[pi]) && inBounds(cand)) return cand;
396
+ }
397
+ return pts;
398
+ });
399
+ }
400
+ return cur;
401
+ }
@@ -0,0 +1,138 @@
1
+ /* 검증 — 관통 · 겹침 · 텍스트 넘침
2
+ *
3
+ * 이 프로젝트에서 눈으로 못 본 결함을 전부 여기서 잡았다.
4
+ * 검사를 배선 '전에' 돌리면 통과한 것처럼 보인다 (채널 분산이 점을 옮기기 때문).
5
+ * 반드시 최종 경로로 판정한다.
6
+ */
7
+
8
+ import { pierces } from './router.mjs';
9
+
10
+ /** 한글은 1em, 라틴은 0.56em 로 본 대략적인 렌더 폭.
11
+ * 정확한 값은 브라우저만 안다 — verify.html 이 getBBox 로 다시 잰다. */
12
+ export function textWidth(s, px, ko = 1.0, asc = 0.56) {
13
+ let w = 0;
14
+ for (const ch of s) w += px * (ch.codePointAt(0) > 0x2000 ? ko : asc);
15
+ return w;
16
+ }
17
+
18
+ /** 폭에 맞게 자른다. 글자수로 자르면 한글/영문이 제각각 깨진다. */
19
+ export function fitText(s, maxPx, px, ko = 1.0, asc = 0.56) {
20
+ if (textWidth(s, px, ko, asc) <= maxPx) return s;
21
+ const ell = textWidth('…', px, ko, asc);
22
+ let out = '';
23
+ for (const ch of s) {
24
+ if (textWidth(out + ch, px, ko, asc) + ell > maxPx) break;
25
+ out += ch;
26
+ }
27
+ return out ? out.replace(/\s+$/, '') + '…' : '…';
28
+ }
29
+
30
+ function segments(paths) {
31
+ const out = [];
32
+ paths.forEach((pts, pi) => {
33
+ if (!pts) return;
34
+ for (let i = 0; i < pts.length - 1; i++) {
35
+ const [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
36
+ if (Math.abs(x1 - x2) < 0.5 && Math.abs(y1 - y2) >= 0.5)
37
+ out.push([pi, 'v', x1, Math.min(y1, y2), Math.max(y1, y2)]);
38
+ else if (Math.abs(y1 - y2) < 0.5 && Math.abs(x1 - x2) >= 0.5)
39
+ out.push([pi, 'h', y1, Math.min(x1, x2), Math.max(x1, x2)]);
40
+ }
41
+ });
42
+ return out;
43
+ }
44
+
45
+ /** 같은 축·같은 좌표에서 실제로 겹쳐 보이는 구간 쌍을 센다 */
46
+ export function measureOverlap(paths, tol = 2.0, minLen = 12.0) {
47
+ const S = segments(paths);
48
+ const buck = new Map();
49
+ for (const s of S) {
50
+ const k = s[1] + '|' + Math.round(s[2] / tol);
51
+ if (!buck.has(k)) buck.set(k, []);
52
+ buck.get(k).push(s);
53
+ }
54
+ let pairs = 0, total = 0;
55
+ for (const v of buck.values()) {
56
+ for (let i = 0; i < v.length; i++) {
57
+ for (let j = i + 1; j < v.length; j++) {
58
+ const a = v[i], b = v[j];
59
+ if (a[0] === b[0]) continue;
60
+ if (Math.abs(a[2] - b[2]) > tol) continue;
61
+ const ov = Math.min(a[4], b[4]) - Math.max(a[3], b[3]);
62
+ if (ov > minLen) { pairs++; total += ov; }
63
+ }
64
+ }
65
+ }
66
+ return { segments: S.length, overlapPairs: pairs, overlapLen: Math.round(total) };
67
+ }
68
+
69
+ /** 배선이 카드를 뚫는가 */
70
+ export function checkPierce(paths, rects, skips) {
71
+ const bad = [];
72
+ paths.forEach((pts, i) => {
73
+ if (pts && pierces(pts, rects, skips[i], 1.5)) bad.push(i);
74
+ });
75
+ return bad;
76
+ }
77
+
78
+ /** 카드끼리 겹치는가 */
79
+ export function checkCardOverlap(rects, names = []) {
80
+ const bad = [];
81
+ for (let i = 0; i < rects.length; i++) {
82
+ for (let j = i + 1; j < rects.length; j++) {
83
+ const a = rects[i], b = rects[j];
84
+ if (a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y)
85
+ bad.push([names[i] || i, names[j] || j]);
86
+ }
87
+ }
88
+ return bad;
89
+ }
90
+
91
+ /** 카드 안 텍스트가 카드 밖으로 나가는가 (추정 폭 기준) */
92
+ export function checkTextFit(items) {
93
+ const bad = [];
94
+ for (const it of items) {
95
+ const w = textWidth(it.text, it.px, it.ko ?? 1.0, it.asc ?? 0.56);
96
+ if (w > it.maxPx + 0.5) bad.push({ ...it, width: Math.round(w), over: Math.round(w - it.maxPx) });
97
+ }
98
+ return bad;
99
+ }
100
+
101
+ /** 캔버스를 벗어나는 좌표가 있는가 */
102
+ export function checkBounds(rects, W, H) {
103
+ return rects.filter(r => r.x < 0 || r.y < 0 || r.x + r.w > W || r.y + r.h > H);
104
+ }
105
+
106
+ /** 배선이 캔버스를 벗어나는가.
107
+ * 카드만 검사하면 놓친다 — 굵은 번들이 가장자리를 타다 밀려 나간 적이 있다. */
108
+ export function checkPathBounds(paths, W, H, pad = 0) {
109
+ const bad = [];
110
+ paths.forEach((pts, i) => {
111
+ if (!pts) return;
112
+ for (const [x, y] of pts) {
113
+ if (x < -pad || y < -pad || x > W + pad || y > H + pad) {
114
+ bad.push({ i, at: [Math.round(x), Math.round(y)] });
115
+ break;
116
+ }
117
+ }
118
+ });
119
+ return bad;
120
+ }
121
+
122
+ /** 전체 판정 — 필수 항목이 하나라도 0 이 아니면 실패 */
123
+ export function report(res) {
124
+ const must = [
125
+ ['카드 관통', res.pierce.length],
126
+ ['카드 겹침', res.cardOverlap.length],
127
+ ['텍스트 넘침', res.textFit.length],
128
+ ['캔버스 이탈', res.bounds.length + (res.pathBounds ? res.pathBounds.length : 0)
129
+ + (res.bundleBounds ? res.bundleBounds.length : 0)],
130
+ ];
131
+ const failed = must.filter(([, n]) => n > 0);
132
+ return {
133
+ ok: failed.length === 0,
134
+ must,
135
+ failed,
136
+ overlap: res.overlap,
137
+ };
138
+ }
@@ -0,0 +1,115 @@
1
+ /* 배선 오케스트레이션 — Node 와 브라우저가 같은 경로를 타게 하는 단일 진입점 */
2
+
3
+ import { OrthoC, ports, spread } from './router.mjs';
4
+ import { isSys } from './layout.mjs';
5
+
6
+ /** 게이트·행 사이에 레인을 깔아 A* 가 쓸 수 있는 선을 늘린다 */
7
+ function lanes(lay) {
8
+ const g = lay.geo;
9
+ const X = [];
10
+ const nCol = lay.columns.length;
11
+ for (let i = 0; i < nCol - 1; i++) {
12
+ const gx = g.PAD + i * (g.CW + g.GUT) + g.CW;
13
+ for (let k = 1; k <= 10; k++) X.push(gx + g.GUT * k / 11);
14
+ }
15
+ X.push(g.PAD - 26, g.PAD + nCol * (g.CW + g.GUT) - g.GUT + g.CW + 26);
16
+
17
+ const Y = [g.TOP + g.HEAD - 30, g.TOP + g.HEAD - 17, lay.H - 84, lay.H - 70, lay.H - 56];
18
+ for (let j = 0; j < lay.maxRows; j++) {
19
+ const yb = g.TOP + g.HEAD + j * (g.CH + g.CG) + g.CH;
20
+ for (const k of [1, 2, 3]) Y.push(yb + g.CG * k / 4);
21
+ }
22
+ return { X, Y };
23
+ }
24
+
25
+ export function wire(model, lay) {
26
+ const g = lay.geo;
27
+ const nCol = lay.columns.length;
28
+ const names = [...lay.pos.keys()];
29
+ const NI = new Map(names.map((t, i) => [t, i]));
30
+ const RECT = names.map(t => {
31
+ const p = lay.pos.get(t);
32
+ return { x: p.x, y: p.y, w: p.w, h: p.h };
33
+ });
34
+ const EDG = model.refs.map(r => [NI.get(r.from), NI.get(r.to)]);
35
+ const skips = EDG.map(([a, b]) => new Set([a, b]));
36
+ const L = lanes(lay);
37
+
38
+ // ── 개별 배선 ────────────────────────────────────────────
39
+ const { port } = ports(EDG, RECT, 9);
40
+ const eBounds = { x1: 16, y1: g.TOP - 14, x2: lay.W - 16, y2: lay.H - g.FOOT + 40 };
41
+ const RT = new OrthoC(RECT, lay.W, lay.H,
42
+ { margin: 15, clear: 8, bounds: eBounds, lanesX: L.X, lanesY: L.Y, tol: 7.0, cong: 150.0 });
43
+
44
+ // 긴 선부터. 짧은 선이 먼저 자리를 잡으면 긴 선이 크게 돈다.
45
+ const order = model.refs.map((_, k) => k).sort((a, b) => {
46
+ const da = Math.abs(lay.pos.get(model.refs[a].from).col - lay.pos.get(model.refs[a].to).col);
47
+ const db = Math.abs(lay.pos.get(model.refs[b].from).col - lay.pos.get(model.refs[b].to).col);
48
+ return db - da;
49
+ });
50
+
51
+ const raw = new Array(EDG.length);
52
+ let fail = 0;
53
+ for (const k of order) {
54
+ const [ai, bi] = EDG[k];
55
+ const p0 = port.get(k + '|a'), p1 = port.get(k + '|b');
56
+ const pts = RT.route(p0, p1, new Set([ai, bi]), 340);
57
+ if (!pts) { fail++; raw[k] = [p0, p1]; } else { RT.commit(pts); raw[k] = pts; }
58
+ }
59
+ const paths = spread(raw, RECT, skips, { gap: 9, tol: 7.0, rounds: 4, bounds: eBounds });
60
+
61
+ // ── 도메인 번들 ──────────────────────────────────────────
62
+ const cnt = new Map();
63
+ for (const r of model.refs) {
64
+ const ga = lay.pos.get(r.from).dom, gb = lay.pos.get(r.to).dom;
65
+ if (ga === gb) continue;
66
+ // 이스케이프로 쓴다 — 이유는 layout.mjs orderColumns 주석 참조
67
+ const k = ga + '\u001f' + gb;
68
+ cnt.set(k, (cnt.get(k) || 0) + 1);
69
+ }
70
+ const DIX = new Map(lay.domBounds.map((r, i) => [r.dom, i]));
71
+ const bKeys = [], bEdges = [], bCount = [];
72
+ for (const [k, n] of cnt) {
73
+ const [ga, gb] = k.split('\u001f');
74
+ if (!DIX.has(ga) || !DIX.has(gb)) continue;
75
+ bKeys.push([ga, gb]);
76
+ bEdges.push([DIX.get(ga), DIX.get(gb)]);
77
+ bCount.push(n);
78
+ }
79
+ const bRect = lay.domBounds.map(r => ({ x: r.x, y: r.y, w: r.w, h: r.h }));
80
+ const bSkips = bEdges.map(([a, b]) => new Set([a, b]));
81
+
82
+ const bLanesX = [];
83
+ for (let i = 0; i < nCol - 1; i++) {
84
+ const gx = g.PAD + i * (g.CW + g.GUT) + g.CW;
85
+ for (let k = 1; k <= 6; k++) bLanesX.push(gx + g.GUT * k / 7);
86
+ }
87
+ const y0 = lay.domBounds.length ? Math.min(...lay.domBounds.map(r => r.y)) : g.TOP;
88
+ // 번들은 굵다(최대 7.6px). 캔버스 가장자리를 타면 테두리에 잘려 깨져 보이고,
89
+ // 제목·범례 위로 지나가면 글자를 덮는다. 그래서 다닐 수 있는 띠를 좁혀 준다.
90
+ const bBounds = { x1: 24, y1: g.TOP - 8, x2: lay.W - 24, y2: lay.H - g.FOOT + 26 };
91
+ const bLanesY = [y0 - 46, y0 - 30, bBounds.y2 - 30, bBounds.y2 - 16, bBounds.y2 - 4]
92
+ .filter(v => v >= bBounds.y1 && v <= bBounds.y2);
93
+
94
+ const bp = ports(bEdges, bRect, 16).port;
95
+ const BRT = new OrthoC(bRect, lay.W, lay.H,
96
+ { margin: 20, clear: 11, bounds: bBounds, lanesX: bLanesX, lanesY: bLanesY,
97
+ tol: 15.0, cong: 320.0 });
98
+ const bOrder = bEdges.map((_, k) => k).sort((a, b) => bCount[b] - bCount[a]);
99
+ const bRaw = new Array(bEdges.length);
100
+ for (const k of bOrder) {
101
+ const [ai, bi] = bEdges[k];
102
+ const p0 = bp.get(k + '|a'), p1 = bp.get(k + '|b');
103
+ const pts = BRT.route(p0, p1, new Set([ai, bi]), 380);
104
+ if (pts) BRT.commit(pts);
105
+ bRaw[k] = pts || [p0, p1];
106
+ }
107
+ const bundlePaths = spread(bRaw, bRect, bSkips,
108
+ { gap: 17, tol: 13.0, rounds: 4, bounds: bBounds });
109
+
110
+ return {
111
+ names, RECT, EDG, skips, paths, fail,
112
+ bundlePaths, bundleKeys: bKeys, bundleCount: bCount, bundleRect: bRect, bundleSkips: bSkips,
113
+ isSysRef: model.refs.map(isSys),
114
+ };
115
+ }