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