copperhead 0.8.1 → 0.10.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 (105) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +13 -5
  3. package/dist/agent/filetools.js +24 -1
  4. package/dist/agent/filetools.js.map +1 -1
  5. package/dist/agent/ledger.js +24 -0
  6. package/dist/agent/ledger.js.map +1 -1
  7. package/dist/agent/loop.js +67 -62
  8. package/dist/agent/loop.js.map +1 -1
  9. package/dist/agent/prompts.js +4 -3
  10. package/dist/agent/prompts.js.map +1 -1
  11. package/dist/agent/providers/openai.js +28 -6
  12. package/dist/agent/providers/openai.js.map +1 -1
  13. package/dist/agent/providers/tool-protocol.js +21 -0
  14. package/dist/agent/providers/tool-protocol.js.map +1 -1
  15. package/dist/agent/recovery.js +95 -1
  16. package/dist/agent/recovery.js.map +1 -1
  17. package/dist/agent/response-cache.js +18 -2
  18. package/dist/agent/response-cache.js.map +1 -1
  19. package/dist/agent/tools.js +185 -1
  20. package/dist/agent/tools.js.map +1 -1
  21. package/dist/agent/transcript.js +2 -0
  22. package/dist/agent/transcript.js.map +1 -1
  23. package/dist/cli.js +77 -2
  24. package/dist/cli.js.map +1 -1
  25. package/dist/commands/check.js +33 -1
  26. package/dist/commands/check.js.map +1 -1
  27. package/dist/commands/create.js +282 -26
  28. package/dist/commands/create.js.map +1 -1
  29. package/dist/commands/doctor.js +211 -11
  30. package/dist/commands/doctor.js.map +1 -1
  31. package/dist/config.js +61 -4
  32. package/dist/config.js.map +1 -1
  33. package/dist/kicad/bootstrap.js +24 -3
  34. package/dist/kicad/bootstrap.js.map +1 -1
  35. package/dist/kicad/cli.js +7 -26
  36. package/dist/kicad/cli.js.map +1 -1
  37. package/dist/kicad/dossier.js +207 -0
  38. package/dist/kicad/dossier.js.map +1 -0
  39. package/dist/kicad/draft/draft.js +132 -0
  40. package/dist/kicad/draft/draft.js.map +1 -0
  41. package/dist/kicad/draft/engine.js +2389 -0
  42. package/dist/kicad/draft/engine.js.map +1 -0
  43. package/dist/kicad/draft/ir.js +368 -0
  44. package/dist/kicad/draft/ir.js.map +1 -0
  45. package/dist/kicad/draft/symsource.js +490 -0
  46. package/dist/kicad/draft/symsource.js.map +1 -0
  47. package/dist/kicad/emit.js +181 -0
  48. package/dist/kicad/emit.js.map +1 -0
  49. package/dist/kicad/fab.js +13 -0
  50. package/dist/kicad/fab.js.map +1 -1
  51. package/dist/kicad/legibility.js +561 -0
  52. package/dist/kicad/legibility.js.map +1 -0
  53. package/dist/kicad/score.js +261 -0
  54. package/dist/kicad/score.js.map +1 -0
  55. package/dist/kicad/sexp.js +262 -10
  56. package/dist/kicad/sexp.js.map +1 -1
  57. package/dist/kicad/symlib.js +346 -16
  58. package/dist/kicad/symlib.js.map +1 -1
  59. package/dist/memory/bom-table.js +108 -34
  60. package/dist/memory/bom-table.js.map +1 -1
  61. package/dist/memory/scaffold.js +6 -0
  62. package/dist/memory/scaffold.js.map +1 -1
  63. package/dist/openspec/cli.js +2 -1
  64. package/dist/openspec/cli.js.map +1 -1
  65. package/dist/util/preflight.js +17 -0
  66. package/dist/util/preflight.js.map +1 -1
  67. package/dist/util/redact.js +12 -2
  68. package/dist/util/redact.js.map +1 -1
  69. package/package.json +9 -7
  70. package/src/agent/filetools.ts +26 -1
  71. package/src/agent/ledger.ts +24 -0
  72. package/src/agent/loop.ts +88 -65
  73. package/src/agent/prompts.ts +4 -3
  74. package/src/agent/providers/openai.ts +38 -4
  75. package/src/agent/providers/tool-protocol.ts +22 -0
  76. package/src/agent/recovery.ts +94 -1
  77. package/src/agent/response-cache.ts +17 -1
  78. package/src/agent/tools.ts +189 -1
  79. package/src/agent/transcript.ts +6 -0
  80. package/src/cli.ts +73 -2
  81. package/src/commands/check.ts +51 -1
  82. package/src/commands/create.ts +278 -22
  83. package/src/commands/doctor.ts +219 -12
  84. package/src/config.ts +107 -2
  85. package/src/kicad/bootstrap.ts +24 -3
  86. package/src/kicad/cli.ts +6 -19
  87. package/src/kicad/dossier.ts +217 -0
  88. package/src/kicad/draft/draft.ts +171 -0
  89. package/src/kicad/draft/engine.ts +2466 -0
  90. package/src/kicad/draft/ir.ts +416 -0
  91. package/src/kicad/draft/symsource.ts +535 -0
  92. package/src/kicad/emit.ts +236 -0
  93. package/src/kicad/fab.ts +15 -0
  94. package/src/kicad/legibility.ts +646 -0
  95. package/src/kicad/score.ts +323 -0
  96. package/src/kicad/sexp.ts +339 -10
  97. package/src/kicad/symlib.ts +364 -18
  98. package/src/memory/bom-table.ts +119 -31
  99. package/src/memory/scaffold.ts +6 -0
  100. package/src/openspec/cli.ts +3 -2
  101. package/src/util/preflight.ts +18 -0
  102. package/src/util/redact.ts +12 -2
  103. package/dist/memory/synap.js +0 -152
  104. package/dist/memory/synap.js.map +0 -1
  105. package/src/memory/synap.ts +0 -217
@@ -0,0 +1,2466 @@
1
+ import type { Bounds } from '../sexp.js';
2
+ import { knum, type PlacementModel, type EmitSymbol } from '../emit.js';
3
+ import { powerSymbolSource, pwrFlagSource, type ResolvedSymbol, type DraftPin } from './symsource.js';
4
+ import type { SchematicIntent, IntentNet, IntentPart, ValidatedIntent } from './ir.js';
5
+
6
+ /**
7
+ * The rule-based deterministic drafting engine (design D1/D2). All geometry is
8
+ * computed in integer multiples of the 1.27mm grid, so every pin lands on-grid
9
+ * by construction. No randomness, no clock, no environment-dependent ordering:
10
+ * identical IR yields an identical placement model on every machine.
11
+ */
12
+
13
+ /** The grid. Every symbol origin and wire endpoint is an integer multiple. */
14
+ const U = 1.27;
15
+ /** Stub length from a pin to its label/power symbol, in grid units. */
16
+ const STUB = 2;
17
+ /** Cell margin around a symbol body (room for stubs, labels, text), in units. */
18
+ const MARGIN = 6;
19
+ /** Vertical gap between rows and horizontal channel between columns, units. */
20
+ const ROW_GAP = 4;
21
+ const CHANNEL = 8;
22
+ /** Gap between group boxes, units. */
23
+ const GROUP_GAP = 8;
24
+ /** Local nets up to this many endpoints may be wired (design D2). */
25
+ const MAX_WIRED_ENDPOINTS = 4;
26
+ /** Wire-span budget in mm beyond which a net becomes labels. */
27
+ const MAX_WIRE_SPAN = 50.8;
28
+ /** Label text metrics, matching the legibility checker's conservative box. */
29
+ const LABEL_HEIGHT = 1.27;
30
+ const LABEL_ADVANCE = 0.6;
31
+ /** How far a colliding label may ride its stub outward, in grid units.
32
+ * Deep enough to carry a bottom-pin label past the routing channel that runs
33
+ * under its connector (#220 phase 2); rungs stay ordered nearest-first, so a
34
+ * label that used to clear at rung n still clears at rung n. */
35
+ const MAX_LABEL_NUDGE = 8;
36
+ /**
37
+ * How far a power stub may be pulled in or pushed out to clear a foreign
38
+ * connection point, in grid units. Bounded so the symbol stays visibly attached
39
+ * to the pin it serves; past this the merged-net gate is the better answer.
40
+ */
41
+ const MAX_POWER_STUB_SHIFT = 4;
42
+ /**
43
+ * Fraction of labels allowed to still overlap a foreign net's label text after
44
+ * the de-collision pass has done what it can.
45
+ *
46
+ * Overlapping text boxes and coincident label POINTS are different failures and
47
+ * are treated differently. A shared point merges two nets (`findMergedNets`) and
48
+ * is always refused — the drawn netlist would not be the IR's. Overlapping text
49
+ * is a legibility defect: the sheet is harder to read, the netlist is correct.
50
+ * Refusing a whole draft over the second kind is what turned a real placement
51
+ * bug into a pipeline that could not finish, so a small budget is tolerated,
52
+ * counted, and reported rather than gated.
53
+ */
54
+ const LABEL_OVERLAP_BUDGET = 0.02;
55
+
56
+ const ceilU = (mm: number): number => Math.ceil(mm / U - 1e-9);
57
+ const grid = (units: number): number => Math.round(units) * U;
58
+
59
+ /**
60
+ * Two coordinates are the same POINT iff they emit identically: comparisons and
61
+ * map keys go through `knum`, the emitter's rounding, because KiCad's
62
+ * connectivity sees the rounded file, not the engine's float dust —
63
+ * 13.969999999999999 and 13.97 are one coordinate on the sheet. Raw float keys
64
+ * here would let two distinct nets whose labels differ by dust but emit to the
65
+ * same point evade the merged-net refusal.
66
+ */
67
+ const sameCoord = (a: number, b: number): boolean => knum(a) === knum(b);
68
+ const pointKey = (x: number, y: number): string => `${knum(x)},${knum(y)}`;
69
+
70
+ /** Standard landscape sheets, smallest first, for content-derived paper. */
71
+ const PAPERS: { name: string; w: number; h: number }[] = [
72
+ { name: 'A5', w: 210, h: 148 },
73
+ { name: 'A4', w: 297, h: 210 },
74
+ { name: 'A3', w: 420, h: 297 },
75
+ { name: 'A2', w: 594, h: 420 },
76
+ { name: 'A1', w: 841, h: 594 },
77
+ { name: 'A0', w: 1189, h: 841 },
78
+ ];
79
+ const FRAME = 10;
80
+ const TITLE_STRIP = 30;
81
+ /** Max pin-to-pin gap, grid units, for chaining a passive bank on one trunk
82
+ * (#233): wide enough for two-pin parts sitting in adjacent COLUMNS (cell
83
+ * width plus the channel, ~23 units), tight enough that a trunk never spans
84
+ * unrelated structure — and every join is still vetoed by the body-crossing
85
+ * and touches-foreign checks regardless of distance. */
86
+ const BANK_PITCH_MAX = 32;
87
+ /** Natural-fit utilization below which the paper pass tries smaller sheets
88
+ * with width and height budgets (#220 phase 4). Matches the legibility
89
+ * checker's low-utilization threshold: a sheet the checker would call mostly
90
+ * empty is a sheet worth compacting. */
91
+ const COMPACT_UTILIZATION = 0.5;
92
+
93
+ export type NetClass = 'rail' | 'ground' | 'signal';
94
+ /**
95
+ * What decided a net's class: an IR `kind` declaration, the library electrical
96
+ * type of a pin it touches, or the last-resort supply-name shape. Reported per
97
+ * net so a name-inferred class — the one inference no pin actually attests —
98
+ * is visible and correctable through the IR.
99
+ */
100
+ export type NetClassBasis = 'declared' | 'pin-type' | 'name';
101
+
102
+ export interface SchematicDraftReport {
103
+ groups: { name: string; members: string[] }[];
104
+ netClasses: { name: string; class: NetClass; overridden: boolean; basis: NetClassBasis }[];
105
+ wireCount: number;
106
+ labelCount: number;
107
+ pwrFlags: string[];
108
+ noConnects: number;
109
+ paper: string;
110
+ notes: string[];
111
+ /**
112
+ * Points where labels of two or more distinct nets landed together, merging
113
+ * them into one net in the emitted sheet. Non-empty means the drawing does
114
+ * not implement the IR, so the caller must refuse the draft.
115
+ */
116
+ mergedNets: { x: number; y: number; nets: string[]; via?: 'labels' | 'wires' }[];
117
+ /**
118
+ * Labels whose TEXT still overlaps a foreign net's label after de-collision.
119
+ * The netlist is correct — these are legibility defects, tolerated up to
120
+ * `LABEL_OVERLAP_BUDGET` and always reported. Never a reason to refuse.
121
+ */
122
+ labelOverlaps: { x: number; y: number; nets: string[] }[];
123
+ /** Whether `labelOverlaps` exceeded the tolerated fraction of all labels. */
124
+ labelOverlapBudgetExceeded: boolean;
125
+ }
126
+
127
+ /**
128
+ * Points carrying labels for two or more distinct nets.
129
+ *
130
+ * Co-located labels are not a cosmetic overlap: KiCad resolves them to a single
131
+ * net and reports `Both A and B are attached to the same items; A will be used
132
+ * in the netlist` — as a *warning*. A live run drew ISET (charge-current
133
+ * program) and NTC (thermistor input) onto one node of a BQ24040 that way,
134
+ * which would have shipped a board whose charge current is not set by its
135
+ * programming resistor and whose temperature cutoff does not work.
136
+ *
137
+ * The engine computes every coordinate, so this is the engine's to catch, and
138
+ * it is strictly worse than the failures already gated: an unreadable sheet
139
+ * stops the pipeline loudly, a merged net flows quietly into layout and
140
+ * fabrication outputs.
141
+ */
142
+ export function findMergedNets(
143
+ labels: { name: string; x: number; y: number }[],
144
+ ): { x: number; y: number; nets: string[] }[] {
145
+ const byPoint = new Map<string, Set<string>>();
146
+ for (const l of labels) {
147
+ const key = pointKey(l.x, l.y);
148
+ const at = byPoint.get(key) ?? new Set<string>();
149
+ at.add(l.name);
150
+ byPoint.set(key, at);
151
+ }
152
+ return [...byPoint.entries()]
153
+ .filter(([, nets]) => nets.size > 1)
154
+ .map(([key, nets]) => {
155
+ const [x, y] = key.split(',').map(Number);
156
+ return { x: x!, y: y!, nets: [...nets].sort() };
157
+ })
158
+ .sort((a, b) => a.nets[0]!.localeCompare(b.nets[0]!));
159
+ }
160
+
161
+ /** True when (px,py) lies on the horizontal/vertical segment, endpoints
162
+ * included. The same dust tolerance as `sameCoord` for the fixed coordinate;
163
+ * the along-segment range check uses a plain epsilon. */
164
+ const SEG_EPS = 0.005;
165
+ const pointOnSeg = (
166
+ px: number,
167
+ py: number,
168
+ s: { x1: number; y1: number; x2: number; y2: number },
169
+ ): boolean => {
170
+ if (sameCoord(s.x1, s.x2)) {
171
+ return sameCoord(px, s.x1) && py >= Math.min(s.y1, s.y2) - SEG_EPS && py <= Math.max(s.y1, s.y2) + SEG_EPS;
172
+ }
173
+ if (sameCoord(s.y1, s.y2)) {
174
+ return sameCoord(py, s.y1) && px >= Math.min(s.x1, s.x2) - SEG_EPS && px <= Math.max(s.x1, s.x2) + SEG_EPS;
175
+ }
176
+ return false;
177
+ };
178
+
179
+ /**
180
+ * Cross-net wire contact is a merged net the co-located-label check cannot
181
+ * see: KiCad joins wires at coincident endpoints and at an endpoint on
182
+ * another wire's interior, whatever the labels say. The lemondrop run routed
183
+ * a local net's trunk down a column of neighbouring stub ends and shorted the
184
+ * crystal drive onto TOUCH_IRQ exactly this way (I22, #204) — ERC demoted it
185
+ * to a warning and it would have flowed into layout. The router now avoids
186
+ * foreign contact; this check gates whatever geometry any pass produces, so
187
+ * a merge can never again leave the engine silently. A label whose anchor
188
+ * sits on a foreign net's wire attaches to that wire in KiCad and is the
189
+ * same defect.
190
+ */
191
+ export function findWireContactMerges(
192
+ wires: { x1: number; y1: number; x2: number; y2: number; net: string }[],
193
+ labels: { name: string; x: number; y: number }[],
194
+ ): { x: number; y: number; nets: string[] }[] {
195
+ const out = new Map<string, { x: number; y: number; nets: string[] }>();
196
+ const add = (x: number, y: number, a: string, b: string): void => {
197
+ if (a === b) return;
198
+ const nets = [a, b].sort();
199
+ const key = `${nets[0]}/${nets[1]}@${pointKey(x, y)}`;
200
+ if (!out.has(key)) out.set(key, { x, y, nets });
201
+ };
202
+ for (let i = 0; i < wires.length; i++) {
203
+ for (let j = i + 1; j < wires.length; j++) {
204
+ const a = wires[i]!;
205
+ const b = wires[j]!;
206
+ if (a.net === b.net) continue;
207
+ if (pointOnSeg(a.x1, a.y1, b)) add(a.x1, a.y1, a.net, b.net);
208
+ if (pointOnSeg(a.x2, a.y2, b)) add(a.x2, a.y2, a.net, b.net);
209
+ if (pointOnSeg(b.x1, b.y1, a)) add(b.x1, b.y1, a.net, b.net);
210
+ if (pointOnSeg(b.x2, b.y2, a)) add(b.x2, b.y2, a.net, b.net);
211
+ }
212
+ }
213
+ for (const l of labels) {
214
+ for (const w of wires) {
215
+ if (w.net === l.name) continue;
216
+ if (pointOnSeg(l.x, l.y, w)) add(l.x, l.y, l.name, w.net);
217
+ }
218
+ }
219
+ return [...out.values()].sort(
220
+ (a, b) => a.nets[0]!.localeCompare(b.nets[0]!) || a.x - b.x || a.y - b.y,
221
+ );
222
+ }
223
+
224
+ /** An orthogonal segment in schematic space, millimetres. */
225
+ export interface Seg {
226
+ x1: number;
227
+ y1: number;
228
+ x2: number;
229
+ y2: number;
230
+ }
231
+
232
+ /**
233
+ * Every on-grid point strictly BETWEEN the ends of each segment.
234
+ *
235
+ * Takes segments, never a bag of points: a label anchored at a point that lies
236
+ * on no wire of its own net is attached to nothing in KiCad, and the net
237
+ * silently loses the name the IR gave it. Callers that keep their points in
238
+ * some other order (anchor preference, say) must pass the segments themselves
239
+ * so the walk cannot interpolate between two points that share no wire.
240
+ */
241
+ export function interiorGridPoints(segs: Seg[], step: number): { x: number; y: number }[] {
242
+ const out: { x: number; y: number }[] = [];
243
+ for (const seg of segs) {
244
+ const steps = Math.round((Math.abs(seg.x2 - seg.x1) + Math.abs(seg.y2 - seg.y1)) / step);
245
+ const sx = Math.sign(seg.x2 - seg.x1);
246
+ const sy = Math.sign(seg.y2 - seg.y1);
247
+ for (let k = 1; k < steps; k++) out.push({ x: seg.x1 + sx * k * step, y: seg.y1 + sy * k * step });
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /**
253
+ * Whether `seg` crosses the LINE a foreign pin's stub could grow along.
254
+ *
255
+ * `touchesForeign` predicts foreign stubs at their base length only, but a
256
+ * signal stub's clearance ladder may extend it further — jetson-agx-thor-
257
+ * baseboard shipped twelve trunk-on-stub contacts exactly that way, each a
258
+ * merged net the gate then refused. `reach` is that maximum growth, in mm.
259
+ * Both are orthogonal, so bounding-box overlap IS intersection, and it also
260
+ * catches collinear overlap, conservatively.
261
+ */
262
+ export function segCrossesStubGrowth(
263
+ seg: Seg,
264
+ pin: { x: number; y: number },
265
+ o: { dx: number; dy: number },
266
+ reach: number,
267
+ eps: number,
268
+ ): boolean {
269
+ const ex = pin.x + o.dx * reach;
270
+ const ey = pin.y + o.dy * reach;
271
+ return (
272
+ Math.min(seg.x1, seg.x2) <= Math.max(pin.x, ex) + eps &&
273
+ Math.max(seg.x1, seg.x2) >= Math.min(pin.x, ex) - eps &&
274
+ Math.min(seg.y1, seg.y2) <= Math.max(pin.y, ey) + eps &&
275
+ Math.max(seg.y1, seg.y2) >= Math.min(pin.y, ey) - eps
276
+ );
277
+ }
278
+
279
+ /**
280
+ * Split one line of bank candidates into the runs that may share a trunk.
281
+ *
282
+ * `canStub` vets a member on its own (its stub must clear foreign points) and
283
+ * `canJoin` vets the join to the member before it. A member that fails either
284
+ * ends the run in progress — a bank never buys density with a merged net — and
285
+ * a run of one is no bank at all, so only runs of two or more come back.
286
+ * Pure, so the veto semantics are testable without a sheet to place.
287
+ */
288
+ export function splitBankRuns<T>(line: T[], canStub: (c: T) => boolean, canJoin: (prev: T, c: T) => boolean): T[][] {
289
+ const runs: T[][] = [];
290
+ let run: T[] = [];
291
+ const flush = (): void => {
292
+ if (run.length >= 2) runs.push(run);
293
+ run = [];
294
+ };
295
+ for (const c of line) {
296
+ if (!canStub(c)) {
297
+ flush();
298
+ continue;
299
+ }
300
+ if (!run.length) {
301
+ run.push(c);
302
+ continue;
303
+ }
304
+ if (canJoin(run[run.length - 1]!, c)) run.push(c);
305
+ else {
306
+ flush();
307
+ run = [c];
308
+ }
309
+ }
310
+ flush();
311
+ return runs;
312
+ }
313
+
314
+ interface Placed {
315
+ part: IntentPart;
316
+ /** The drawn refdes (the part's ref, shared by every unit instance). */
317
+ refDes: string;
318
+ /** KiCad unit number for a multi-unit instance; null for single-unit parts. */
319
+ unit: number | null;
320
+ sym: ResolvedSymbol;
321
+ /** Origin, mm (grid multiple). */
322
+ x: number;
323
+ y: number;
324
+ body: Bounds; // schematic space, absolute
325
+ cellW: number; // units
326
+ cellH: number; // units
327
+ }
328
+
329
+ /**
330
+ * One placeable thing. A single-unit part is one instance whose key IS its
331
+ * refdes. A multi-unit part (an opamp, a gate pack) becomes one instance per
332
+ * unit, keyed `REF#unit`, each carrying only that unit's pins and body — the
333
+ * units share symbol-space pin coordinates, so placing the symbol once would
334
+ * overlay unrelated pins on one point and silently merge their nets (#218).
335
+ * Net endpoints stay `REF.PIN` strings; pin numbers are package-unique, so
336
+ * each endpoint resolves to exactly one instance.
337
+ */
338
+ interface Instance {
339
+ key: string;
340
+ ref: string;
341
+ unit: number | null;
342
+ part: IntentPart;
343
+ sym: ResolvedSymbol;
344
+ }
345
+
346
+ const bodyBoundsOf = (sym: ResolvedSymbol): Bounds => {
347
+ if (sym.body) return sym.body;
348
+ const xs = sym.pins.map((p) => p.x);
349
+ const ys = sym.pins.map((p) => p.y);
350
+ if (!xs.length) return { minX: -U, minY: -U, maxX: U, maxY: U };
351
+ return { minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys) };
352
+ };
353
+
354
+ /** Pin connection point in schematic space for a part placed at (x, y), rot 0. */
355
+ const pinAt = (p: Placed, pin: DraftPin): { x: number; y: number } => ({ x: p.x + pin.x, y: p.y - pin.y });
356
+
357
+ /** Outward direction of a pin (away from the body), schematic space. */
358
+ function outward(pin: DraftPin): { dx: number; dy: number } {
359
+ // pin angle points from the connection point toward the body (symbol space,
360
+ // Y-up); outward is the opposite, with Y flipped into schematic space.
361
+ const a = ((pin.angle % 360) + 360) % 360;
362
+ if (a === 0) return { dx: -1, dy: 0 };
363
+ if (a === 180) return { dx: 1, dy: 0 };
364
+ if (a === 90) return { dx: 0, dy: 1 };
365
+ return { dx: 0, dy: -1 };
366
+ }
367
+
368
+ /**
369
+ * Supply-name shapes for the last-resort classification below. Deliberately
370
+ * narrow, and narrow in ONE direction: a rail misread as a signal draws labels
371
+ * (exactly what the engine did before the fallback existed), while a signal
372
+ * misread as a rail draws a power symbol and makes the sheet assert a supply
373
+ * the design does not have. So an underscore may join a VOLTAGE suffix
374
+ * (VDD_3V3, VCC_1V8) and nothing else — VBUS_DET, VCC_SENSE and VDD_MON are
375
+ * measurement nodes on real boards and stay signals. Anything the shapes miss
376
+ * is still correctable with an IR `kind` declaration, which outranks all of
377
+ * this, and the report names the basis so a miss is visible.
378
+ */
379
+ const GROUND_NAME = /^([adp]?gnd[0-9a-z]*|vss[0-9a-z]*)$/i;
380
+ const RAIL_NAME = /^(?:[+-]?[0-9]+(?:\.[0-9]+)?v[0-9]*|(?:vcc|vdd|vbus|vee)[0-9a-z]*(?:_[0-9]+v[0-9]*)?)$/i;
381
+
382
+ function classifyNet(
383
+ net: IntentNet,
384
+ pinsOf: (ep: string) => DraftPin | null,
385
+ ): { cls: NetClass; overridden: boolean; basis: NetClassBasis } {
386
+ if (net.kind === 'power') return { cls: 'rail', overridden: true, basis: 'declared' };
387
+ if (net.kind === 'ground') return { cls: 'ground', overridden: true, basis: 'declared' };
388
+ if (net.kind === 'signal') return { cls: 'signal', overridden: true, basis: 'declared' };
389
+ const touchesPower = net.pins.some((ep) => {
390
+ const p = pinsOf(ep);
391
+ return p !== null && (p.etype === 'power_in' || p.etype === 'power_out');
392
+ });
393
+ if (!touchesPower) {
394
+ // No electrical-type evidence: real boards routinely carry their supplies
395
+ // on embedded symbols whose pins are all `passive` (stickhub's GND, 80
396
+ // pins, drafted as 80 labels and zero ground bars). Fall back to the
397
+ // unambiguous supply-name shapes only; anything else stays signal.
398
+ if (GROUND_NAME.test(net.name)) return { cls: 'ground', overridden: false, basis: 'name' };
399
+ if (RAIL_NAME.test(net.name)) return { cls: 'rail', overridden: false, basis: 'name' };
400
+ return { cls: 'signal', overridden: false, basis: 'name' };
401
+ }
402
+ return { cls: /gnd|vss/i.test(net.name) ? 'ground' : 'rail', overridden: false, basis: 'pin-type' };
403
+ }
404
+
405
+ const boundsOverlap = (a: Bounds, b: Bounds): boolean =>
406
+ a.minX < b.maxX - 0.01 && a.maxX > b.minX + 0.01 && a.minY < b.maxY - 0.01 && a.maxY > b.minY + 0.01;
407
+
408
+ /**
409
+ * The box a label's text occupies, matching the legibility checker's
410
+ * conservative metrics. Shared by the de-collision pass and the overlap report
411
+ * so "clear" means one thing in the engine.
412
+ */
413
+ const labelTextBox = (name: string, x: number, y: number, rot: number): Bounds => {
414
+ const w = Math.max(1, name.length) * LABEL_ADVANCE * LABEL_HEIGHT;
415
+ const h = LABEL_HEIGHT / 2;
416
+ return rot === 180
417
+ ? { minX: x - w, minY: y - h, maxX: x, maxY: y + h }
418
+ : { minX: x, minY: y - h, maxX: x + w, maxY: y + h };
419
+ };
420
+
421
+ /**
422
+ * Pairs of labels naming DIFFERENT nets whose text boxes overlap.
423
+ *
424
+ * Distinct from `findMergedNets`, which looks for a shared label *point*. A
425
+ * shared point is electrical — KiCad fuses the nets. Overlapping text is
426
+ * cosmetic: the sheet reads badly, the netlist is right. Reported one entry per
427
+ * colliding label position, nets sorted, so the same pair is not listed twice.
428
+ */
429
+ export function findLabelOverlaps(
430
+ labels: { name: string; x: number; y: number; rot: number }[],
431
+ ): { x: number; y: number; nets: string[] }[] {
432
+ const out = new Map<string, { x: number; y: number; nets: Set<string> }>();
433
+ const boxes = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot));
434
+ for (let i = 0; i < labels.length; i++) {
435
+ for (let j = i + 1; j < labels.length; j++) {
436
+ const a = labels[i]!;
437
+ const b = labels[j]!;
438
+ if (a.name === b.name) continue;
439
+ // an exact coincidence is a merged net, reported by findMergedNets; do
440
+ // not also count it here or one fault reads as two
441
+ if (sameCoord(a.x, b.x) && sameCoord(a.y, b.y)) continue;
442
+ if (!boundsOverlap(boxes[i]!, boxes[j]!)) continue;
443
+ for (const [l, other] of [[a, b], [b, a]] as const) {
444
+ const key = pointKey(l.x, l.y);
445
+ const e = out.get(key) ?? { x: l.x, y: l.y, nets: new Set<string>([l.name]) };
446
+ e.nets.add(other.name);
447
+ out.set(key, e);
448
+ }
449
+ }
450
+ }
451
+ return [...out.values()]
452
+ .map((e) => ({ x: e.x, y: e.y, nets: [...e.nets].sort() }))
453
+ .sort((a, b) => a.nets[0]!.localeCompare(b.nets[0]!) || a.x - b.x || a.y - b.y);
454
+ }
455
+
456
+ const segCrossesBody = (x1: number, y1: number, x2: number, y2: number, b: Bounds): boolean => {
457
+ const inX = Math.max(Math.min(x1, x2), b.minX) < Math.min(Math.max(x1, x2), b.maxX) - 0.01;
458
+ const inY = Math.max(Math.min(y1, y2), b.minY) < Math.min(Math.max(y1, y2), b.maxY) - 0.01;
459
+ if (x1 === x2) return x1 > b.minX + 0.01 && x1 < b.maxX - 0.01 && inY;
460
+ if (y1 === y2) return y1 > b.minY + 0.01 && y1 < b.maxY - 0.01 && inX;
461
+ return inX && inY; // conservative for diagonals (the engine never draws them)
462
+ };
463
+
464
+ export function draftSchematicPlacement(validated: ValidatedIntent, projectName: string, today: string): { model: PlacementModel; report: SchematicDraftReport } {
465
+ const { intent, symbols, docGroups } = validated;
466
+ const notes: string[] = [];
467
+
468
+ // ---------- net classification (deterministic, visible in the report) ----------
469
+ const partByRef = new Map(intent.parts.map((p) => [p.ref, p]));
470
+
471
+ // ---------- instance expansion (multi-unit parts, #218) ----------
472
+ // Units none of whose OWN pins a net or no-connect references are left
473
+ // unplaced (the intent says nothing about them, and drawing them would add
474
+ // unconnected pins the intent never declared). Common (unit-0) pins do not
475
+ // count as a unit's own: they appear in every unit's view because KiCad
476
+ // draws them on every placed unit — an LM358's V+/V- reaching the rails
477
+ // must not drag an unused second opamp onto the sheet. A multi-unit part
478
+ // with NO referenced pins at all places all its units, so the part stays
479
+ // visible like an unwired single-unit part does.
480
+ const usedEps = new Set<string>();
481
+ for (const net of intent.nets) for (const ep of net.pins) if (typeof ep === 'string') usedEps.add(ep);
482
+ for (const ep of intent.noConnect ?? []) if (typeof ep === 'string') usedEps.add(ep);
483
+ /** Endpoints of common (unit-0) pins: drawn — and wired — on EVERY placed
484
+ * instance of their part. */
485
+ const commonEps = new Set<string>();
486
+ const instances: Instance[] = intent.parts.flatMap((p): Instance[] => {
487
+ const sym = symbols.get(p.ref);
488
+ if (!sym) return [];
489
+ if (!sym.multiUnit || !sym.units?.length) return [{ key: p.ref, ref: p.ref, unit: null, part: p, sym }];
490
+ const common = new Set(sym.commonUnitPins ?? []);
491
+ for (const n of common) commonEps.add(`${p.ref}.${n}`);
492
+ const referenced = sym.units.filter((u) =>
493
+ u.pins.some((pin) => !common.has(pin.number) && usedEps.has(`${p.ref}.${pin.number}`)),
494
+ );
495
+ return (referenced.length ? referenced : sym.units).map((u) => ({
496
+ key: `${p.ref}#${u.unit}`,
497
+ ref: p.ref,
498
+ unit: u.unit,
499
+ part: p,
500
+ sym: { ...sym, pins: u.pins, body: u.body },
501
+ }));
502
+ });
503
+ const instByKey = new Map(instances.map((i) => [i.key, i]));
504
+ /** Placed instances per refdes, for expanding a common pin's endpoint. */
505
+ const instancesOfRef = new Map<string, Instance[]>();
506
+ for (const inst of instances) instancesOfRef.set(inst.ref, [...(instancesOfRef.get(inst.ref) ?? []), inst]);
507
+ const epInstKey = new Map<string, string>();
508
+ for (const inst of instances) {
509
+ for (const pin of inst.sym.pins) {
510
+ const ep = `${inst.ref}.${pin.number}`;
511
+ if (!commonEps.has(ep)) epInstKey.set(ep, inst.key);
512
+ }
513
+ }
514
+ /** Placement-instance key owning endpoint REF.PIN. A common pin resolves to
515
+ * its part's FIRST placed instance (single-instance callers — layering,
516
+ * idiom passes — need one answer; the wiring passes use `expandEp` and
517
+ * reach every appearance). Falls back to the ref itself so lookups fail
518
+ * softly like before. */
519
+ const instKeyOf = (ref: string, pin: string): string =>
520
+ epInstKey.get(`${ref}.${pin}`) ?? instancesOfRef.get(ref)?.[0]?.key ?? ref;
521
+ /** Every placed instance carrying endpoint REF.PIN: one for a unit's own
522
+ * pin, all of the part's instances for a common pin. */
523
+ const expandEp = (ref: string, pin: string): Instance[] => {
524
+ if (commonEps.has(`${ref}.${pin}`)) return instancesOfRef.get(ref) ?? [];
525
+ const inst = instByKey.get(instKeyOf(ref, pin));
526
+ return inst ? [inst] : [];
527
+ };
528
+
529
+ const pinLookup = (ep: string): DraftPin | null => {
530
+ const m = /^([^.]+)\.(.+)$/.exec(ep);
531
+ if (!m) return null;
532
+ const sym = symbols.get(m[1]!);
533
+ return sym?.pins.find((p) => p.number === m[2]) ?? null;
534
+ };
535
+ const netClasses = new Map<string, { cls: NetClass; overridden: boolean; basis: NetClassBasis }>();
536
+ for (const net of intent.nets) netClasses.set(net.name, classifyNet(net, pinLookup));
537
+ const powerNets = intent.nets.filter((n) => netClasses.get(n.name)!.cls !== 'signal');
538
+ const signalNets = intent.nets.filter((n) => netClasses.get(n.name)!.cls === 'signal');
539
+
540
+ // ---------- reductions: decoupling caps and connectors ----------
541
+ // Structural, not name-based (#233): the reduction's own conditions below
542
+ // (a rail on one pin, ground on the other, an owner IC on the same rail)
543
+ // are what make a part a decoupling element. Real boards carry their caps
544
+ // under embedded, renamed lib ids the old `Device:C` test never matched,
545
+ // so their banks stayed in the columns as label islands; and a rail-clamp
546
+ // TVS drawn beside the caps is how the hand-drawn sheets show it too.
547
+ const isTwoPin = (p: IntentPart): boolean => (symbols.get(p.ref)?.pins.length ?? 0) === 2;
548
+ const railsOf = (ref: string): string[] =>
549
+ powerNets
550
+ .filter((net) => netClasses.get(net.name)!.cls === 'rail' && net.pins.some((ep) => ep.startsWith(`${ref}.`)))
551
+ .map((net) => net.name);
552
+ const railOf = (ref: string): string | null => railsOf(ref)[0] ?? null;
553
+ const touchesGround = (ref: string): boolean =>
554
+ powerNets.some((n) => netClasses.get(n.name)!.cls === 'ground' && n.pins.some((ep) => ep.startsWith(`${ref}.`)));
555
+
556
+ const decapOwner = new Map<string, string>(); // cap ref -> owner IC ref
557
+ for (const p of intent.parts) {
558
+ const sym = symbols.get(p.ref)!;
559
+ if (sym.isPower || !isTwoPin(p)) continue;
560
+ const rail = railOf(p.ref);
561
+ if (!rail || !touchesGround(p.ref)) continue;
562
+ // owner: an IC (3+ pins) on the same rail — same group first, then most
563
+ // shared nets, then refdes order (deterministic tie-break, engine spec)
564
+ const candidates = intent.parts
565
+ .filter((c) => c.ref !== p.ref && (symbols.get(c.ref)?.pins.length ?? 0) >= 3 && railsOf(c.ref).includes(rail))
566
+ .map((c) => ({
567
+ ref: c.ref,
568
+ sameGroup: c.group === p.group ? 1 : 0,
569
+ shared: intent.nets.filter((n) => n.pins.some((e) => e.startsWith(`${c.ref}.`)) && n.pins.some((e) => e.startsWith(`${p.ref}.`))).length,
570
+ }))
571
+ .sort((a, b) => b.sameGroup - a.sameGroup || b.shared - a.shared || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
572
+ if (candidates.length) decapOwner.set(p.ref, candidates[0]!.ref);
573
+ }
574
+ const isConnector = (p: IntentPart): boolean => p.libId.startsWith('Connector');
575
+
576
+ // ---------- facing-label extents ----------
577
+ // A labelled stub extends horizontal TEXT into the channel beside its pin:
578
+ // the stub plus the net name at the checker's conservative advance. The base
579
+ // column channel and group gap assume short names; two facing pins whose
580
+ // combined names run past ~25 characters overrun them, and the de-collision
581
+ // pass cannot help (riding a stub outward moves the text further INTO the
582
+ // facing group). So the gaps below are widened by the facing extents, and a
583
+ // long-named pair drafts clean by construction instead of surviving as an
584
+ // error-severity text collision. Conservative on purpose: whether a signal
585
+ // net is wired or labelled is decided after placement, so every signal net
586
+ // counts here — typical names fit inside the base gaps and nothing widens.
587
+ const signalNetOfPin = new Map<string, string>();
588
+ for (const net of signalNets) for (const ep of net.pins) signalNetOfPin.set(ep, net.name);
589
+ /** Every endpoint's net, power-class nets included (the idiom passes need
590
+ * to see a chain's rail/ground end, which signalNetOfPin cannot). */
591
+ const netByEndpoint = new Map<string, IntentNet>();
592
+ for (const net of intent.nets) for (const ep of net.pins) netByEndpoint.set(ep, net);
593
+ const labelExtents = (keys: string[]): { left: number; right: number } => {
594
+ let left = 0;
595
+ let right = 0;
596
+ for (const key of keys) {
597
+ const inst = instByKey.get(key);
598
+ for (const pin of inst?.sym.pins ?? []) {
599
+ const net = signalNetOfPin.get(`${inst!.ref}.${pin.number}`);
600
+ if (!net) continue;
601
+ const o = outward(pin);
602
+ if (o.dx === 0) continue;
603
+ const extent = STUB * U + Math.max(1, net.length) * LABEL_ADVANCE * LABEL_HEIGHT;
604
+ if (o.dx === -1) left = Math.max(left, extent);
605
+ else right = Math.max(right, extent);
606
+ }
607
+ }
608
+ return { left, right };
609
+ };
610
+ /** Extra gap units so facing label text fits a boundary whose body-to-body
611
+ * clearance is `baseUnits` (one unit of slack between the two texts). */
612
+ const widenBy = (rightOfPrev: number, leftOfNext: number, baseUnits: number): number =>
613
+ Math.max(0, ceilU(rightOfPrev + leftOfNext) + 1 - baseUnits);
614
+
615
+ // ---------- group ordering: hints, then SUBSYSTEMS.md order, then name ----------
616
+ const groupNames = [...new Set(intent.parts.filter((p) => !symbols.get(p.ref)!.isPower).map((p) => p.group))];
617
+ const orderIndex = (g: string): number => {
618
+ const hinted = intent.hints?.groupOrder?.findIndex((h) => h.toLowerCase() === g.toLowerCase());
619
+ if (hinted !== undefined && hinted >= 0) return hinted;
620
+ const doc = docGroups?.findIndex((h) => h.toLowerCase() === g.toLowerCase());
621
+ if (doc !== undefined && doc >= 0) return 1000 + doc;
622
+ return 2000;
623
+ };
624
+ groupNames.sort((a, b) => orderIndex(a) - orderIndex(b) || a.localeCompare(b));
625
+
626
+ // ---------- in-group placement: layering + barycenter, integer grid ----------
627
+ const placed = new Map<string, Placed>();
628
+ const groupRects: { name: string; x1: number; y1: number; x2: number; y2: number }[] = [];
629
+ const groupOf = new Map<string, string>();
630
+ const groupExtents = new Map<string, { left: number; right: number }>();
631
+ for (const gname of groupNames) {
632
+ groupExtents.set(
633
+ gname,
634
+ labelExtents(instances.filter((i) => i.part.group === gname && !i.sym.isPower).map((i) => i.key)),
635
+ );
636
+ }
637
+
638
+ /**
639
+ * Place every group's cells. `bandBudgetW` caps a single group's width, in
640
+ * grid units: a column that would tile past it starts a new band of columns
641
+ * below the ones already placed (#219). `colBudgetH` caps a single column's
642
+ * height, in grid units: a depth whose parts stack taller becomes several
643
+ * side-by-side columns, the vertical analog of banding (#220 phase 4).
644
+ * `Infinity` for both keeps the classic single-band ribbon. Placement is
645
+ * deterministic in (intent, budgets), so the paper-selection pass below may
646
+ * re-run it with tighter budgets when a sheet is worth compacting onto.
647
+ * Returns each group's band count.
648
+ */
649
+ const placeAllGroups = (bandBudgetW: number, colBudgetH: number = Infinity): Map<string, number> => {
650
+ placed.clear();
651
+ groupRects.length = 0;
652
+ groupOf.clear();
653
+ const bandsOf = new Map<string, number>();
654
+ let groupX = 0; // running x origin (units) for group tiling
655
+ let prevGroup: string | null = null; // last group that actually placed cells
656
+
657
+ for (const gname of groupNames) {
658
+ // widen the gap to the previous group when facing label text needs it
659
+ if (prevGroup !== null) {
660
+ groupX += widenBy(groupExtents.get(prevGroup)!.right, groupExtents.get(gname)!.left, 2 * MARGIN + GROUP_GAP);
661
+ }
662
+ const members = instances.filter(
663
+ (i) => i.part.group === gname && !i.sym.isPower && !decapOwner.has(i.key),
664
+ );
665
+ const caps = instances.filter((i) => i.part.group === gname && decapOwner.has(i.key));
666
+ for (const i of [...members, ...caps]) groupOf.set(i.key, gname);
667
+ const memberKeySet = new Set(members.map((m) => m.key));
668
+
669
+ // layer assignment: connectors at depth 0; signal edges push depth forward
670
+ const depth = new Map<string, number>(members.map((m) => [m.key, isConnector(m.part) ? 0 : 1]));
671
+ const edges: { from: string; to: string }[] = [];
672
+ for (const net of signalNets) {
673
+ const eps = net.pins
674
+ .map((ep) => {
675
+ const m = /^([^.]+)\.(.+)$/.exec(ep);
676
+ return m ? instKeyOf(m[1]!, m[2]!) : '';
677
+ })
678
+ .filter((k) => memberKeySet.has(k));
679
+ const uniq = [...new Set(eps)];
680
+ for (let i = 0; i < uniq.length; i++) {
681
+ for (let j = i + 1; j < uniq.length; j++) {
682
+ const [a, b] = [uniq[i]!, uniq[j]!].sort((x, y) => x.localeCompare(y, undefined, { numeric: true }));
683
+ edges.push({ from: a!, to: b! });
684
+ }
685
+ }
686
+ }
687
+ for (let iter = 0; iter < members.length; iter++) {
688
+ let changed = false;
689
+ for (const e of edges) {
690
+ const want = (depth.get(e.from) ?? 0) + 1;
691
+ if ((depth.get(e.to) ?? 0) < want && want <= members.length) {
692
+ depth.set(e.to, want);
693
+ changed = true;
694
+ }
695
+ }
696
+ if (!changed) break;
697
+ }
698
+ const depths = [...new Set([...depth.values()])].sort((a, b) => a - b);
699
+ const columns: string[][] = depths.map((d) => members.filter((m) => depth.get(m.key) === d).map((m) => m.key));
700
+
701
+ // barycenter row ordering (two sweeps), refdes as the deterministic tie
702
+ const rowOf = new Map<string, number>();
703
+ columns.forEach((col) => col.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i)));
704
+ for (let sweep = 0; sweep < 2; sweep++) {
705
+ for (let ci = 1; ci < columns.length; ci++) {
706
+ const col = columns[ci]!;
707
+ const bary = (ref: string): number => {
708
+ const neigh = edges
709
+ .filter((e) => e.from === ref || e.to === ref)
710
+ .map((e) => (e.from === ref ? e.to : e.from))
711
+ .filter((o) => rowOf.has(o));
712
+ if (!neigh.length) return rowOf.get(ref)!;
713
+ return neigh.reduce((s, o) => s + rowOf.get(o)!, 0) / neigh.length;
714
+ };
715
+ col.sort((a, b) => bary(a) - bary(b) || a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i));
716
+ }
717
+ }
718
+
719
+ // cells: sized from body plus margins, positions snapped to the grid
720
+ const cellDims = new Map<string, { w: number; h: number; body: Bounds }>();
721
+ for (const m of members) {
722
+ const b = bodyBoundsOf(m.sym);
723
+ cellDims.set(m.key, {
724
+ w: ceilU(b.maxX - b.minX) + 2 * MARGIN,
725
+ h: ceilU(b.maxY - b.minY) + 2 * MARGIN,
726
+ body: b,
727
+ });
728
+ }
729
+ // Column height budget (#220 phase 4): a depth whose parts stack taller
730
+ // than the budget splits into several side-by-side columns, in row
731
+ // order, so a 24-part board stops drafting as one full-height strip on
732
+ // a sheet two sizes too large. Cells never shrink; only the arrangement
733
+ // changes, so readability is untouched.
734
+ const columnsToPlace: string[][] =
735
+ colBudgetH === Infinity
736
+ ? columns
737
+ : columns.flatMap((col) => {
738
+ const chunks: string[][] = [];
739
+ let cur: string[] = [];
740
+ let h = 0;
741
+ for (const ref of col) {
742
+ const add = cellDims.get(ref)!.h + (cur.length ? ROW_GAP : 0);
743
+ if (cur.length && h + add > colBudgetH) {
744
+ chunks.push(cur);
745
+ cur = [ref];
746
+ h = cellDims.get(ref)!.h;
747
+ } else {
748
+ cur.push(ref);
749
+ h += add;
750
+ }
751
+ }
752
+ if (cur.length) chunks.push(cur);
753
+ return chunks;
754
+ });
755
+ let colX = groupX;
756
+ let groupMaxY = 0;
757
+ let bandTop = 0; // y origin (units) of the current band of columns
758
+ let bandCount = 1;
759
+ // The budget must leave room for the label TEXT facing the sheet edges:
760
+ // a band filled to the full usable width puts the leftmost column's
761
+ // left-facing labels outside the frame (#220 phase 1), and no later
762
+ // shift can fix both edges at once.
763
+ const ext = groupExtents.get(gname)!;
764
+ const bandW = Math.max(1, bandBudgetW - ceilU(ext.left) - ceilU(ext.right));
765
+ for (let ci = 0; ci < columnsToPlace.length; ci++) {
766
+ const col = columnsToPlace[ci]!;
767
+ const colW = Math.max(...col.map((r) => cellDims.get(r)!.w));
768
+ // Banding (#219): a column that would tile past the width budget starts
769
+ // a new band of columns below everything placed so far, the way the
770
+ // shelf-wrap below re-rows whole groups. Never before the first column
771
+ // of a band, so a single over-wide column still places (and the caller
772
+ // rejects this budget instead).
773
+ if (colX > groupX && colX + colW - groupX > bandW) {
774
+ bandTop = groupMaxY + GROUP_GAP;
775
+ colX = groupX;
776
+ bandCount++;
777
+ }
778
+ let rowY = bandTop;
779
+ for (const ref of col) {
780
+ const dims = cellDims.get(ref)!;
781
+ const cx = colX + Math.floor(colW / 2); // shared column axis (units)
782
+ const cy = rowY + Math.floor(dims.h / 2);
783
+ const b = dims.body;
784
+ // origin so the body centers on the cell center, snapped to grid
785
+ const ox = grid(cx - Math.round((b.minX + b.maxX) / 2 / U));
786
+ const oy = grid(cy + Math.round((b.minY + b.maxY) / 2 / U));
787
+ const inst = instByKey.get(ref)!;
788
+ placed.set(ref, {
789
+ part: inst.part,
790
+ refDes: inst.ref,
791
+ unit: inst.unit,
792
+ sym: inst.sym,
793
+ x: ox,
794
+ y: oy,
795
+ body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
796
+ cellW: dims.w,
797
+ cellH: dims.h,
798
+ });
799
+ rowY += dims.h + ROW_GAP;
800
+ }
801
+ groupMaxY = Math.max(groupMaxY, rowY - ROW_GAP);
802
+ const next = columnsToPlace[ci + 1];
803
+ colX += colW + CHANNEL + (next ? widenBy(labelExtents(col).right, labelExtents(next).left, 2 * MARGIN + CHANNEL) : 0);
804
+ }
805
+
806
+ // decoupling rows: caps in a uniform row under their owner (or the group)
807
+ const capRefs = caps.map((c) => c.key).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
808
+ if (capRefs.length) {
809
+ // The bank stacks under the circuit at the circuit's own width, the
810
+ // way a hand-drawn sheet does — a 45-cap ribbon run out to the band
811
+ // budget alone turned the group into an L-shape wider than the sheet
812
+ // it deserved (#233). The floor keeps a short bank (four typical cap
813
+ // cells) on one row even when the circuit above it is narrower.
814
+ const blockW = Math.max(64, colX - groupX);
815
+ const capBudget = Math.min(bandW, blockW);
816
+ let capX = groupX;
817
+ let capY = groupMaxY + MARGIN + 4;
818
+ for (const ref of capRefs) {
819
+ const inst = instByKey.get(ref)!;
820
+ const b = bodyBoundsOf(inst.sym);
821
+ // Banding (#219): a decoupling bank wider than the budget wraps onto
822
+ // another uniform row rather than running past the frame.
823
+ if (capX > groupX && capX + ceilU(b.maxX - b.minX) + 2 * MARGIN - groupX > capBudget) {
824
+ capX = groupX;
825
+ capY += 2 * MARGIN + 6;
826
+ }
827
+ const ox = grid(capX + MARGIN);
828
+ const oy = grid(capY + MARGIN);
829
+ placed.set(ref, {
830
+ part: inst.part,
831
+ refDes: inst.ref,
832
+ unit: inst.unit,
833
+ sym: inst.sym,
834
+ x: ox,
835
+ y: oy,
836
+ body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
837
+ cellW: ceilU(b.maxX - b.minX) + 2 * MARGIN,
838
+ cellH: ceilU(b.maxY - b.minY) + 2 * MARGIN,
839
+ });
840
+ capX += ceilU(b.maxX - b.minX) + 2 * MARGIN;
841
+ }
842
+ groupMaxY = capY + 2 * MARGIN + 6;
843
+ }
844
+
845
+ // ---------- idiom micro-templates and the alignment pass (7.5/7.5a) ----------
846
+ // Column placement is correct but reads machine-made for the small
847
+ // structures a human drafter draws by reflex: a pull-up sits directly on
848
+ // the pin it pulls with its rail above, a series RC hangs as one straight
849
+ // vertical run, crystal load caps mirror about their crystal. Two passes
850
+ // rearrange exactly those shapes after column placement, both no-ops
851
+ // unless the textbook topology is present, and both collision-checked so
852
+ // a failed fit falls back to the column position rather than overlapping.
853
+ const inGroup = new Set(members.map((m) => m.key));
854
+ const idiomPlaced = new Set<string>();
855
+ const CHAIN_GAP = 4 * U;
856
+ /** The two pins of a vertically-pinned two-lead instance, or null. */
857
+ const vertPins = (key: string): { top: DraftPin; bot: DraftPin } | null => {
858
+ const pins = placed.get(key)?.sym.pins ?? [];
859
+ if (pins.length !== 2) return null;
860
+ const top = pins.find((p) => outward(p).dy === -1);
861
+ const bot = pins.find((p) => outward(p).dy === 1);
862
+ return top && bot ? { top, bot } : null;
863
+ };
864
+ const isCrystal = (key: string): boolean => /crystal|reson/i.test(placed.get(key)?.part.libId ?? '');
865
+ /** An instance the chain pass may move: two vertical leads, in this group,
866
+ * not already spoken for by the decap row or the crystal template. */
867
+ const chainable = (key: string): boolean =>
868
+ inGroup.has(key) && !decapOwner.has(key) && !idiomPlaced.has(key) && !isCrystal(key) && vertPins(key) !== null;
869
+ /** Endpoint REF.PIN for an instance's pin (the base refdes, never the key). */
870
+ const epOf = (key: string, pin: string): string => `${placed.get(key)?.refDes ?? key}.${pin}`;
871
+ const parseEp = (ep: string): { ref: string; pin: string; key: string } | null => {
872
+ const m = /^([^.]+)\.(.+)$/.exec(ep);
873
+ return m ? { ref: m[1]!, pin: m[2]!, key: instKeyOf(m[1]!, m[2]!) } : null;
874
+ };
875
+ const classOf = (name: string): NetClass => netClasses.get(name)?.cls ?? 'signal';
876
+ const padOverlap = (a: Bounds, b: Bounds, pad: number): boolean =>
877
+ a.minX < b.maxX + pad && a.maxX > b.minX - pad && a.minY < b.maxY + pad && a.maxY > b.minY - pad;
878
+ /** Candidate placement putting `key`'s TOP pin connection point at (x, y). */
879
+ const candidateAt = (key: string, x: number, y: number): Placed => {
880
+ const prev = placed.get(key)!;
881
+ const v = vertPins(key)!;
882
+ const b = bodyBoundsOf(prev.sym);
883
+ const ox = x - v.top.x;
884
+ const oy = y + v.top.y;
885
+ return {
886
+ part: prev.part,
887
+ refDes: prev.refDes,
888
+ unit: prev.unit,
889
+ sym: prev.sym,
890
+ x: ox,
891
+ y: oy,
892
+ body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
893
+ cellW: prev.cellW,
894
+ cellH: prev.cellH,
895
+ };
896
+ };
897
+ /** Apply candidate moves unless any moved body lands within a grid unit of
898
+ * an unmoved one; all-or-nothing so a failed fit changes nothing. */
899
+ const applyMoves = (moves: Map<string, Placed>): boolean => {
900
+ for (const cand of moves.values()) {
901
+ for (const [oref, op] of placed) {
902
+ if (moves.has(oref)) continue;
903
+ if (padOverlap(cand.body, op.body, U)) return false;
904
+ }
905
+ }
906
+ for (const [ref, cand] of moves) placed.set(ref, cand);
907
+ return true;
908
+ };
909
+ /** Every endpoint of every net a set of parts touches (the chain's own
910
+ * connection points, allowed to sit on its axis by definition). */
911
+ const ownEndpoints = (keys: Iterable<string>): Set<string> => {
912
+ const eps = new Set<string>();
913
+ for (const key of keys) {
914
+ for (const pin of placed.get(key)?.sym.pins ?? []) {
915
+ const net = netByEndpoint.get(epOf(key, pin.number));
916
+ for (const ep of net?.pins ?? []) eps.add(ep);
917
+ }
918
+ }
919
+ return eps;
920
+ };
921
+ /**
922
+ * A chain's wires run vertically along one x. No FOREIGN connected pin or
923
+ * stub end may sit on that line within the chain's y-range: KiCad joins
924
+ * wires at coincident endpoints, so a chain routed down a column of
925
+ * neighbouring stub ends silently merges nets. The first npn-switch run
926
+ * of this pass did exactly that — the reset pull-up's run down U1's
927
+ * left-pin stub column attached 5V to DRIVE. Body boxes cannot catch
928
+ * this; the check must be against connection points.
929
+ */
930
+ const axisClear = (
931
+ axisX: number,
932
+ yMin: number,
933
+ yMax: number,
934
+ ownEps: Set<string>,
935
+ conn: { y: number; net: string }[] = [],
936
+ movedRefs: Set<string> = new Set<string>(),
937
+ ): boolean => {
938
+ for (const [oref, opl] of placed) {
939
+ for (const pin of opl.sym.pins) {
940
+ const ep = `${opl.refDes}.${pin.number}`;
941
+ const net = netByEndpoint.get(ep);
942
+ if (!net) continue;
943
+ const o = outward(pin);
944
+ const len = classOf(net.name) !== 'signal' && o.dx !== 0 ? STUB + 2 : STUB;
945
+ const p = pinAt(opl, pin);
946
+ const end = { x: p.x + o.dx * len * U, y: p.y + o.dy * len * U };
947
+ if (!ownEps.has(ep)) {
948
+ for (const q of [p, end]) {
949
+ if (sameCoord(q.x, axisX) && q.y > yMin - U && q.y < yMax + U) return false;
950
+ }
951
+ }
952
+ // A horizontal stub SEGMENT crossing the axis exactly at a chain
953
+ // CONNECTION row of a DIFFERENT net passes through that connection
954
+ // point — a mid-segment crossing elsewhere is harmless, but a lead
955
+ // parked on the crossing row is a KiCad join. The pull-up idiom
956
+ // parked R1's bottom lead on U1's power-pin row and the VCC stub
957
+ // ran straight through the pulled SIG node (I22's chain-pass face,
958
+ // #204). Net-aware, because ownEps is too coarse here: U1's VCC pin
959
+ // shares a net with the chain's rail end, yet its stub through a
960
+ // SIG row still merges. Moved parts are skipped — `placed` holds
961
+ // their stale pre-move positions.
962
+ if (o.dx !== 0 && !movedRefs.has(oref)) {
963
+ const row = conn.find((c) => sameCoord(c.y, p.y));
964
+ if (row && row.net !== net.name) {
965
+ const lo = Math.min(p.x, end.x) - 0.001;
966
+ const hi = Math.max(p.x, end.x) + 0.001;
967
+ if (axisX >= lo && axisX <= hi) return false;
968
+ }
969
+ }
970
+ }
971
+ }
972
+ return true;
973
+ };
974
+ /** The room a rail/ground end grows past its pin: stub, bar, value text. */
975
+ const POWER_CLEAR = 8 * U;
976
+ const powerEndBox = (axisX: number, pinY: number, dir: -1 | 1): Bounds => ({
977
+ minX: axisX - 3 * U,
978
+ maxX: axisX + 3 * U,
979
+ minY: dir === -1 ? pinY - POWER_CLEAR : pinY,
980
+ maxY: dir === -1 ? pinY : pinY + POWER_CLEAR,
981
+ });
982
+ /** Clearance-check a chain (or flank) move set against its axis and its
983
+ * power-end growth, then apply; marks the parts idiom-placed only when
984
+ * everything held. */
985
+ const finalizeMoves = (
986
+ segments: { axisX: number; ys: number[]; conn?: { y: number; net: string }[] }[],
987
+ moves: Map<string, Placed>,
988
+ clearBoxes: Bounds[] = [],
989
+ ): boolean => {
990
+ const own = ownEndpoints(moves.keys());
991
+ const movedRefs = new Set(moves.keys());
992
+ for (const seg of segments) {
993
+ // the pad covers the power stub and symbol a rail/ground end grows
994
+ if (!axisClear(seg.axisX, Math.min(...seg.ys) - 4 * U, Math.max(...seg.ys) + 4 * U, own, seg.conn ?? [], movedRefs)) return false;
995
+ }
996
+ // a power symbol is not a body, so the body check cannot see it: the
997
+ // divider repro grew R2's GND bar and value text straight into the body
998
+ // of the part below until these boxes were checked explicitly
999
+ for (const box of clearBoxes) {
1000
+ for (const [oref, op] of placed) {
1001
+ if (moves.has(oref)) continue;
1002
+ if (padOverlap(box, op.body, 0)) return false;
1003
+ }
1004
+ }
1005
+ if (!applyMoves(moves)) return false;
1006
+ for (const ref of moves.keys()) idiomPlaced.add(ref);
1007
+ return true;
1008
+ };
1009
+
1010
+ // Crystal flanking: each horizontal crystal pin whose net also reaches a
1011
+ // two-lead cap-to-ground drops that cap below the pin's stub end. The
1012
+ // crystal's pins are symmetric about its body, so the two caps come out
1013
+ // mirror-placed at equal offsets and a common height by construction.
1014
+ for (const m of members) {
1015
+ if (!isCrystal(m.key)) continue;
1016
+ const xpl = placed.get(m.key);
1017
+ if (!xpl || xpl.sym.pins.length !== 2) continue;
1018
+ const moves = new Map<string, Placed>();
1019
+ const segments: { axisX: number; ys: number[] }[] = [];
1020
+ const clearBoxes: Bounds[] = [];
1021
+ for (const pin of xpl.sym.pins) {
1022
+ const o = outward(pin);
1023
+ if (o.dx === 0) continue;
1024
+ const net = netByEndpoint.get(`${m.ref}.${pin.number}`);
1025
+ if (!net || classOf(net.name) !== 'signal') continue;
1026
+ const cap = net.pins
1027
+ .map(parseEp)
1028
+ .find((e): e is { ref: string; pin: string; key: string } => {
1029
+ if (!e || e.key === m.key || !chainable(e.key) || moves.has(e.key)) return false;
1030
+ const v = vertPins(e.key)!;
1031
+ if (e.pin !== v.top.number) return false; // crystal node must enter the cap's top lead
1032
+ const other = netByEndpoint.get(`${e.ref}.${v.bot.number}`);
1033
+ return other !== undefined && classOf(other.name) !== 'signal';
1034
+ });
1035
+ if (!cap) continue;
1036
+ const at = pinAt(xpl, pin);
1037
+ const axisX = at.x + o.dx * STUB * U;
1038
+ const cand = candidateAt(cap.key, axisX, at.y + CHAIN_GAP);
1039
+ const v = vertPins(cap.key)!;
1040
+ moves.set(cap.key, cand);
1041
+ segments.push({ axisX, ys: [at.y, cand.y - v.bot.y] });
1042
+ clearBoxes.push(powerEndBox(axisX, cand.y - v.bot.y, 1)); // the ground symbol below the cap
1043
+ }
1044
+ // all-or-nothing per crystal: one dropped cap and one column cap would
1045
+ // read worse than the plain columns the pass is improving on
1046
+ if (moves.size) finalizeMoves(segments, moves, clearBoxes);
1047
+ }
1048
+
1049
+ // Drop chains: a maximal run of two-lead vertical parts linked pin-to-pin
1050
+ // by two-endpoint signal nets, ended on each side by an anchor pin (any
1051
+ // other part) or a power-class net. The run is restacked as one straight
1052
+ // vertical line: on the anchor's stub-end axis when there is an anchor
1053
+ // (the wire from the anchor continues dead straight into the chain), on
1054
+ // its own column axis when both ends are rails (a divider). Uniform gaps,
1055
+ // top-to-bottom in connectivity order — AC-16.31's zero-bend contract.
1056
+ type ChainEnd =
1057
+ | { kind: 'power' | 'open' | 'invalid' }
1058
+ | { kind: 'anchor'; ref: string; pin: DraftPin };
1059
+ const walk = (start: string, dir: 'up' | 'down', chain: string[]): ChainEnd => {
1060
+ let current = start;
1061
+ for (;;) {
1062
+ const v = vertPins(current)!;
1063
+ const pinN = dir === 'up' ? v.top.number : v.bot.number;
1064
+ const net = netByEndpoint.get(epOf(current, pinN));
1065
+ if (!net) return { kind: 'open' }; // declared no-connect or unused
1066
+ if (classOf(net.name) !== 'signal') return { kind: 'power' };
1067
+ if (net.pins.length !== 2) return { kind: 'invalid' }; // a tapped node is not a series chain
1068
+ const otherEp = net.pins.map(parseEp).find((e) => e !== null && e.key !== current);
1069
+ if (!otherEp) return { kind: 'invalid' };
1070
+ const opl = placed.get(otherEp.key);
1071
+ if (!opl || groupOf.get(otherEp.key) !== gname) return { kind: 'invalid' };
1072
+ if (chainable(otherEp.key) && !chain.includes(otherEp.key)) {
1073
+ const ov = vertPins(otherEp.key)!;
1074
+ // the link must enter through the lead facing the chain, or the
1075
+ // drawn run would have to cross the part's own body
1076
+ if (otherEp.pin !== (dir === 'up' ? ov.bot.number : ov.top.number)) return { kind: 'invalid' };
1077
+ if (dir === 'up') chain.unshift(otherEp.key);
1078
+ else chain.push(otherEp.key);
1079
+ current = otherEp.key;
1080
+ continue;
1081
+ }
1082
+ const pin = opl.sym.pins.find((p) => p.number === otherEp.pin);
1083
+ if (!pin || chain.includes(otherEp.key)) return { kind: 'invalid' };
1084
+ return { kind: 'anchor', ref: otherEp.key, pin };
1085
+ }
1086
+ };
1087
+ const chained = new Set<string>();
1088
+ for (const m of members) {
1089
+ if (!chainable(m.key) || chained.has(m.key)) continue;
1090
+ const chain = [m.key];
1091
+ const topEnd = walk(m.key, 'up', chain);
1092
+ const bottomEnd = walk(chain[chain.length - 1]!, 'down', chain);
1093
+ for (const ref of chain) chained.add(ref);
1094
+ if (topEnd.kind === 'invalid' || bottomEnd.kind === 'invalid') continue;
1095
+ if (chain.length > 4) continue; // beyond four parts this is a network, not an idiom
1096
+ const anchors = [topEnd, bottomEnd].filter((e): e is Extract<ChainEnd, { kind: 'anchor' }> => e.kind === 'anchor');
1097
+ if (anchors.length === 0 && chain.length < 2) continue; // a lone floating part has nothing to align to
1098
+ if (topEnd.kind === 'open' && bottomEnd.kind === 'open') continue;
1099
+
1100
+ const stubEndOf = (a: Extract<ChainEnd, { kind: 'anchor' }>): { x: number; y: number; o: { dx: number; dy: number } } => {
1101
+ const at = pinAt(placed.get(a.ref)!, a.pin);
1102
+ const o = outward(a.pin);
1103
+ return { x: at.x + o.dx * STUB * U, y: at.y + o.dy * STUB * U, o };
1104
+ };
1105
+ let axisX: number;
1106
+ let cursor: number; // y of the next TOP pin to place
1107
+ let order = chain;
1108
+ if (topEnd.kind === 'anchor') {
1109
+ const s = stubEndOf(topEnd);
1110
+ if (s.o.dy === -1) continue; // an up-facing pin cannot feed a downward run
1111
+ if (bottomEnd.kind === 'anchor') {
1112
+ const b = stubEndOf(bottomEnd);
1113
+ // both ends must sit on one axis with the second anchor below and
1114
+ // able to receive from above, else leave the columns alone
1115
+ if (!sameCoord(s.x, b.x) || b.y <= s.y || b.o.dy === 1) continue;
1116
+ }
1117
+ axisX = s.x;
1118
+ cursor = s.y + CHAIN_GAP;
1119
+ } else if (bottomEnd.kind === 'anchor') {
1120
+ // rail above, anchor below (a pull-up): stack upward from the anchor
1121
+ const s = stubEndOf(bottomEnd);
1122
+ if (s.o.dy === 1) continue; // a down-facing pin cannot feed an upward run
1123
+ axisX = s.x;
1124
+ order = [...chain].reverse();
1125
+ // Bounded lift: when a connection row would sit on a foreign stub's
1126
+ // crossing (axisClear's segment check), raise the whole stack a grid
1127
+ // row at a time rather than shipping the contact or losing the idiom.
1128
+ const netOf = (key: string, pinN: string): string => netByEndpoint.get(epOf(key, pinN))?.name ?? '';
1129
+ for (let lift = 0; lift < 3; lift++) {
1130
+ let up = s.y - CHAIN_GAP - lift * 2 * U;
1131
+ const moves = new Map<string, Placed>();
1132
+ const conn: { y: number; net: string }[] = [{ y: s.y, net: netOf(bottomEnd.ref, bottomEnd.pin.number) }];
1133
+ for (const ref of order) {
1134
+ const v = vertPins(ref)!;
1135
+ const span = v.top.y - v.bot.y; // symbol-space lead separation
1136
+ const cand = candidateAt(ref, axisX, up - span);
1137
+ moves.set(ref, cand);
1138
+ conn.push({ y: up, net: netOf(ref, v.bot.number) }, { y: up - span, net: netOf(ref, v.top.number) });
1139
+ up = up - span - CHAIN_GAP;
1140
+ }
1141
+ const topY = up + CHAIN_GAP;
1142
+ const topRef = order[order.length - 1]!;
1143
+ const done = finalizeMoves(
1144
+ [{ axisX, ys: [s.y, topY], conn: [...conn, { y: topY, net: netOf(topRef, vertPins(topRef)!.top.number) }] }],
1145
+ moves,
1146
+ topEnd.kind === 'power' ? [powerEndBox(axisX, topY, -1)] : [],
1147
+ );
1148
+ if (done) break;
1149
+ }
1150
+ continue;
1151
+ } else {
1152
+ // both ends are rails: a divider — straighten in place on its own axis
1153
+ const first = placed.get(chain[0]!)!;
1154
+ const v = vertPins(chain[0]!)!;
1155
+ const topAt = pinAt(first, v.top);
1156
+ axisX = topAt.x;
1157
+ cursor = topAt.y;
1158
+ }
1159
+ const cursor0 = cursor;
1160
+ const netOf2 = (key: string, pinN: string): string => netByEndpoint.get(epOf(key, pinN))?.name ?? '';
1161
+ for (let lift = 0; lift < 3; lift++) {
1162
+ cursor = cursor0 + lift * 2 * U;
1163
+ const moves = new Map<string, Placed>();
1164
+ const startY = cursor;
1165
+ const conn: { y: number; net: string }[] = [];
1166
+ let fits = true;
1167
+ for (const ref of order) {
1168
+ const cand = candidateAt(ref, axisX, cursor);
1169
+ moves.set(ref, cand);
1170
+ const v = vertPins(ref)!;
1171
+ conn.push({ y: cursor, net: netOf2(ref, v.top.number) }, { y: cursor + (v.top.y - v.bot.y), net: netOf2(ref, v.bot.number) });
1172
+ cursor = cursor + (v.top.y - v.bot.y) + CHAIN_GAP;
1173
+ }
1174
+ let axisEndY = cursor - CHAIN_GAP;
1175
+ if (bottomEnd.kind === 'anchor') {
1176
+ // cursor now sits one gap below the last lead; it may not pass the
1177
+ // lower anchor's stub end or the closing wire would run backwards
1178
+ const b = stubEndOf(bottomEnd);
1179
+ if (cursor > b.y + 0.001) fits = false;
1180
+ axisEndY = b.y;
1181
+ }
1182
+ const clearBoxes: Bounds[] = [];
1183
+ if (topEnd.kind === 'power') clearBoxes.push(powerEndBox(axisX, startY, -1));
1184
+ if (bottomEnd.kind === 'power') clearBoxes.push(powerEndBox(axisX, cursor - CHAIN_GAP, 1));
1185
+ const endNet = conn.length ? conn[0]!.net : '';
1186
+ const startConn = { y: cursor0 - CHAIN_GAP, net: topEnd.kind === 'anchor' ? netOf2(topEnd.ref, topEnd.pin.number) : endNet };
1187
+ const lastConn = { y: axisEndY, net: bottomEnd.kind === 'anchor' ? netOf2(bottomEnd.ref, bottomEnd.pin.number) : (conn.length ? conn[conn.length - 1]!.net : '') };
1188
+ if (fits && finalizeMoves([{ axisX, ys: [cursor0 - CHAIN_GAP, axisEndY], conn: [startConn, ...conn, lastConn] }], moves, clearBoxes)) break;
1189
+ if (!fits) break; // lifting only shrinks the room below; no retry can help
1190
+ }
1191
+ }
1192
+
1193
+ const memberRefs = [...members.map((m) => m.key), ...capRefs];
1194
+ const cells = memberRefs.map((r) => placed.get(r)!);
1195
+ if (cells.length) {
1196
+ const minX = Math.min(...cells.map((c) => c.body.minX)) - MARGIN * U;
1197
+ const maxX = Math.max(...cells.map((c) => c.body.maxX)) + MARGIN * U;
1198
+ const minY = Math.min(...cells.map((c) => c.body.minY)) - (MARGIN + 4) * U;
1199
+ const maxY = Math.max(...cells.map((c) => c.body.maxY)) + (MARGIN + 2) * U;
1200
+ groupRects.push({ name: gname, x1: minX, y1: minY, x2: maxX, y2: maxY });
1201
+ groupX = Math.round(maxX / U) + GROUP_GAP;
1202
+ prevGroup = gname;
1203
+ bandsOf.set(gname, bandCount);
1204
+ }
1205
+ }
1206
+ return bandsOf;
1207
+ };
1208
+
1209
+ // ---------- shelf-wrap: reflow the group ribbon into rows (design D12) ----------
1210
+ // Groups tile left-to-right above, which on a design with many subsystems
1211
+ // yields a ribbon: this repo's light controller came out 750 x 83 mm, a 9:1
1212
+ // strip that forces A1 and leaves 85% of the sheet empty. Wrapping that into
1213
+ // rows is what a human drafter does, and it costs nothing in readability as
1214
+ // long as the reading order is preserved — groups keep their declared order
1215
+ // and fill left-to-right, then top-to-bottom, exactly like text.
1216
+ //
1217
+ // Runs BEFORE the wire/label pass so spans are measured on final coordinates:
1218
+ // a shorter sheet turns some label pairs back into real wires.
1219
+ const paperHint = intent.hints?.paper;
1220
+ if (paperHint && !PAPERS.some((p) => p.name === paperHint)) {
1221
+ notes.push(`paper hint "${paperHint}" is not a standard size; deriving paper from content`);
1222
+ }
1223
+ const hinted = paperHint ? PAPERS.find((p) => p.name === paperHint) : undefined;
1224
+ // A hint pins the width budget; otherwise try every sheet, smallest first.
1225
+ const candidates = hinted ? [hinted] : PAPERS;
1226
+ const gap = GROUP_GAP * U;
1227
+ const usableW = (p: { w: number }): number => p.w - 2 * FRAME;
1228
+ const usableH = (p: { h: number }): number => p.h - 2 * FRAME - TITLE_STRIP;
1229
+
1230
+ /**
1231
+ * Shelf-wrap the group rects to a width budget; returns per-group offsets.
1232
+ *
1233
+ * Offsets are relative to where the single-row pass already put each group,
1234
+ * never absolute targets: a row that does not wrap gets dx = dy = 0 and its
1235
+ * geometry is bit-for-bit what it was. Re-deriving absolute positions here
1236
+ * would re-round every group's width through the grid and shift
1237
+ * long-standing layouts by a unit for no reason.
1238
+ */
1239
+ const wrapTo = (budgetW: number): { deltas: { dx: number; dy: number }[]; w: number; h: number } => {
1240
+ const originX = groupRects[0]!.x1;
1241
+ const leftExtOf = (name: string): number => groupExtents.get(name)?.left ?? 0;
1242
+ const rightExtOf = (name: string): number => groupExtents.get(name)?.right ?? 0;
1243
+ const deltas: { dx: number; dy: number }[] = [];
1244
+ let rowOriginX = originX;
1245
+ // Label text on the row's flanks needs budget too: a row filled to the
1246
+ // full usable width hangs its leading group's left-facing labels outside
1247
+ // the frame, where no later shift can reach them (#220, the shelf-wrap
1248
+ // analog of the band budget's reserved extents).
1249
+ let rowLeftExt = leftExtOf(groupRects[0]!.name);
1250
+ let dyUnits = 0;
1251
+ let rowH = 0;
1252
+ for (const r of groupRects) {
1253
+ // A group wider than the whole budget still starts its own row; it will
1254
+ // overflow, and the caller rejects this paper for it.
1255
+ if (r.x1 > rowOriginX && r.x2 - rowOriginX + rowLeftExt + rightExtOf(r.name) > budgetW) {
1256
+ dyUnits += Math.ceil((rowH + gap) / U);
1257
+ rowOriginX = r.x1;
1258
+ rowLeftExt = leftExtOf(r.name);
1259
+ rowH = 0;
1260
+ }
1261
+ deltas.push({ dx: grid(Math.round((originX - rowOriginX) / U)), dy: dyUnits * U });
1262
+ rowH = Math.max(rowH, r.y2 - r.y1);
1263
+ }
1264
+ const xs = groupRects.flatMap((r, i) => [r.x1 + deltas[i]!.dx, r.x2 + deltas[i]!.dx]);
1265
+ const ys = groupRects.flatMap((r, i) => [r.y1 + deltas[i]!.dy, r.y2 + deltas[i]!.dy]);
1266
+ return { deltas, w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
1267
+ };
1268
+
1269
+ type SheetFit = { paper: (typeof PAPERS)[number]; wrap: { deltas: { dx: number; dy: number }[]; w: number; h: number } | null };
1270
+ /**
1271
+ * Whether the current placement fits sheet `p`, with the group shelf-wrap
1272
+ * deltas that make it fit. `wrap` is null when there is nothing to reflow:
1273
+ * one group is already its own row, and an intent whose parts are all power
1274
+ * symbols has no group rect to measure from at all.
1275
+ */
1276
+ const fitsOn = (p: (typeof PAPERS)[number]): SheetFit | null => {
1277
+ if (groupRects.length > 1) {
1278
+ const w = wrapTo(usableW(p));
1279
+ return w.w <= usableW(p) && w.h <= usableH(p) ? { paper: p, wrap: w } : null;
1280
+ }
1281
+ const r = groupRects[0];
1282
+ return !r || (r.x2 - r.x1 <= usableW(p) && r.y2 - r.y1 <= usableH(p)) ? { paper: p, wrap: null } : null;
1283
+ };
1284
+ /** The smallest candidate sheet the current placement fits, or null. */
1285
+ const bestFit = (): SheetFit | null => {
1286
+ for (const p of candidates) {
1287
+ const f = fitsOn(p);
1288
+ if (f) return f;
1289
+ }
1290
+ return null;
1291
+ };
1292
+
1293
+ /**
1294
+ * Budgeted attempt at one sheet. The width budget alone reshapes a ribbon
1295
+ * into bands, but a group of stacked two-pin parts fills the HEIGHT first
1296
+ * and leaves the landscape width untouched (stickhub reflowed to 347 mm of
1297
+ * A1's 821 usable and still overflowed the bottom). Shorter column budgets
1298
+ * spread the same cells into more side-by-side columns, so walk the height
1299
+ * fractions until the content matches the sheet's aspect or nothing fits.
1300
+ */
1301
+ const tryPaperBudgeted = (p: (typeof PAPERS)[number]): { fit: SheetFit; bands: Map<string, number> } | null => {
1302
+ for (const frac of [1, 0.7, 0.5]) {
1303
+ const b = placeAllGroups(Math.floor(usableW(p) / U), Math.floor((usableH(p) * frac) / U));
1304
+ const f = fitsOn(p);
1305
+ if (f) return { fit: f, bands: b };
1306
+ }
1307
+ return null;
1308
+ };
1309
+
1310
+ let bands = placeAllGroups(Infinity);
1311
+ let fit = bestFit();
1312
+ if (!fit) {
1313
+ // No sheet holds the natural ribbon even with whole groups wrapped into
1314
+ // rows: some group is by itself wider than the widest usable frame (#219
1315
+ // drew 94 parts as one strip four sizes past the designer's A3, with 367
1316
+ // out-of-frame findings — a sheet that would not plot). Growing the paper
1317
+ // cannot fix that, so instead wrap COLUMNS into bands inside the oversized
1318
+ // groups, targeting the smallest sheet that fits. Each attempt must fit
1319
+ // the sheet whose width it banded to: accepting a narrow banding on a
1320
+ // larger sheet would re-create the empty-ribbon failure, rotated 90°.
1321
+ for (const p of candidates) {
1322
+ const t = tryPaperBudgeted(p);
1323
+ if (t) {
1324
+ bands = t.bands;
1325
+ fit = t.fit;
1326
+ break;
1327
+ }
1328
+ }
1329
+ if (!fit) {
1330
+ const largest = candidates[candidates.length - 1]!;
1331
+ bands = placeAllGroups(Math.floor(usableW(largest) / U), Math.floor(usableH(largest) / U));
1332
+ fit = { paper: largest, wrap: groupRects.length > 1 ? wrapTo(usableW(largest)) : null };
1333
+ notes.push(
1334
+ `content does not fit the ${hinted ? 'hinted' : 'largest standard'} sheet (${largest.name}) even with groups and columns wrapped; the drawing will overflow the frame`,
1335
+ );
1336
+ }
1337
+ for (const [g, n] of bands) {
1338
+ if (n > 1) notes.push(`group "${g}" was wider than the sheet; its columns wrapped onto ${n} bands`);
1339
+ }
1340
+ } else if (!hinted && fit.paper !== PAPERS[0]) {
1341
+ // ---------- compaction (#220 phase 4) ----------
1342
+ // The natural ribbon FITS a sheet, but mostly with air: a 24-part board
1343
+ // whose parts stack into one full-height strip "fits" A1 while the person
1344
+ // drew the same circuit on A3. When the natural fit uses less than the
1345
+ // checker's utilization floor, retry the smaller sheets, smallest first,
1346
+ // with both budgets, and take the first that holds the reflowed content.
1347
+ // A paper hint pins the sheet and skips this entirely.
1348
+ // Utilization by INK, not bounding box: an L-shaped layout (a tall column
1349
+ // strip plus a wide bank ribbon) spans a bbox that reads "full" while the
1350
+ // sheet is mostly air, and the bbox measure let stickhub sprawl onto A0
1351
+ // uncompacted. The sum of placed cell areas is what is actually drawn.
1352
+ const inkArea = [...placed.values()].reduce((s, p) => s + p.cellW * p.cellH * U * U, 0);
1353
+ const utilOf = (f: SheetFit): number => inkArea / (usableW(f.paper) * usableH(f.paper));
1354
+ const naturalUtil = utilOf(fit);
1355
+ if (naturalUtil < COMPACT_UTILIZATION) {
1356
+ const naturalPaper = fit.paper;
1357
+ let compacted: SheetFit | null = null;
1358
+ let compactedBands = bands;
1359
+ for (const p of candidates) {
1360
+ if (p === naturalPaper) break; // only sheets smaller than the natural fit
1361
+ const t = tryPaperBudgeted(p);
1362
+ if (t) {
1363
+ compacted = t.fit;
1364
+ compactedBands = t.bands;
1365
+ break;
1366
+ }
1367
+ }
1368
+ if (compacted) {
1369
+ bands = compactedBands;
1370
+ fit = compacted;
1371
+ notes.push(
1372
+ `sheet compacted: the natural layout fit ${naturalPaper.name} at ${Math.round(naturalUtil * 100)}% utilization; reflowed onto ${fit.paper.name}`,
1373
+ );
1374
+ for (const [g, n] of bands) {
1375
+ if (n > 1) notes.push(`group "${g}" was wider than the sheet; its columns wrapped onto ${n} bands`);
1376
+ }
1377
+ } else {
1378
+ // nothing smaller holds the reflowed content: restore the natural
1379
+ // placement byte for byte
1380
+ bands = placeAllGroups(Infinity);
1381
+ fit = bestFit()!;
1382
+ }
1383
+ }
1384
+ }
1385
+ if (fit.wrap) {
1386
+ const wrap = fit.wrap;
1387
+ const rows = new Set(wrap.deltas.map((d) => d.dy)).size;
1388
+ if (rows > 1) notes.push(`groups wrapped onto ${rows} rows to fit the sheet`);
1389
+ groupRects.forEach((r, i) => {
1390
+ const d = wrap.deltas[i]!;
1391
+ if (!d.dx && !d.dy) return;
1392
+ for (const ref of [...groupOf.entries()].filter(([, g]) => g === r.name).map(([ref]) => ref)) {
1393
+ const pl = placed.get(ref);
1394
+ if (!pl) continue;
1395
+ pl.x += d.dx;
1396
+ pl.y += d.dy;
1397
+ pl.body.minX += d.dx;
1398
+ pl.body.maxX += d.dx;
1399
+ pl.body.minY += d.dy;
1400
+ pl.body.maxY += d.dy;
1401
+ }
1402
+ r.x1 += d.dx;
1403
+ r.x2 += d.dx;
1404
+ r.y1 += d.dy;
1405
+ r.y2 += d.dy;
1406
+ });
1407
+ }
1408
+
1409
+ // ---------- stubs, power symbols, labels, wires (design D2/D6a) ----------
1410
+ const wires: PlacementModel['wires'] = [];
1411
+ const labels: PlacementModel['labels'] = [];
1412
+ const junctions: { x: number; y: number }[] = [];
1413
+ const extraSymbols: EmitSymbol[] = [];
1414
+ const libSymbols = new Map<string, string>();
1415
+ const pwrFlags: string[] = [];
1416
+ /** Labels sitting at a stub end, with the stub they may ride outward. */
1417
+ const stubbedLabels: {
1418
+ label: number;
1419
+ wire: number;
1420
+ o: { dx: number; dy: number };
1421
+ /** The net's own endpoints, so a clearance check can ignore them (#217). */
1422
+ pins: string[];
1423
+ }[] = [];
1424
+ /**
1425
+ * Wired-net labels with every wire point of their run as fallback anchors.
1426
+ * `pts` is sorted for anchor preference (topmost-leftmost first) and so says
1427
+ * nothing about which point joins which; `segs` keeps the run's segments in
1428
+ * emission order, which is what the interior walk must step along.
1429
+ */
1430
+ const wiredLabels: {
1431
+ label: number;
1432
+ pts: { x: number; y: number }[];
1433
+ segs: Seg[];
1434
+ }[] = [];
1435
+ let wireIdx = new Map<string, number>();
1436
+ const addWire = (net: string, x1: number, y1: number, x2: number, y2: number): void => {
1437
+ if (sameCoord(x1, x2) && sameCoord(y1, y2)) return; // zero-length once emitted
1438
+
1439
+ const i = wireIdx.get(net) ?? 0;
1440
+ wireIdx.set(net, i + 1);
1441
+ wires.push({ x1, y1, x2, y2, net, index: i });
1442
+ };
1443
+
1444
+ let pwrSeq = 0;
1445
+ let flgSeq = 0;
1446
+ const endpointsOf = (net: IntentNet): { ref: string; pin: DraftPin; at: { x: number; y: number } }[] => {
1447
+ const eps = net.pins
1448
+ // a common (unit-0) pin is drawn on every placed instance of its part;
1449
+ // every appearance is wired to this same net, so the appearances stay
1450
+ // one electrical point and no drawn pin end dangles
1451
+ .flatMap((ep) => {
1452
+ const m = /^([^.]+)\.(.+)$/.exec(ep)!;
1453
+ return expandEp(m[1]!, m[2]!).map((inst) => {
1454
+ const pl = placed.get(inst.key);
1455
+ const pin = pl?.sym.pins.find((p) => p.number === m[2]);
1456
+ if (!pl || !pin) return null;
1457
+ return { ref: inst.key, pin, at: pinAt(pl, pin) };
1458
+ });
1459
+ })
1460
+ .filter((e): e is NonNullable<typeof e> => e !== null)
1461
+ .sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }) || a.pin.number.localeCompare(b.pin.number, undefined, { numeric: true }));
1462
+ // Stacked pins are one point on the sheet: KiCad symbols routinely repeat a
1463
+ // pin at the same coordinate (a thermal pad carried as a second GND pin, a
1464
+ // doubled supply pin). Drafting per PIN would stack a stub, a power symbol,
1465
+ // and its value text exactly on top of an identical one — invisible in the
1466
+ // render, an overlap to the checker, redundant to a reviewer. One item per
1467
+ // POINT; connectivity is unchanged because the pins share the point.
1468
+ return eps.filter((e, i) => eps.findIndex((o) => o.at.x === e.at.x && o.at.y === e.at.y) === i);
1469
+ };
1470
+
1471
+ // power-class nets: per-pin power symbols, rails up, grounds down; one
1472
+ // PWR_FLAG per net without a power_out driver (design D6a). The stub runs in
1473
+ // the pin's OUTWARD direction — a fixed vertical drop would land on the next
1474
+ // pin of a connector-style part (2 grid rows apart) and short two nets.
1475
+ // Horizontal stubs run 4 units so their symbol clears the 2-unit signal
1476
+ // stubs and label anchors of neighbouring rows.
1477
+ const powerBodies = [...placed.values()].map((p) => p.body);
1478
+
1479
+ /**
1480
+ * True when any of `segs` touches a FOREIGN connection point: a pin, the
1481
+ * stub end any connected pin grows (predicted — stubs of nets sorted later
1482
+ * are not emitted yet), or an already-emitted wire of another net. KiCad
1483
+ * joins wires at coincident endpoints and at an endpoint on a wire's
1484
+ * interior, so any such contact merges nets (I22, #204).
1485
+ *
1486
+ * Used by every pass that decides where a wire may end: the trunk-and-branch
1487
+ * veto, the labelled-stub fallback (the cap-to-ground drop placed a cap whose
1488
+ * own 2-unit stub ended exactly on the neighbouring power pin's stub interior,
1489
+ * so stubs need the check as much as trunks), the power-stub ladder, and the
1490
+ * label nudge. Defined here rather than beside the signal pass because the
1491
+ * power pass below runs first and needs it too (#217).
1492
+ */
1493
+ const touchesForeign = (
1494
+ segs: { x1: number; y1: number; x2: number; y2: number }[],
1495
+ netName: string,
1496
+ ownEps: Set<string>,
1497
+ opts: { predictStubs?: boolean } = {},
1498
+ ): boolean => {
1499
+ const predictStubs = opts.predictStubs ?? true;
1500
+ for (const opl of placed.values()) {
1501
+ for (const pin of opl.sym.pins) {
1502
+ const ep = `${opl.refDes}.${pin.number}`;
1503
+ const onet = netByEndpoint.get(ep);
1504
+ // A pin with NO net is still a connection point: a wire through it
1505
+ // joins it in KiCad. jetson-agx-thor-baseboard put an L-route corner
1506
+ // exactly on such a pin (J14.46, a single-pin group the intent never
1507
+ // names) and shipped a merged net past every gate. Only the pin's
1508
+ // POINT is guarded for netless pins; there is no stub to predict.
1509
+ if (onet?.name === netName || ownEps.has(ep)) continue;
1510
+ const p = pinAt(opl, pin);
1511
+ if (segs.some((c) => pointOnSeg(p.x, p.y, c))) return true;
1512
+ if (!predictStubs || !onet) continue;
1513
+ const o = outward(pin);
1514
+ const len = (netClasses.get(onet.name)?.cls ?? 'signal') !== 'signal' && o.dx !== 0 ? STUB + 2 : STUB;
1515
+ const end = { x: p.x + o.dx * len * U, y: p.y + o.dy * len * U };
1516
+ if (segs.some((c) => pointOnSeg(end.x, end.y, c))) return true;
1517
+ }
1518
+ }
1519
+ for (const w of wires) {
1520
+ if (w.net === netName) continue;
1521
+ if (
1522
+ segs.some(
1523
+ (c) =>
1524
+ pointOnSeg(w.x1, w.y1, c) ||
1525
+ pointOnSeg(w.x2, w.y2, c) ||
1526
+ pointOnSeg(c.x1, c.y1, w) ||
1527
+ pointOnSeg(c.x2, c.y2, w),
1528
+ )
1529
+ ) {
1530
+ return true;
1531
+ }
1532
+ }
1533
+ return false;
1534
+ };
1535
+
1536
+ /** Visible power value texts placed so far, for the collision rules below. */
1537
+ const shownPowerValues: { net: string; box: Bounds }[] = [];
1538
+ const powerValueBox = (net: string, x: number, y: number): Bounds => {
1539
+ const w = Math.max(1, net.length) * LABEL_ADVANCE * LABEL_HEIGHT;
1540
+ return { minX: x - w / 2, minY: y - LABEL_HEIGHT / 2, maxX: x + w / 2, maxY: y + LABEL_HEIGHT / 2 };
1541
+ };
1542
+ for (const net of [...powerNets].sort((a, b) => a.name.localeCompare(b.name))) {
1543
+ const cls = netClasses.get(net.name)!.cls;
1544
+ const src = powerSymbolSource(net.name, cls === 'ground' ? 'ground' : 'rail');
1545
+ libSymbols.set(src.libId, src.sourceText);
1546
+ const hasDriver = net.pins.some((ep) => pinLookup(ep)?.etype === 'power_out');
1547
+ const eps = endpointsOf(net);
1548
+ /** One PWR_FLAG per undriven net, on the net's FIRST endpoint — whether
1549
+ * that endpoint drafts as a bank member or a lone symbol. */
1550
+ const maybeFlag = (i: number, x: number, y: number): void => {
1551
+ if (i !== 0 || hasDriver) return;
1552
+ const flag = pwrFlagSource();
1553
+ libSymbols.set(flag.libId, flag.sourceText);
1554
+ flgSeq++;
1555
+ extraSymbols.push({
1556
+ ref: `#FLG${String(flgSeq).padStart(2, '0')}`,
1557
+ libId: flag.libId,
1558
+ value: 'PWR_FLAG',
1559
+ footprint: '',
1560
+ at: { x, y, rot: 0 },
1561
+ refAt: { x, y },
1562
+ valueAt: { x, y },
1563
+ hideRef: true,
1564
+ hideValue: true,
1565
+ pinNumbers: ['1'],
1566
+ });
1567
+ pwrFlags.push(net.name);
1568
+ };
1569
+ // ---------- rail-bank trunks (#233, #220 phase 3) ----------
1570
+ // Decap-row caps adjacent on the same power net chain on ONE trunk: a
1571
+ // stub per pin, horizontal joins between consecutive stub ends, a single
1572
+ // power symbol and value at the first end. The human's sixteen-cap VBUS
1573
+ // bank carries two power symbols; the per-pin idiom drew twenty-six.
1574
+ // Every stub and trunk segment must clear foreign points and bodies, and
1575
+ // a member that cannot join cleanly splits the run — a bank never buys
1576
+ // density with a merged net.
1577
+ const consumed = new Set<number>();
1578
+ {
1579
+ const ownEps = new Set(net.pins);
1580
+ // Structural, not name-based: any two-pin part with a vertical pin on
1581
+ // this power net banks — real boards carry their caps under embedded,
1582
+ // renamed symbols the Device:C test never matches, and a pull-up array
1583
+ // on one rail trunk is drawn the same way by hand.
1584
+ const cands = eps
1585
+ .map((ep, i) => ({ ep, i, o: outward(ep.pin) }))
1586
+ .filter((c) => c.o.dy !== 0 && c.o.dx === 0 && (placed.get(c.ep.ref)?.sym.pins.length ?? 0) === 2);
1587
+ const byLine = new Map<string, typeof cands>();
1588
+ for (const c of cands) {
1589
+ const k = `${c.o.dy}|${knum(c.ep.at.y + c.o.dy * STUB * U)}`;
1590
+ byLine.set(k, [...(byLine.get(k) ?? []), c]);
1591
+ }
1592
+ const stubClear = (c: (typeof cands)[number]): boolean => {
1593
+ const end = { x: c.ep.at.x, y: c.ep.at.y + c.o.dy * STUB * U };
1594
+ return !touchesForeign([{ x1: c.ep.at.x, y1: c.ep.at.y, x2: end.x, y2: end.y }], net.name, ownEps, {
1595
+ predictStubs: false,
1596
+ });
1597
+ };
1598
+ /**
1599
+ * A trunk may not cross the LINE a foreign stub could grow along. The
1600
+ * geometry is `segCrossesStubGrowth` (tested directly); this walks every
1601
+ * foreign pin over it at the signal stub's maximum grown length.
1602
+ */
1603
+ const crossesForeignStubLine = (seg: Seg): boolean => {
1604
+ for (const opl of placed.values()) {
1605
+ for (const pin of opl.sym.pins) {
1606
+ const ep = `${opl.refDes}.${pin.number}`;
1607
+ const onet = netByEndpoint.get(ep);
1608
+ if (!onet || onet.name === net.name) continue;
1609
+ if (segCrossesStubGrowth(seg, pinAt(opl, pin), outward(pin), (STUB + 2) * U, SEG_EPS)) return true;
1610
+ }
1611
+ }
1612
+ return false;
1613
+ };
1614
+ const emitBank = (run: typeof cands): void => {
1615
+ const dy = run[0]!.o.dy;
1616
+ const ends = run.map((c) => ({ x: c.ep.at.x, y: c.ep.at.y + dy * STUB * U }));
1617
+ run.forEach((c, j) => {
1618
+ addWire(net.name, c.ep.at.x, c.ep.at.y, ends[j]!.x, ends[j]!.y);
1619
+ consumed.add(c.i);
1620
+ });
1621
+ for (let j = 1; j < ends.length; j++) {
1622
+ addWire(net.name, ends[j - 1]!.x, ends[j - 1]!.y, ends[j]!.x, ends[j]!.y);
1623
+ }
1624
+ const first = ends[0]!;
1625
+ const valueAt = { x: first.x, y: first.y + dy * 3.556 };
1626
+ const box = powerValueBox(net.name, valueAt.x, valueAt.y);
1627
+ const hideValue = shownPowerValues.some((p) => p.net === net.name && boundsOverlap(p.box, box));
1628
+ if (!hideValue) shownPowerValues.push({ net: net.name, box });
1629
+ pwrSeq++;
1630
+ extraSymbols.push({
1631
+ ref: `#PWR${String(pwrSeq).padStart(2, '0')}`,
1632
+ libId: src.libId,
1633
+ value: net.name,
1634
+ footprint: '',
1635
+ at: { x: first.x, y: first.y, rot: 0 },
1636
+ refAt: { x: first.x, y: first.y },
1637
+ valueAt,
1638
+ hideRef: true,
1639
+ hideValue,
1640
+ pinNumbers: ['1'],
1641
+ });
1642
+ for (const [j, c] of run.entries()) maybeFlag(c.i, ends[j]!.x, ends[j]!.y);
1643
+ };
1644
+ const joinClear = (prev: (typeof cands)[number], c: (typeof cands)[number]): boolean => {
1645
+ const y = c.ep.at.y + c.o.dy * STUB * U;
1646
+ const seg = { x1: prev.ep.at.x, y1: y, x2: c.ep.at.x, y2: y };
1647
+ return (
1648
+ c.ep.at.x - prev.ep.at.x <= BANK_PITCH_MAX * U &&
1649
+ !powerBodies.some((b) => segCrossesBody(seg.x1, seg.y1, seg.x2, seg.y2, b)) &&
1650
+ !touchesForeign([seg], net.name, ownEps, { predictStubs: true }) &&
1651
+ !crossesForeignStubLine(seg)
1652
+ );
1653
+ };
1654
+ for (const line of [...byLine.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([, v]) => v)) {
1655
+ line.sort((a, b) => a.ep.at.x - b.ep.at.x);
1656
+ for (const run of splitBankRuns(line, stubClear, joinClear)) emitBank(run);
1657
+ }
1658
+ }
1659
+ eps.forEach((ep, i) => {
1660
+ if (consumed.has(i)) return;
1661
+ const o = outward(ep.pin);
1662
+ let len = o.dx !== 0 ? STUB + 2 : STUB;
1663
+ // Fair share of the channel: a stub may never cross the MIDLINE to the
1664
+ // nearest facing foreign pin on its own line, whatever the text pass
1665
+ // wants. Drafting order decides who draws first, and a first-drafted
1666
+ // stub that fills the channel leaves the facing pin no clear rung at
1667
+ // any length — jetson's text-grown 8-unit stub in a 9-unit channel did
1668
+ // exactly that, and the facing GND shipped touching (a refusal).
1669
+ let maxLen = Infinity;
1670
+ for (const opl of placed.values()) {
1671
+ for (const pin of opl.sym.pins) {
1672
+ const fep = `${opl.refDes}.${pin.number}`;
1673
+ if (netByEndpoint.get(fep)?.name === net.name) continue;
1674
+ const p2 = pinAt(opl, pin);
1675
+ if (o.dx !== 0 && sameCoord(p2.y, ep.at.y) && Math.sign(p2.x - ep.at.x) === o.dx) {
1676
+ maxLen = Math.min(maxLen, Math.max(1, Math.floor(Math.abs(p2.x - ep.at.x) / U / 2)));
1677
+ } else if (o.dy !== 0 && sameCoord(p2.x, ep.at.x) && Math.sign(p2.y - ep.at.y) === o.dy) {
1678
+ maxLen = Math.min(maxLen, Math.max(1, Math.floor(Math.abs(p2.y - ep.at.y) / U / 2)));
1679
+ }
1680
+ }
1681
+ }
1682
+ if (len > maxLen) len = maxLen;
1683
+ const at = (l: number): { x: number; y: number } => ({ x: ep.at.x + o.dx * l * U, y: ep.at.y + o.dy * l * U });
1684
+ const valueAtOf = (end: { x: number; y: number }): { x: number; y: number } => ({
1685
+ x: end.x,
1686
+ y: end.y + (o.dy !== 0 ? o.dy * 3.556 : cls === 'ground' ? 3.556 : -3.556),
1687
+ });
1688
+ // Adjacent power pins collide their value texts two ways, resolved two
1689
+ // ways. The SAME net repeated (a TQFP's VCC pins one row apart) hides
1690
+ // the duplicates: one visible name per cluster carries the same
1691
+ // information. A DIFFERENT net's name may never be hidden, so its
1692
+ // symbol rides its own stub outward, a bounded grid step at a time,
1693
+ // until the text clears — keeping "every gate failure is resolvable
1694
+ // through the IR" true for a collision the IR cannot otherwise reach.
1695
+ let hideValue = false;
1696
+ {
1697
+ const first = valueAtOf(at(len));
1698
+ const firstBox = powerValueBox(net.name, first.x, first.y);
1699
+ if (shownPowerValues.some((p) => p.net === net.name && boundsOverlap(p.box, firstBox))) {
1700
+ hideValue = true;
1701
+ } else {
1702
+ for (let extra = 0; extra < 4; extra++) {
1703
+ const v = valueAtOf(at(len));
1704
+ const b = powerValueBox(net.name, v.x, v.y);
1705
+ if (!shownPowerValues.some((p) => p.net !== net.name && boundsOverlap(p.box, b))) break;
1706
+ if (len + 2 > maxLen) break; // text never buys past the midline
1707
+ const extended = at(len + 2);
1708
+ if (powerBodies.some((bd) => segCrossesBody(ep.at.x, ep.at.y, extended.x, extended.y, bd))) break;
1709
+ len += 2;
1710
+ }
1711
+ }
1712
+ }
1713
+ /**
1714
+ * The length chosen above answers a typographic question: where does the
1715
+ * value text stop colliding. It says nothing about where the stub's
1716
+ * ENDPOINT lands, and a power stub that ends on another net's stub is a
1717
+ * shorted rail (#217: cm5_minima put a +5V pin 4 units above a GND pin,
1718
+ * both stubs grew 2 units toward each other, and they met exactly in the
1719
+ * middle — the drawn sheet ties +5V to GND).
1720
+ *
1721
+ * So the text-driven length is only a preference. Try it first, then
1722
+ * lengths either side of it, and take the first whose endpoint touches no
1723
+ * foreign connection point. Shorter is in the ladder deliberately: two
1724
+ * pins facing each other cannot be separated by growing the stub, only by
1725
+ * pulling it back. One unit is the floor — the power symbol still needs
1726
+ * somewhere to sit.
1727
+ *
1728
+ * When nothing clears, keep the preferred length and let the merged-net
1729
+ * gate refuse loudly. Shipping a quiet short is the one outcome barred.
1730
+ */
1731
+ const ownPinEps = new Set(net.pins);
1732
+ /**
1733
+ * Which bodies a stub of length `l` would cross, by index.
1734
+ *
1735
+ * A pin sits ON its own part's outline, so EVERY length crosses at least
1736
+ * that body, including the preferred one the engine ships today. Treating
1737
+ * any crossing as disqualifying would veto the whole ladder (it did, on
1738
+ * the first attempt at #217). What matters is that moving the stub does
1739
+ * not put it through something the preferred length was already clear of.
1740
+ */
1741
+ const crossedBy = (l: number): Set<number> => {
1742
+ const e = at(l);
1743
+ const out = new Set<number>();
1744
+ powerBodies.forEach((bd, i) => {
1745
+ if (segCrossesBody(ep.at.x, ep.at.y, e.x, e.y, bd)) out.add(i);
1746
+ });
1747
+ return out;
1748
+ };
1749
+ const baseCrossed = crossedBy(len);
1750
+ const clearsAt = (l: number): boolean => {
1751
+ if ([...crossedBy(l)].some((i) => !baseCrossed.has(i))) return false;
1752
+ const e = at(l);
1753
+ return !touchesForeign([{ x1: ep.at.x, y1: ep.at.y, x2: e.x, y2: e.y }], net.name, ownPinEps, {
1754
+ predictStubs: false,
1755
+ });
1756
+ };
1757
+ /**
1758
+ * Whether the value text would still be clear at length `l`. The length
1759
+ * chosen above already answers this for the preferred length; the ladder
1760
+ * has to keep answering it, or a stub moved for electrical reasons drags
1761
+ * its rail name into a neighbouring body (it dragged "+3V3" into R1 on
1762
+ * the pull-up idiom the first time this ladder was written).
1763
+ *
1764
+ * Only a preference: a text collision is a legibility cost the report
1765
+ * names, while a merged net refuses the draft outright. So a rung that is
1766
+ * electrically clear but typographically ugly still beats no rung at all.
1767
+ */
1768
+ const textClearAt = (l: number): boolean => {
1769
+ if (hideValue) return true;
1770
+ const v = valueAtOf(at(l));
1771
+ const b = powerValueBox(net.name, v.x, v.y);
1772
+ if (powerBodies.some((bd) => boundsOverlap(b, bd))) return false;
1773
+ return !shownPowerValues.some((p) => p.net !== net.name && boundsOverlap(p.box, b));
1774
+ };
1775
+ if (!clearsAt(len)) {
1776
+ const ladder: number[] = [];
1777
+ for (let d = 1; d <= MAX_POWER_STUB_SHIFT; d++) {
1778
+ if (len + d <= maxLen) ladder.push(len + d);
1779
+ if (len - d >= 1) ladder.push(len - d);
1780
+ }
1781
+ // Full retreat, beyond the bounded shift: the TEXT-driven growth above
1782
+ // can carry `len` so far out that every rung within
1783
+ // MAX_POWER_STUB_SHIFT still overlaps the facing pin's stub, and the
1784
+ // electrically clear short lengths sit out of reach (jetson's D2
1785
+ // shipped a 10-unit VCC_IN stub through the facing net's 4-unit stub
1786
+ // and its power symbol that way). Last rungs, so any bounded rung
1787
+ // that clears still wins and existing layouts do not move.
1788
+ for (let l = Math.min(len, STUB + 2); l >= 1; l--) {
1789
+ if (!ladder.includes(l)) ladder.push(l);
1790
+ }
1791
+ const freed = ladder.find((l) => clearsAt(l) && textClearAt(l)) ?? ladder.find(clearsAt);
1792
+ if (freed !== undefined) len = freed;
1793
+ }
1794
+ const stubEnd = at(len);
1795
+ const valueAt = valueAtOf(stubEnd);
1796
+ if (!hideValue) shownPowerValues.push({ net: net.name, box: powerValueBox(net.name, valueAt.x, valueAt.y) });
1797
+ addWire(net.name, ep.at.x, ep.at.y, stubEnd.x, stubEnd.y);
1798
+ pwrSeq++;
1799
+ extraSymbols.push({
1800
+ ref: `#PWR${String(pwrSeq).padStart(2, '0')}`,
1801
+ libId: src.libId,
1802
+ value: net.name,
1803
+ footprint: '',
1804
+ at: { x: stubEnd.x, y: stubEnd.y, rot: 0 },
1805
+ refAt: { x: stubEnd.x, y: stubEnd.y },
1806
+ // The bar is drawn on a fixed side of its own pin (rails above, grounds
1807
+ // below), but a stub leaves its pin in whatever direction the pin
1808
+ // faces. Offsetting the value by class alone therefore throws the text
1809
+ // back across the stub and into the part whenever the two disagree —
1810
+ // a rail hanging off a downward pin puts "+5V" on the symbol above it.
1811
+ // The text follows the stub outward, so it always lands on the far
1812
+ // side of the symbol from the part it serves.
1813
+ valueAt,
1814
+ hideRef: true,
1815
+ // the net name IS the flag's meaning: an anonymous bar tells a
1816
+ // reviewer nothing, so the value stays visible like stock power
1817
+ // symbols (the checker verifies it collides with nothing)
1818
+ hideValue,
1819
+ pinNumbers: ['1'],
1820
+ });
1821
+ // the flag's pin sits exactly on the stub END so KiCad's connectivity
1822
+ // (which joins at wire endpoints) sees the power_out driver
1823
+ maybeFlag(i, stubEnd.x, stubEnd.y);
1824
+ });
1825
+ }
1826
+
1827
+ // signal nets: local nets wired, everything else labelled at a stub
1828
+ const bodies = [...placed.values()].map((p) => p.body);
1829
+ let wired = 0;
1830
+ let labelled = 0;
1831
+ for (const net of [...signalNets].sort((a, b) => a.name.localeCompare(b.name))) {
1832
+ const eps = endpointsOf(net);
1833
+ if (!eps.length) continue;
1834
+ const stubs = eps.map((ep) => {
1835
+ const o = outward(ep.pin);
1836
+ return { ep, end: { x: ep.at.x + o.dx * STUB * U, y: ep.at.y + o.dy * STUB * U }, o };
1837
+ });
1838
+ const groupsTouched = new Set(eps.map((e) => groupOf.get(e.ref)));
1839
+ const spanX = Math.max(...stubs.map((s) => s.end.x)) - Math.min(...stubs.map((s) => s.end.x));
1840
+ const spanY = Math.max(...stubs.map((s) => s.end.y)) - Math.min(...stubs.map((s) => s.end.y));
1841
+ let asWire = groupsTouched.size === 1 && eps.length <= MAX_WIRED_ENDPOINTS && Math.max(spanX, spanY) <= MAX_WIRE_SPAN;
1842
+
1843
+ if (asWire) {
1844
+ // trunk-and-branch: a vertical trunk with horizontal branches. Several
1845
+ // deterministic trunk positions are tried in order (median stub x, right
1846
+ // of everything, left of everything); the first collision-free routing
1847
+ // wins, and if none exists the net falls back to labels — the engine may
1848
+ // never trip its own wire-through-symbol gate.
1849
+ const xs = stubs.map((s) => s.end.x).sort((a, b) => a - b);
1850
+ const ys = stubs.map((s) => s.end.y);
1851
+ const trunkCandidates = [
1852
+ grid(Math.round(xs[Math.floor(xs.length / 2)]! / U)),
1853
+ grid(Math.round(xs[xs.length - 1]! / U) + STUB),
1854
+ grid(Math.round(xs[0]! / U) - STUB),
1855
+ ];
1856
+ let routed = false;
1857
+ for (const trunkX of trunkCandidates) {
1858
+ const candidate: { x1: number; y1: number; x2: number; y2: number }[] = [];
1859
+ for (const s of stubs) {
1860
+ candidate.push({ x1: s.ep.at.x, y1: s.ep.at.y, x2: s.end.x, y2: s.end.y });
1861
+ if (!sameCoord(s.end.x, trunkX)) candidate.push({ x1: s.end.x, y1: s.end.y, x2: trunkX, y2: s.end.y });
1862
+ }
1863
+ // the trunk is split at every branch meet: coincident wire ENDPOINTS
1864
+ // are what both KiCad and the geometric netlister join on
1865
+ const meetYs = [...new Map(ys.map((y) => [knum(y), y])).values()].sort((a, b) => a - b);
1866
+ for (let i = 1; i < meetYs.length; i++) {
1867
+ candidate.push({ x1: trunkX, y1: meetYs[i - 1]!, x2: trunkX, y2: meetYs[i]! });
1868
+ }
1869
+ if (candidate.some((c) => bodies.some((b) => segCrossesBody(c.x1, c.y1, c.x2, c.y2, b)))) continue;
1870
+ // No candidate segment may touch a foreign connection point (I22,
1871
+ // #204): a trunk routed down a column of neighbouring stub ends
1872
+ // silently merges nets. Mirrors the chain pass's axisClear,
1873
+ // generalized to every candidate segment.
1874
+ if (touchesForeign(candidate, net.name, new Set(net.pins))) continue;
1875
+ for (const c of candidate) addWire(net.name, c.x1, c.y1, c.x2, c.y2);
1876
+ // one label names the wired net (topmost-leftmost wire point): the net
1877
+ // stays identifiable to PINOUT/drift and to a reviewer without a
1878
+ // label-per-pin, matching hand-drafting practice
1879
+ const pts = candidate.flatMap((c) => [
1880
+ { x: c.x1, y: c.y1 },
1881
+ { x: c.x2, y: c.y2 },
1882
+ ]);
1883
+ pts.sort((a, b) => a.y - b.y || a.x - b.x);
1884
+ labels.push({ name: net.name, x: pts[0]!.x, y: pts[0]!.y, rot: 0 });
1885
+ wiredLabels.push({ label: labels.length - 1, pts, segs: candidate.map((c) => ({ ...c })) });
1886
+ wired++;
1887
+ if (eps.length > 2) {
1888
+ for (const s of stubs) {
1889
+ const meet = sameCoord(s.end.x, trunkX) ? s.end : { x: trunkX, y: s.end.y };
1890
+ if (meet.y > Math.min(...ys) && meet.y < Math.max(...ys)) junctions.push(meet);
1891
+ }
1892
+ }
1893
+ routed = true;
1894
+ break;
1895
+ }
1896
+ if (!routed) asWire = false;
1897
+ }
1898
+ if (!asWire) {
1899
+ for (const s of stubs) {
1900
+ // A stub is a wire too: its endpoint resting on a foreign net's wire
1901
+ // or connection point merges nets exactly like a trunk would (the
1902
+ // cap-to-ground drop's 2-unit stub ended on the neighbouring power
1903
+ // pin's stub interior — I22's third face). Grow the stub a grid unit
1904
+ // at a time until the endpoint is clear; the interior then CROSSES
1905
+ // the foreign wire mid-segment, which does not connect. If no length
1906
+ // clears, emit the plain stub and let the merged-net gate refuse
1907
+ // loudly rather than ship the contact.
1908
+ let end = s.end;
1909
+ const own = new Set(net.pins);
1910
+ // Rungs in preference order: the classic 0..2 extensions first so
1911
+ // clear cases stay byte-identical, then deeper extensions, then a
1912
+ // one-unit retreat. A stub that ships with NO clear rung still ends
1913
+ // touching a foreign wire and the merge gate refuses the draft, so
1914
+ // every extra rung here is a board that drafts instead of refusing
1915
+ // (jetson's twelve wire-contact refusals were exactly this fallback).
1916
+ for (const len of [STUB, STUB + 1, STUB + 2, STUB + 3, STUB + 4, STUB + 5, STUB + 6, 1]) {
1917
+ const cand = {
1918
+ x: s.ep.at.x + s.o.dx * len * U,
1919
+ y: s.ep.at.y + s.o.dy * len * U,
1920
+ };
1921
+ if (!touchesForeign([{ x1: s.ep.at.x, y1: s.ep.at.y, x2: cand.x, y2: cand.y }], net.name, own)) {
1922
+ end = cand;
1923
+ break;
1924
+ }
1925
+ }
1926
+ addWire(net.name, s.ep.at.x, s.ep.at.y, end.x, end.y);
1927
+ // labels are always horizontal (drafting standard): leftward pins read
1928
+ // outward to the left, everything else extends to the right
1929
+ labels.push({ name: net.name, x: end.x, y: end.y, rot: s.o.dx === -1 ? 180 : 0 });
1930
+ stubbedLabels.push({ label: labels.length - 1, wire: wires.length - 1, o: s.o, pins: net.pins });
1931
+ labelled++;
1932
+ }
1933
+ }
1934
+ }
1935
+
1936
+ // ---------- member symbols with collision-free text slots ----------
1937
+ // Built BEFORE the label de-collision pass so the pass can treat every
1938
+ // visible ref/value text as an obstacle: the checker measures text-vs-text
1939
+ // collisions at error severity, so a box the checker will see must be a box
1940
+ // the avoider saw first.
1941
+ const emitSymbols: EmitSymbol[] = [];
1942
+ /** Emit entry with its placement, for the slot-refinement pass below (two
1943
+ * unit instances share one refdes, so `ref` alone no longer keys `placed`). */
1944
+ const emitPairs: { sym: EmitSymbol; pl: Placed }[] = [];
1945
+ /** What KiCad renders for the reference: a multi-unit instance shows its
1946
+ * unit letter (U1A, U1B), so width metrics must measure the rendered text. */
1947
+ const displayRefOf = (pl: Placed): string =>
1948
+ pl.unit !== null ? `${pl.refDes}${String.fromCharCode(64 + Math.min(pl.unit, 26))}` : pl.refDes;
1949
+ for (const [, pl] of [...placed.entries()].sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }))) {
1950
+ const pinSides = new Set(pl.sym.pins.map((p) => {
1951
+ const o = outward(p);
1952
+ return o.dx === -1 ? 'left' : o.dx === 1 ? 'right' : o.dy === -1 ? 'top' : 'bottom';
1953
+ }));
1954
+ const cy = (pl.body.minY + pl.body.maxY) / 2;
1955
+ const cx = (pl.body.minX + pl.body.maxX) / 2;
1956
+ const textW = Math.max(displayRefOf(pl).length, pl.part.value.length) * 0.8 * 1.27;
1957
+ let refAt: { x: number; y: number };
1958
+ let valueAt: { x: number; y: number };
1959
+ if (!pinSides.has('top')) {
1960
+ refAt = { x: cx, y: pl.body.minY - 2.54 };
1961
+ // value stacks above the ref when the bottom also carries pins, and sits
1962
+ // below the body otherwise
1963
+ valueAt = pinSides.has('bottom') ? { x: cx, y: pl.body.minY - 5.08 } : { x: cx, y: pl.body.maxY + 2.54 };
1964
+ } else if (
1965
+ pl.body.maxX - pl.body.minX >= textW + 2.54 &&
1966
+ pl.body.maxY - pl.body.minY >= 7.62
1967
+ ) {
1968
+ // pins on top AND a body big enough to hold its own name: a TQFP-class
1969
+ // part carries pins on all four sides, so every outside slot lands on
1970
+ // some pin's stub or label; the body interior is the one guaranteed-free
1971
+ // area, and it is where KiCad's own large symbols put their text
1972
+ refAt = { x: cx, y: cy - 1.27 };
1973
+ valueAt = { x: cx, y: cy + 1.27 };
1974
+ } else {
1975
+ refAt = { x: pl.body.maxX + textW / 2 + 1.27, y: cy - 1.27 };
1976
+ valueAt = { x: pl.body.maxX + textW / 2 + 1.27, y: cy + 1.27 };
1977
+ }
1978
+ const sym: EmitSymbol = {
1979
+ ref: pl.refDes,
1980
+ libId: pl.sym.libId,
1981
+ value: pl.part.value,
1982
+ footprint: pl.part.footprint ?? '',
1983
+ at: { x: pl.x, y: pl.y, rot: 0 },
1984
+ refAt,
1985
+ valueAt,
1986
+ pinNumbers: pl.sym.pins.map((p) => p.number),
1987
+ ...(pl.unit !== null ? { unit: pl.unit } : {}),
1988
+ };
1989
+ emitSymbols.push(sym);
1990
+ emitPairs.push({ sym, pl });
1991
+ libSymbols.set(pl.sym.libId, pl.sym.sourceText);
1992
+ }
1993
+
1994
+ /** Centered text box, matching the checker's `textBounds` metrics. */
1995
+ const centeredTextBox = (s: string, x: number, y: number): Bounds => {
1996
+ const w = Math.max(1, s.length) * LABEL_ADVANCE * LABEL_HEIGHT;
1997
+ return { minX: x - w / 2, minY: y - LABEL_HEIGHT / 2, maxX: x + w / 2, maxY: y + LABEL_HEIGHT / 2 };
1998
+ };
1999
+ /** Text-box-vs-segment overlap; hoisted so slot refinement below and the
2000
+ * label pass share one metric. */
2001
+ const segHitsBoxEarly = (w: { x1: number; y1: number; x2: number; y2: number }, b: Bounds): boolean =>
2002
+ Math.min(w.x1, w.x2) < b.maxX - 0.01 &&
2003
+ Math.max(w.x1, w.x2) > b.minX + 0.01 &&
2004
+ Math.min(w.y1, w.y2) < b.maxY - 0.01 &&
2005
+ Math.max(w.y1, w.y2) > b.minY + 0.01;
2006
+
2007
+ // ---------- symbol-field slot refinement (I23, #210) ----------
2008
+ // The heuristic slots above consult nothing: attempt-07 ended ERC-clean
2009
+ // with 8 error-severity findings that were exactly these ref/value fields
2010
+ // sitting on wires and neighbouring bodies, with no IR lever to move them.
2011
+ // Re-slot each dirty pair down a deterministic ladder; the first slot whose
2012
+ // boxes clear every wire, every FOREIGN body, and all field text placed so
2013
+ // far wins. Where the heuristic is already clean the output is
2014
+ // byte-identical; where nothing clears, the heuristic stays so the checker
2015
+ // still reports the collision honestly.
2016
+ {
2017
+ const fieldBoxes: Bounds[] = extraSymbols
2018
+ .filter((s) => !s.hideValue)
2019
+ .map((s) => centeredTextBox(s.value, s.valueAt.x, s.valueAt.y));
2020
+ for (const { sym, pl } of emitPairs) {
2021
+ const dref = displayRefOf(pl);
2022
+ const cx = (pl.body.minX + pl.body.maxX) / 2;
2023
+ const cy = (pl.body.minY + pl.body.maxY) / 2;
2024
+ const textW = Math.max(dref.length, sym.value.length) * 0.8 * 1.27;
2025
+ const pairClear = (r: { x: number; y: number }, v: { x: number; y: number }): boolean => {
2026
+ for (const b of [centeredTextBox(dref, r.x, r.y), centeredTextBox(sym.value, v.x, v.y)]) {
2027
+ if (wires.some((w) => segHitsBoxEarly(w, b))) return false;
2028
+ for (const op of placed.values()) {
2029
+ if (op !== pl && boundsOverlap(b, op.body)) return false;
2030
+ }
2031
+ if (fieldBoxes.some((t) => boundsOverlap(t, b))) return false;
2032
+ }
2033
+ return true;
2034
+ };
2035
+ if (!pairClear(sym.refAt, sym.valueAt)) {
2036
+ const ladder: [{ x: number; y: number }, { x: number; y: number }][] = [];
2037
+ for (const extra of [0, 2.54]) {
2038
+ ladder.push(
2039
+ [{ x: cx, y: pl.body.maxY + 2.54 + extra }, { x: cx, y: pl.body.maxY + 5.08 + extra }],
2040
+ [{ x: cx, y: pl.body.minY - 5.08 - extra }, { x: cx, y: pl.body.minY - 2.54 - extra }],
2041
+ [
2042
+ { x: pl.body.maxX + textW / 2 + 1.27 + extra, y: cy - 1.27 },
2043
+ { x: pl.body.maxX + textW / 2 + 1.27 + extra, y: cy + 1.27 },
2044
+ ],
2045
+ [
2046
+ { x: pl.body.minX - textW / 2 - 1.27 - extra, y: cy - 1.27 },
2047
+ { x: pl.body.minX - textW / 2 - 1.27 - extra, y: cy + 1.27 },
2048
+ ],
2049
+ );
2050
+ }
2051
+ for (const [r, v] of ladder) {
2052
+ if (pairClear(r, v)) {
2053
+ sym.refAt = r;
2054
+ sym.valueAt = v;
2055
+ break;
2056
+ }
2057
+ }
2058
+ }
2059
+ fieldBoxes.push(centeredTextBox(dref, sym.refAt.x, sym.refAt.y), centeredTextBox(sym.value, sym.valueAt.x, sym.valueAt.y));
2060
+ }
2061
+ }
2062
+
2063
+ /** Every visible ref/value text the checker will measure. */
2064
+ const textObstacles: Bounds[] = [
2065
+ ...emitPairs.flatMap(({ sym: s, pl }) => [centeredTextBox(displayRefOf(pl), s.refAt.x, s.refAt.y), centeredTextBox(s.value, s.valueAt.x, s.valueAt.y)]),
2066
+ ...extraSymbols.filter((s) => !s.hideValue).map((s) => centeredTextBox(s.value, s.valueAt.x, s.valueAt.y)),
2067
+ ];
2068
+
2069
+ // Nets are drafted in name order, so a net can only avoid what is already on
2070
+ // the sheet: "COMP" cannot see the trunk "COMP_Z" is about to run through the
2071
+ // very point its label occupies. This pass runs once the routing is complete
2072
+ // and walks each stub-anchored label outward a grid unit at a time until its
2073
+ // text box clears every foreign wire, body, and visible ref/value text. The
2074
+ // anchor rides the stub it extends, so the label stays attached and
2075
+ // connectivity never changes.
2076
+ const segHitsBox = (w: { x1: number; y1: number; x2: number; y2: number }, b: Bounds): boolean =>
2077
+ Math.min(w.x1, w.x2) < b.maxX - 0.01 &&
2078
+ Math.max(w.x1, w.x2) > b.minX + 0.01 &&
2079
+ Math.min(w.y1, w.y2) < b.maxY - 0.01 &&
2080
+ Math.max(w.y1, w.y2) > b.minY + 0.01;
2081
+ /** Is (x, y) on the (axis-aligned) segment, endpoints included? */
2082
+ const segContains = (w: { x1: number; y1: number; x2: number; y2: number }, x: number, y: number): boolean =>
2083
+ x >= Math.min(w.x1, w.x2) - 0.01 &&
2084
+ x <= Math.max(w.x1, w.x2) + 0.01 &&
2085
+ y >= Math.min(w.y1, w.y2) - 0.01 &&
2086
+ y <= Math.max(w.y1, w.y2) + 0.01 &&
2087
+ (Math.abs(w.x1 - w.x2) < 0.01 ? Math.abs(x - w.x1) <= 0.01 : Math.abs(y - w.y1) <= 0.01);
2088
+
2089
+ // Wired-net labels first: the single label naming a wired run used to be
2090
+ // pinned at the topmost-leftmost wire point with no clearance check, and on
2091
+ // a drop chain that point is the anchor corner — the text immediately lies
2092
+ // across the chain's vertical run. The label may sit at ANY point of its own
2093
+ // net's wires, so walk the run's points and take the first whose text box
2094
+ // clears everything the checker will measure; wires the point itself lies on
2095
+ // are the label's own attachment and never count as collisions.
2096
+ for (const rec of wiredLabels) {
2097
+ const lb = labels[rec.label]!;
2098
+ const clearWired = (x: number, y: number): boolean => {
2099
+ const box = labelTextBox(lb.name, x, y, 0);
2100
+ if (bodies.some((b) => boundsOverlap(box, b))) return false;
2101
+ if (textObstacles.some((b) => boundsOverlap(box, b))) return false;
2102
+ if (wires.some((w) => !segContains(w, x, y) && segHitsBox(w, box))) return false;
2103
+ return !labels.some(
2104
+ (o, i) => i !== rec.label && o.name !== lb.name && boundsOverlap(box, labelTextBox(o.name, o.x, o.y, o.rot)),
2105
+ );
2106
+ };
2107
+ if (clearWired(lb.x, lb.y)) continue;
2108
+ const alt = rec.pts.find((p) => clearWired(p.x, p.y));
2109
+ if (alt) {
2110
+ lb.x = alt.x;
2111
+ lb.y = alt.y;
2112
+ continue;
2113
+ }
2114
+ // No segment endpoint clears, but the label may sit at ANY point of its
2115
+ // own net's wires: walk the interior grid points of each segment too
2116
+ // (#220 phase 2). Endpoints stay the first choice so a run that used to
2117
+ // clear keeps its exact label point.
2118
+ //
2119
+ // Walk `segs`, never `pts`: `pts` is sorted for anchor preference, so
2120
+ // pairing it up interpolates between two points that share no wire and
2121
+ // anchors the label in open sheet, where KiCad attaches nothing and the
2122
+ // net silently carries whatever name KiCad invents for it instead of the
2123
+ // one the IR gave it. Three corpus nets shipped exactly that way —
2124
+ // Net-(F201-Pad1), Net-(U8-BIN) and Net-(U8-RIN), each anchored on no
2125
+ // wire of its own run.
2126
+ const inner = interiorGridPoints(rec.segs, U).find((p) => clearWired(p.x, p.y));
2127
+ if (inner) {
2128
+ lb.x = inner.x;
2129
+ lb.y = inner.y;
2130
+ }
2131
+ }
2132
+
2133
+ for (const rec of stubbedLabels) {
2134
+ const lb = labels[rec.label]!;
2135
+ const stub = wires[rec.wire]!;
2136
+ const clearAt = (x: number, y: number, rot: number = lb.rot): boolean => {
2137
+ const box = labelTextBox(lb.name, x, y, rot);
2138
+ if (bodies.some((b) => boundsOverlap(box, b))) return false;
2139
+ if (textObstacles.some((b) => boundsOverlap(box, b))) return false;
2140
+ if (wires.some((w, i) => i !== rec.wire && segHitsBox(w, box))) return false;
2141
+ // Foreign labels are part of what a label must clear, not just bodies and
2142
+ // wires. Without this the pass declares a point clear that another net's
2143
+ // label already holds, both labels stay put, and `findMergedNets` then
2144
+ // refuses the draft for a collision the avoider was never looking for —
2145
+ // an engine state no IR can steer out of, because the IR does not choose
2146
+ // coordinates. Read live from `labels`, so already-nudged neighbours are
2147
+ // seen at their final positions and immovable wired-net labels (which
2148
+ // carry no stub to ride) are seen at all.
2149
+ return !labels.some(
2150
+ (o, i) =>
2151
+ i !== rec.label &&
2152
+ o.name !== lb.name &&
2153
+ boundsOverlap(box, labelTextBox(o.name, o.x, o.y, o.rot)),
2154
+ );
2155
+ };
2156
+ /**
2157
+ * Does another net's label sit on exactly this point? That is the fatal
2158
+ * case — KiCad fuses the two nets — as opposed to merely overlapping text.
2159
+ */
2160
+ const mergesAt = (x: number, y: number): boolean =>
2161
+ labels.some((o, i) => i !== rec.label && o.name !== lb.name && sameCoord(o.x, x) && sameCoord(o.y, y));
2162
+ /**
2163
+ * Riding a label outward drags the stub's ENDPOINT with it (`rideTo` moves
2164
+ * both). Every test above this asks a typographic question — does the text
2165
+ * box clear a body, a wire, another label — and none asks the electrical
2166
+ * one, so the pass could answer "the text is clear here" about a point that
2167
+ * sits on another net's wire and silently tie the two together (#217:
2168
+ * interf_u rode /PC-RD's stub from 2 units to 4 and parked its end on
2169
+ * /WR_REG's trunk).
2170
+ *
2171
+ * A candidate must therefore be electrically clear as well as legible.
2172
+ * The stub's own wire needs no exclusion: `touchesForeign` skips wires of
2173
+ * the same net, and this one is the net's own.
2174
+ */
2175
+ const wireClearAt = (x: number, y: number): boolean =>
2176
+ !touchesForeign([{ x1: stub.x1, y1: stub.y1, x2: x, y2: y }], lb.name, new Set(rec.pins), {
2177
+ predictStubs: false,
2178
+ });
2179
+ const rideTo = (x: number, y: number): void => {
2180
+ stub.x2 = x;
2181
+ stub.y2 = y;
2182
+ lb.x = x;
2183
+ lb.y = y;
2184
+ };
2185
+ if (clearAt(lb.x, lb.y)) continue;
2186
+ /** Candidate points along the stub, nearest first. */
2187
+ const candidates: { x: number; y: number }[] = [];
2188
+ for (let extra = 1; extra <= MAX_LABEL_NUDGE; extra++) {
2189
+ const x = lb.x + rec.o.dx * extra * U;
2190
+ const y = lb.y + rec.o.dy * extra * U;
2191
+ // an extension that would run the stub through a symbol is no better
2192
+ // than the collision it fixes
2193
+ if (bodies.some((b) => segCrossesBody(stub.x1, stub.y1, x, y, b))) break;
2194
+ candidates.push({ x, y });
2195
+ }
2196
+ // Last rung: pull the stub BACK to one unit. Riding outward moves a
2197
+ // facing pair's text toward each other, so two long names in a tight
2198
+ // channel can never separate that way — but each is under a grid unit
2199
+ // deep into the other, and one unit of retreat clears it (#220 phase 2).
2200
+ {
2201
+ const shortened = { x: stub.x1 + rec.o.dx * U, y: stub.y1 + rec.o.dy * U };
2202
+ if (Math.abs(lb.x - stub.x1) + Math.abs(lb.y - stub.y1) > U + 0.01) candidates.push(shortened);
2203
+ }
2204
+ const clear = candidates.find((c) => clearAt(c.x, c.y) && wireClearAt(c.x, c.y));
2205
+ if (clear) {
2206
+ rideTo(clear.x, clear.y);
2207
+ continue;
2208
+ }
2209
+ // A vertical stub may flip its text to the other side of the anchor: a
2210
+ // trunk running parallel beside the stub blocks every rung on one side
2211
+ // while the other side is empty (#220 phase 2). The anchor point itself is
2212
+ // unchanged, so this is purely typographic — but the flipped box no longer
2213
+ // overlaps a same-point foreign label, so the merge check must be explicit.
2214
+ if (rec.o.dy !== 0) {
2215
+ const flipRot = lb.rot === 0 ? 180 : 0;
2216
+ const flip = [{ x: lb.x, y: lb.y }, ...candidates].find(
2217
+ (c) =>
2218
+ clearAt(c.x, c.y, flipRot) &&
2219
+ !mergesAt(c.x, c.y) &&
2220
+ (sameCoord(c.x, lb.x) && sameCoord(c.y, lb.y) ? true : wireClearAt(c.x, c.y)),
2221
+ );
2222
+ if (flip) {
2223
+ lb.rot = flipRot;
2224
+ rideTo(flip.x, flip.y);
2225
+ continue;
2226
+ }
2227
+ }
2228
+ // Nothing fully clear within the nudge budget. Overlapping text is a
2229
+ // legibility cost the sheet can carry and the report will name; a shared
2230
+ // point is a merged net and refuses the whole draft. So when the label is
2231
+ // currently ON another net's point, take the nearest candidate that at
2232
+ // least breaks the coincidence — trading a refusal for a flagged blemish.
2233
+ // A label that merely overlaps is left alone: moving it would buy nothing
2234
+ // and the emitted sheet must stay a function of the IR alone.
2235
+ if (!mergesAt(lb.x, lb.y)) continue;
2236
+ // Same precedence as above, one rung down: this is already the consolation
2237
+ // move for a label sitting on another net's point, so it may accept
2238
+ // overlapping text, but it still may not trade one merge for another.
2239
+ const unmerged = candidates.find((c) => !mergesAt(c.x, c.y) && wireClearAt(c.x, c.y));
2240
+ if (unmerged) rideTo(unmerged.x, unmerged.y);
2241
+ }
2242
+
2243
+ // ---------- power-value sweep (#220 phase 2) ----------
2244
+ // The power pass placed its value text before any signal label existed, and
2245
+ // the label ride above can fail to clear in a dense row — whichever mover
2246
+ // ran last was blind to the other, and cm5_minima's residual error findings
2247
+ // were exactly "#PWR Value and label X overlap". The value text is the one
2248
+ // item on the sheet with no electrical meaning, so it moves LAST, with the
2249
+ // finished drawing as its obstacle set: slide it outward along its stub
2250
+ // axis, then allow a small lateral step, to the first slot the checker will
2251
+ // measure as clean. Nothing clear keeps the placed slot so the report stays
2252
+ // honest, and a value that is already clean does not move at all.
2253
+ {
2254
+ const labelBoxesFinal = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot));
2255
+ const memberText = emitPairs.flatMap(({ sym: s, pl }) => [
2256
+ centeredTextBox(displayRefOf(pl), s.refAt.x, s.refAt.y),
2257
+ centeredTextBox(s.value, s.valueAt.x, s.valueAt.y),
2258
+ ]);
2259
+ const valueEntries = extraSymbols.filter((s) => !s.hideValue);
2260
+ const liveBoxes = new Map(valueEntries.map((s) => [s, powerValueBox(s.value, s.valueAt.x, s.valueAt.y)]));
2261
+ const clearFor = (self: EmitSymbol, b: Bounds): boolean =>
2262
+ !bodies.some((bd) => boundsOverlap(b, bd)) &&
2263
+ !wires.some((w) => segHitsBoxEarly(w, b)) &&
2264
+ !labelBoxesFinal.some((lb) => boundsOverlap(lb, b)) &&
2265
+ !memberText.some((t) => boundsOverlap(t, b)) &&
2266
+ ![...liveBoxes].some(([o, ob]) => o !== self && boundsOverlap(ob, b));
2267
+ for (const s of valueEntries) {
2268
+ if (clearFor(s, liveBoxes.get(s)!)) continue;
2269
+ // outward = the side of the symbol the text was already offset to
2270
+ const dir = Math.sign(s.valueAt.y - s.at.y) || -1;
2271
+ const cands: { x: number; y: number }[] = [];
2272
+ for (let k = 1; k <= 8; k++) cands.push({ x: s.valueAt.x, y: s.valueAt.y + dir * k * U });
2273
+ for (let k = 0; k <= 8; k++) {
2274
+ for (const lx of [U, -U, 2 * U, -2 * U, 3 * U, -3 * U, 4 * U, -4 * U]) {
2275
+ cands.push({ x: s.valueAt.x + lx, y: s.valueAt.y + dir * k * U });
2276
+ }
2277
+ }
2278
+ const found = cands.find((c) => clearFor(s, powerValueBox(s.value, c.x, c.y)));
2279
+ if (found) {
2280
+ s.valueAt = { x: found.x, y: found.y };
2281
+ liveBoxes.set(s, powerValueBox(s.value, found.x, found.y));
2282
+ }
2283
+ }
2284
+ }
2285
+
2286
+ // junctions: any point where three or more wire ends meet
2287
+ const endCount = new Map<string, { x: number; y: number; n: number }>();
2288
+ for (const w of wires) {
2289
+ for (const [x, y] of [[w.x1, w.y1], [w.x2, w.y2]] as const) {
2290
+ const k = pointKey(x, y);
2291
+ const e = endCount.get(k) ?? { x, y, n: 0 };
2292
+ e.n++;
2293
+ endCount.set(k, e);
2294
+ }
2295
+ }
2296
+ for (const e of endCount.values()) if (e.n >= 3) junctions.push({ x: e.x, y: e.y });
2297
+ const uniqJunctions = [...new Map(junctions.map((j) => [pointKey(j.x, j.y), j])).values()];
2298
+
2299
+ // no-connect markers (design D6a); a common pin's marker lands on every
2300
+ // placed appearance, mirroring how the wiring passes treat such pins
2301
+ const noConnects: { x: number; y: number }[] = [];
2302
+ for (const ep of intent.noConnect ?? []) {
2303
+ const m = /^([^.]+)\.(.+)$/.exec(ep);
2304
+ if (!m) continue;
2305
+ for (const inst of expandEp(m[1]!, m[2]!)) {
2306
+ const pl = placed.get(inst.key);
2307
+ const pin = pl?.sym.pins.find((p) => p.number === m[2]);
2308
+ if (pl && pin) noConnects.push(pinAt(pl, pin));
2309
+ }
2310
+ }
2311
+
2312
+ // ---------- sheet: content-derived paper, balanced placement ----------
2313
+ const allX = [...groupRects.map((r) => r.x1), ...groupRects.map((r) => r.x2)];
2314
+ const allY = [...groupRects.map((r) => r.y1), ...groupRects.map((r) => r.y2)];
2315
+ const contentW = allX.length ? Math.max(...allX) - Math.min(...allX) : 0;
2316
+ const contentH = allY.length ? Math.max(...allY) - Math.min(...allY) : 0;
2317
+ // The sheet was already decided by the wrap-and-band pass above: `fit` names
2318
+ // the smallest candidate the final group rects fit (or the largest, noted,
2319
+ // when nothing holds them). Re-deriving it from content here could only
2320
+ // disagree with the budget the columns were banded to.
2321
+ const paper = fit.paper;
2322
+
2323
+ // offset so content sits centered in the usable area (whitespace balance,
2324
+ // design D11), snapped to the grid so origins stay grid-true
2325
+ const minX = allX.length ? Math.min(...allX) : 0;
2326
+ const minY = allY.length ? Math.min(...allY) : 0;
2327
+ const availW = paper.w - 2 * FRAME;
2328
+ const availH = paper.h - 2 * FRAME - TITLE_STRIP;
2329
+ let dx = grid(Math.round((FRAME + Math.max(0, (availW - contentW) / 2) - minX) / U));
2330
+ let dy = grid(Math.round((FRAME + 4 * U + Math.max(0, (availH - contentH) / 2) - minY) / U));
2331
+
2332
+ // The group rects measure bodies plus margins; label TEXT extends past them
2333
+ // at the sheet-facing edges, and on a sheet banded near the full usable
2334
+ // width the centered offset leaves that text outside the frame (#220
2335
+ // phase 1). Clamp the shift against the true extent, label boxes included:
2336
+ // a whole-unit correction keeps the grid, fires only when text would cross
2337
+ // the frame, and an extent wider than the window keeps the centered offset
2338
+ // (that overflow was already noted by the fit pass).
2339
+ const textBoxes = [
2340
+ ...labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot)),
2341
+ // power VALUE text is measured by the checker too, and the sweep above
2342
+ // may have slid it past its group rect's margin (pic_programmer put a
2343
+ // rail name 1 mm over the top edge of a compacted sheet)
2344
+ ...extraSymbols.filter((s) => !s.hideValue).map((s) => powerValueBox(s.value, s.valueAt.x, s.valueAt.y)),
2345
+ ];
2346
+ const fullMinX = Math.min(minX, ...textBoxes.map((b) => b.minX));
2347
+ const fullMaxX = Math.max(minX + contentW, ...textBoxes.map((b) => b.maxX));
2348
+ const fullMinY = Math.min(minY, ...textBoxes.map((b) => b.minY));
2349
+ const fullMaxY = Math.max(minY + contentH, ...textBoxes.map((b) => b.maxY));
2350
+ // Both edges are corrected, far edge first, so the whole-unit rounding of
2351
+ // the far-edge shift can never leave the near edge (the checker-visible
2352
+ // frame line) outside: the near-edge correction runs last and wins. When
2353
+ // the span nearly fills the window, the far edge may keep up to one unit
2354
+ // of overhang into the engine's conservative title strip; the strip is
2355
+ // wider than the checker's reserved corner, so that overhang is invisible.
2356
+ const clampShift = (d: number, lo0: number, hi0: number, lo: number, hi: number): number => {
2357
+ if (hi0 - lo0 > hi - lo) return d;
2358
+ if (hi0 + d > hi) d -= Math.ceil((hi0 + d - hi) / U - 1e-9) * U;
2359
+ if (lo0 + d < lo) d += Math.ceil((lo - lo0 - d) / U - 1e-9) * U;
2360
+ return d;
2361
+ };
2362
+ dx = clampShift(dx, fullMinX, fullMaxX, FRAME, paper.w - FRAME);
2363
+ // the bottom edge is the engine's own usable bottom, ABOVE the title strip:
2364
+ // content that fills the sheet's height exactly would otherwise carry the
2365
+ // centering pass's 4-unit downward offset into the reserved corner
2366
+ dy = clampShift(dy, fullMinY, fullMaxY, FRAME, paper.h - FRAME - TITLE_STRIP);
2367
+ const shift = <T extends { x?: number; y?: number; x1?: number; y1?: number; x2?: number; y2?: number }>(o: T): T => {
2368
+ if (o.x !== undefined) o.x += dx;
2369
+ if (o.y !== undefined) o.y += dy;
2370
+ if (o.x1 !== undefined) o.x1 += dx;
2371
+ if (o.y1 !== undefined) o.y1 += dy;
2372
+ if (o.x2 !== undefined) o.x2 += dx;
2373
+ if (o.y2 !== undefined) o.y2 += dy;
2374
+ return o;
2375
+ };
2376
+ for (const s of [...emitSymbols, ...extraSymbols]) {
2377
+ s.at.x += dx;
2378
+ s.at.y += dy;
2379
+ shift(s.refAt);
2380
+ shift(s.valueAt);
2381
+ }
2382
+ wires.forEach(shift);
2383
+ labels.forEach(shift);
2384
+ uniqJunctions.forEach(shift);
2385
+ noConnects.forEach(shift);
2386
+ groupRects.forEach(shift);
2387
+
2388
+ // Two labels of DIFFERENT nets at one point is a merged net, not a cosmetic
2389
+ // overlap: KiCad resolves co-located labels to a single net and reports
2390
+ // `Both A and B are attached to the same items; A will be used in the
2391
+ // netlist` — as a warning. A live run drew ISET (charge-current program) and
2392
+ // NTC (thermistor input) onto the same node of a BQ24040 that way, which
2393
+ // would have shipped a board whose charge current is not set by its
2394
+ // programming resistor and whose temperature cutoff does not work.
2395
+ //
2396
+ // The engine computes every coordinate, so this is ours to catch, and it is
2397
+ // strictly worse than the failures we do gate: an unreadable sheet stops the
2398
+ // pipeline loudly, while a merged net passes ERC-as-warning and flows into
2399
+ // layout and fabrication outputs. Reported as a hard finding — the netlist
2400
+ // the IR declared is not the netlist that got drawn.
2401
+ const mergedNets = [
2402
+ ...findMergedNets(labels).map((m) => ({ ...m, via: 'labels' as const })),
2403
+ ...findWireContactMerges(wires, labels).map((m) => ({ ...m, via: 'wires' as const })),
2404
+ ];
2405
+
2406
+ // Overlapping label TEXT is the other half of the same pass and deliberately
2407
+ // not a gate. The de-collision loop clears what it can and, where it cannot,
2408
+ // prefers a legible-but-overlapping position over a merged net. What survives
2409
+ // is counted against a budget and named in the report, so a sheet never ships
2410
+ // a blemish silently and never stalls a run over one either.
2411
+ const labelOverlaps = findLabelOverlaps(labels);
2412
+ const labelOverlapBudgetExceeded =
2413
+ labels.length > 0 && labelOverlaps.length / labels.length > LABEL_OVERLAP_BUDGET;
2414
+ if (labelOverlaps.length) {
2415
+ const pct = ((labelOverlaps.length / Math.max(1, labels.length)) * 100).toFixed(1);
2416
+ const where = labelOverlaps
2417
+ .slice(0, 8)
2418
+ .map((o) => `${o.nets.join('/')} at (${o.x}, ${o.y})`)
2419
+ .join('; ');
2420
+ notes.push(
2421
+ `${labelOverlapBudgetExceeded ? 'LABEL OVERLAP BUDGET EXCEEDED: ' : ''}` +
2422
+ `${labelOverlaps.length} of ${labels.length} label(s) (${pct}%, budget ` +
2423
+ `${(LABEL_OVERLAP_BUDGET * 100).toFixed(1)}%) overlap a foreign net's label text. ` +
2424
+ `The netlist is unaffected — these are legibility defects, listed so they can be ` +
2425
+ `fixed or accepted deliberately: ${where}` +
2426
+ `${labelOverlaps.length > 8 ? `; and ${labelOverlaps.length - 8} more` : ''}`,
2427
+ );
2428
+ }
2429
+
2430
+ const model: PlacementModel = {
2431
+ projectName,
2432
+ paper: paper.name,
2433
+ title: { title: projectName, date: today, rev: 'A' },
2434
+ libSymbols: [...libSymbols.entries()].map(([libId, sourceText]) => ({ libId, sourceText })),
2435
+ symbols: [...emitSymbols, ...extraSymbols],
2436
+ wires,
2437
+ junctions: uniqJunctions,
2438
+ labels,
2439
+ noConnects,
2440
+ rectangles: groupRects.map((r) => ({ x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2, stroke: 'solid' as const, name: r.name })),
2441
+ captions: groupRects.map((r) => ({ text: r.name, x: r.x1 + 2, y: r.y1 + 2, name: r.name })),
2442
+ };
2443
+
2444
+ const report: SchematicDraftReport = {
2445
+ groups: groupNames.map((g) => ({
2446
+ name: g,
2447
+ // instance keys fold back to refdes (a dual opamp is one member, not two)
2448
+ members: [
2449
+ ...new Set([...groupOf.entries()].filter(([, gg]) => gg === g).map(([k]) => placed.get(k)?.refDes ?? k)),
2450
+ ].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })),
2451
+ })),
2452
+ netClasses: [...netClasses.entries()]
2453
+ .sort((a, b) => a[0].localeCompare(b[0]))
2454
+ .map(([name, c]) => ({ name, class: c.cls, overridden: c.overridden, basis: c.basis })),
2455
+ wireCount: wires.length,
2456
+ labelCount: labels.length,
2457
+ pwrFlags,
2458
+ noConnects: noConnects.length,
2459
+ paper: paper.name,
2460
+ notes,
2461
+ mergedNets,
2462
+ labelOverlaps,
2463
+ labelOverlapBudgetExceeded,
2464
+ };
2465
+ return { model, report };
2466
+ }