copperhead 0.10.0 → 0.11.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 (99) hide show
  1. package/README.md +41 -2
  2. package/dist/agent/context.js +2 -0
  3. package/dist/agent/context.js.map +1 -0
  4. package/dist/agent/dock-renderer.js +2 -2
  5. package/dist/agent/dock-renderer.js.map +1 -1
  6. package/dist/agent/envelope.js +105 -0
  7. package/dist/agent/envelope.js.map +1 -0
  8. package/dist/agent/loop.js +34 -14
  9. package/dist/agent/loop.js.map +1 -1
  10. package/dist/agent/providers/claude-code.js +17 -1
  11. package/dist/agent/providers/claude-code.js.map +1 -1
  12. package/dist/agent/providers/codex.js +84 -39
  13. package/dist/agent/providers/codex.js.map +1 -1
  14. package/dist/agent/recovery.js +91 -14
  15. package/dist/agent/recovery.js.map +1 -1
  16. package/dist/agent/registry.js +49 -0
  17. package/dist/agent/registry.js.map +1 -0
  18. package/dist/agent/render.js +2 -2
  19. package/dist/agent/render.js.map +1 -1
  20. package/dist/agent/theme.js +10 -5
  21. package/dist/agent/theme.js.map +1 -1
  22. package/dist/agent/tools.js +99 -769
  23. package/dist/agent/tools.js.map +1 -1
  24. package/dist/capabilities/define.js +35 -0
  25. package/dist/capabilities/define.js.map +1 -0
  26. package/dist/capabilities/handlers.js +744 -0
  27. package/dist/capabilities/handlers.js.map +1 -0
  28. package/dist/capabilities/helpers.js +39 -0
  29. package/dist/capabilities/helpers.js.map +1 -0
  30. package/dist/capabilities/index.js +50 -0
  31. package/dist/capabilities/index.js.map +1 -0
  32. package/dist/capabilities/skills/generate-report.js +23 -0
  33. package/dist/capabilities/skills/generate-report.js.map +1 -0
  34. package/dist/cli.js +84 -1
  35. package/dist/cli.js.map +1 -1
  36. package/dist/commands/create.js +5 -2
  37. package/dist/commands/create.js.map +1 -1
  38. package/dist/commands/doctor.js +33 -3
  39. package/dist/commands/doctor.js.map +1 -1
  40. package/dist/commands/skill.js +109 -0
  41. package/dist/commands/skill.js.map +1 -0
  42. package/dist/commands/sync.js +3 -1
  43. package/dist/commands/sync.js.map +1 -1
  44. package/dist/config.js +18 -6
  45. package/dist/config.js.map +1 -1
  46. package/dist/kicad/cli.js +106 -18
  47. package/dist/kicad/cli.js.map +1 -1
  48. package/dist/kicad/draft/draft.js +3 -0
  49. package/dist/kicad/draft/draft.js.map +1 -1
  50. package/dist/kicad/draft/engine.js +3139 -218
  51. package/dist/kicad/draft/engine.js.map +1 -1
  52. package/dist/kicad/draft/symsource.js +24 -10
  53. package/dist/kicad/draft/symsource.js.map +1 -1
  54. package/dist/kicad/emit.js +45 -6
  55. package/dist/kicad/emit.js.map +1 -1
  56. package/dist/kicad/legibility.js +51 -4
  57. package/dist/kicad/legibility.js.map +1 -1
  58. package/dist/kicad/score.js +173 -3
  59. package/dist/kicad/score.js.map +1 -1
  60. package/dist/kicad/sexp.js +32 -6
  61. package/dist/kicad/sexp.js.map +1 -1
  62. package/dist/mcp/server.js +485 -0
  63. package/dist/mcp/server.js.map +1 -0
  64. package/dist/memory/scaffold.js +8 -1
  65. package/dist/memory/scaffold.js.map +1 -1
  66. package/package.json +5 -2
  67. package/src/agent/context.ts +35 -0
  68. package/src/agent/dock-renderer.ts +3 -2
  69. package/src/agent/envelope.ts +124 -0
  70. package/src/agent/loop.ts +45 -17
  71. package/src/agent/providers/claude-code.ts +22 -1
  72. package/src/agent/providers/codex.ts +91 -42
  73. package/src/agent/recovery.ts +89 -12
  74. package/src/agent/registry.ts +58 -0
  75. package/src/agent/render.ts +4 -3
  76. package/src/agent/theme.ts +15 -5
  77. package/src/agent/tools.ts +124 -816
  78. package/src/agent/types.ts +10 -5
  79. package/src/capabilities/define.ts +88 -0
  80. package/src/capabilities/handlers.ts +769 -0
  81. package/src/capabilities/helpers.ts +37 -0
  82. package/src/capabilities/index.ts +53 -0
  83. package/src/capabilities/skills/generate-report.ts +25 -0
  84. package/src/cli.ts +84 -1
  85. package/src/commands/create.ts +5 -2
  86. package/src/commands/doctor.ts +34 -3
  87. package/src/commands/skill.ts +127 -0
  88. package/src/commands/sync.ts +5 -3
  89. package/src/config.ts +32 -8
  90. package/src/kicad/cli.ts +129 -18
  91. package/src/kicad/draft/draft.ts +2 -0
  92. package/src/kicad/draft/engine.ts +3034 -226
  93. package/src/kicad/draft/symsource.ts +24 -10
  94. package/src/kicad/emit.ts +71 -7
  95. package/src/kicad/legibility.ts +55 -6
  96. package/src/kicad/score.ts +187 -8
  97. package/src/kicad/sexp.ts +37 -6
  98. package/src/mcp/server.ts +560 -0
  99. package/src/memory/scaffold.ts +8 -1
@@ -1,4 +1,4 @@
1
- import { knum } from '../emit.js';
1
+ import { knum, CAPTION_SIZE } from '../emit.js';
2
2
  import { powerSymbolSource, pwrFlagSource } from './symsource.js';
3
3
  /**
4
4
  * The rule-based deterministic drafting engine (design D1/D2). All geometry is
@@ -10,20 +10,137 @@ import { powerSymbolSource, pwrFlagSource } from './symsource.js';
10
10
  const U = 1.27;
11
11
  /** Stub length from a pin to its label/power symbol, in grid units. */
12
12
  const STUB = 2;
13
- /** Cell margin around a symbol body (room for stubs, labels, text), in units. */
14
- const MARGIN = 6;
13
+ /** Cell margin around a symbol body (room for stubs, labels, text), in units,
14
+ * for the first draft; a measured round shrinks it to `MEASURED_MARGIN`. */
15
+ const BASE_MARGIN = 4;
16
+ /** Cell margin once the cell is sized from what it actually drew. */
17
+ const MEASURED_MARGIN = 2;
18
+ /** Vertical cell margin, units: rows are tighter than columns because a
19
+ * part's texts sit beside it, not above and below; a pin that faces up or
20
+ * down with a rail or ground on it reserves `POWER_REACH` instead. */
21
+ const VMARGIN = 2;
22
+ const POWER_REACH = 6;
23
+ /** Clear grid units kept beyond a cell's measured drawn extent. */
24
+ const MEASURE_PAD = 1;
25
+ /** Padding, grid units, a group box keeps around the label and field text it
26
+ * encloses, and the clearance kept between two such boxes. */
27
+ const BOX_PAD = 1;
28
+ /** Grid units between two drawn group boxes, after the boxes have grown to
29
+ * their text: the one distance a reader sees between blocks. Equal to
30
+ * GROUP_GAP, the gap the wrap fits between rects, so a fit on measured
31
+ * boxes is exact. */
32
+ const BOX_LINE_GAP = 4;
33
+ const BOX_GAP = 4;
15
34
  /** Vertical gap between rows and horizontal channel between columns, units. */
16
- const ROW_GAP = 4;
17
- const CHANNEL = 8;
35
+ const ROW_GAP = 3;
36
+ const CHANNEL = 4;
18
37
  /** Gap between group boxes, units. */
19
- const GROUP_GAP = 8;
20
- /** Local nets up to this many endpoints may be wired (design D2). */
38
+ const GROUP_GAP = 4;
39
+ /** Local nets up to this many endpoints may be wired (design D2); a larger
40
+ * cluster is narrowed to the endpoints bound to one anchor before routing. */
21
41
  const MAX_WIRED_ENDPOINTS = 4;
42
+ /** Subsets of one cluster the wire pass tries to route before it gives up. */
43
+ const ROUTE_ATTEMPTS = 64;
44
+ /** Nodes the masonry wrap's deal search visits before it keeps the best deal found so far. */
45
+ const MASONRY_NODE_BUDGET = 200_000;
22
46
  /** Wire-span budget in mm beyond which a net becomes labels. */
23
47
  const MAX_WIRE_SPAN = 50.8;
24
48
  /** Label text metrics, matching the legibility checker's conservative box. */
25
49
  const LABEL_HEIGHT = 1.27;
26
- const LABEL_ADVANCE = 0.6;
50
+ /**
51
+ * Advance the ENGINE gives text when it spaces groups, clears labels and
52
+ * fields of one another, and draws the box around a group. The checker's 0.6
53
+ * is tuned below KiCad's stroke font on purpose (design C3: never a false
54
+ * collision); measured on a plotted sheet the font advances about 0.72 of
55
+ * its height, so an engine that cleared text at 0.6 drew sheets where a rail
56
+ * name ran into the label on the next pin row while the checker called them
57
+ * clean. The engine is the side that must be conservative: it reserves 0.8.
58
+ */
59
+ const LABEL_ADVANCE = 0.8;
60
+ const TEXT_RESERVE = LABEL_ADVANCE;
61
+ /** Advance of KiCad's stroke font for the upper-case pin names inside a
62
+ * body, per height: wider than the mixed-case reserve above. */
63
+ const NAME_ADVANCE = 1.0;
64
+ /** Air between any two texts, mm. Boxes that merely do not overlap still
65
+ * read as one smudge on paper (esp32-amp's "100k" sat 0.6 mm above
66
+ * "USB_OV"); every clearance check pads the candidate by this. */
67
+ const TEXT_PAD = 0.8;
68
+ const padBox = (b, p = TEXT_PAD) => ({ minX: b.minX - p, minY: b.minY - p, maxX: b.maxX + p, maxY: b.maxY + p });
69
+ /** `COPPERHEAD_DRAFT_TRACE=1` prints every placement decision the engine
70
+ * makes silently: what it claimed, what it skipped and why, what it refused. */
71
+ const trace = (msg) => {
72
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1')
73
+ console.error(`[draft] ${msg}`);
74
+ };
75
+ /** `labelTextBox` at the reserve advance: what the text takes on paper. */
76
+ const labelReserveBox = (name, x, y, rot, kind = 'local') => labelBoxAt(name, x, y, rot, kind, TEXT_RESERVE);
77
+ /**
78
+ * Along-axis length a global label's flag adds beyond its text: the margin
79
+ * either side of the text plus the pointed tip. Measured from eeschema's own
80
+ * outline at 1.27 mm (a 12-character name draws 17.0 mm long against about
81
+ * 14.5 of text), and kept a little generous because the advance estimate
82
+ * under-reads wide glyphs.
83
+ */
84
+ const FLAG_PAD = 2.5 * LABEL_HEIGHT;
85
+ /** Grid units of the caption band at the top of a group box: the bold
86
+ * caption at `CAPTION_SIZE` plus clearance over the tallest thing a cell can
87
+ * carry above its body (a rail symbol on an upward stub with its value text). */
88
+ const CAPTION_BAND = 7;
89
+ /**
90
+ * The box a label occupies at `advance` per character. A local label is bare
91
+ * text standing on its anchor (see `labelTextBox`); a global label is a flag
92
+ * two text heights tall, centred on the anchor line, whose length runs away
93
+ * from the anchor in the rotation's direction — 0 right, 90 up, 180 left,
94
+ * 270 down in schematic Y-down coordinates, exactly as eeschema draws it.
95
+ */
96
+ const labelBoxAt = (name, x, y, rot, kind, advance) => {
97
+ const textW = Math.max(1, name.length) * advance * LABEL_HEIGHT;
98
+ if (kind === 'global') {
99
+ const w = textW + FLAG_PAD;
100
+ const h = LABEL_HEIGHT;
101
+ const r = ((rot % 360) + 360) % 360;
102
+ if (r === 90)
103
+ return { minX: x - h, minY: y - w, maxX: x + h, maxY: y };
104
+ if (r === 270)
105
+ return { minX: x - h, minY: y, maxX: x + h, maxY: y + w };
106
+ if (r === 180)
107
+ return { minX: x - w, minY: y - h, maxX: x, maxY: y + h };
108
+ return { minX: x, minY: y - h, maxX: x + w, maxY: y + h };
109
+ }
110
+ // A plain label STANDS on its anchor line: the checker measures it that way
111
+ // (`labelBounds`), and so does the engine. The union box that also reached
112
+ // half a height below the line dated from the checker's centred days; kept,
113
+ // it made every name on a row collide with that row's own continuing wire
114
+ // (esp32-amp's UART_TXD could stand nowhere on its 9 mm run).
115
+ return rot === 180
116
+ ? { minX: x - textW, minY: y - LABEL_HEIGHT, maxX: x, maxY: y }
117
+ : { minX: x, minY: y - LABEL_HEIGHT, maxX: x + textW, maxY: y };
118
+ };
119
+ /** KiCad's label rotation for text running outward along a stub direction. */
120
+ const rotOutward = (o) => (o.dx === -1 ? 180 : o.dy === -1 ? 90 : o.dy === 1 ? 270 : 0);
121
+ /**
122
+ * Flag shape for a global label at a pin, from the pin's electrical type:
123
+ * an output pin's net leaves through an output flag, an input's arrives
124
+ * through an input flag, a bidirectional or tri-state pin's points both
125
+ * ways, and everything else (passive parts, power, unspecified) gets the
126
+ * plain box. Cosmetic to KiCad's ERC, informative to a reader.
127
+ */
128
+ export function flagShape(etype) {
129
+ switch (etype) {
130
+ case 'output':
131
+ case 'open_collector':
132
+ case 'open_emitter':
133
+ case 'power_out':
134
+ return 'output';
135
+ case 'input':
136
+ return 'input';
137
+ case 'bidirectional':
138
+ case 'tri_state':
139
+ return 'bidirectional';
140
+ default:
141
+ return 'passive';
142
+ }
143
+ }
27
144
  /** How far a colliding label may ride its stub outward, in grid units.
28
145
  * Deep enough to carry a bottom-pin label past the routing channel that runs
29
146
  * under its connector (#220 phase 2); rungs stay ordered nearest-first, so a
@@ -71,6 +188,8 @@ const PAPERS = [
71
188
  ];
72
189
  const FRAME = 10;
73
190
  const TITLE_STRIP = 30;
191
+ /** The title block's width along the bottom edge, as the checker measures it. */
192
+ const TITLE_BLOCK_W = 110;
74
193
  /** Max pin-to-pin gap, grid units, for chaining a passive bank on one trunk
75
194
  * (#233): wide enough for two-pin parts sitting in adjacent COLUMNS (cell
76
195
  * width plus the channel, ~23 units), tight enough that a trunk never spans
@@ -248,6 +367,37 @@ export function splitBankRuns(line, canStub, canJoin) {
248
367
  flush();
249
368
  return runs;
250
369
  }
370
+ /** `sym` mirrored left-for-right (KiCad's `(mirror y)`), then turned by `rot`. */
371
+ const transformSym = (sym, rot, mirror) => {
372
+ if (!mirror)
373
+ return rotatedSym(sym, rot);
374
+ const pins = sym.pins.map((p) => ({ ...p, x: -p.x, angle: (((180 - p.angle) % 360) + 360) % 360 }));
375
+ const b = sym.body ? bodyBoundsOf(sym) : null;
376
+ const body = b ? { minX: -b.maxX, maxX: -b.minX, minY: b.minY, maxY: b.maxY } : null;
377
+ return rotatedSym({ ...sym, pins, body }, rot);
378
+ };
379
+ /**
380
+ * The symbol as KiCad draws it at rotation `rot` (0, 90, 180, 270): pins,
381
+ * their direction angles and the body box all turned in symbol space, so
382
+ * every downstream helper (pinAt, outward, bodyBoundsOf) reads the drawn
383
+ * geometry without knowing the part was turned. Matches `pinAbsolute`:
384
+ * rx = x cos − y sin, ry = x sin + y cos.
385
+ */
386
+ const rotatedSym = (sym, rot) => {
387
+ const r = ((rot % 360) + 360) % 360;
388
+ if (r === 0)
389
+ return sym;
390
+ const turn = (x, y) => r === 90 ? { x: -y, y: x } : r === 180 ? { x: -x, y: -y } : { x: y, y: -x };
391
+ const pins = sym.pins.map((p) => ({ ...p, ...turn(p.x, p.y), angle: (p.angle + r) % 360 }));
392
+ const b = sym.body ? bodyBoundsOf(sym) : null;
393
+ let body = null;
394
+ if (b) {
395
+ const c1 = turn(b.minX, b.minY);
396
+ const c2 = turn(b.maxX, b.maxY);
397
+ body = { minX: Math.min(c1.x, c2.x), maxX: Math.max(c1.x, c2.x), minY: Math.min(c1.y, c2.y), maxY: Math.max(c1.y, c2.y) };
398
+ }
399
+ return { ...sym, pins, body };
400
+ };
251
401
  const bodyBoundsOf = (sym) => {
252
402
  if (sym.body)
253
403
  return sym.body;
@@ -257,6 +407,49 @@ const bodyBoundsOf = (sym) => {
257
407
  return { minX: -U, minY: -U, maxX: U, maxY: U };
258
408
  return { minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys) };
259
409
  };
410
+ /**
411
+ * Wire and label colours by function: rails one colour, grounds another,
412
+ * and every FAMILY of signal nets — the nets sharing a prefix before the
413
+ * first underscore, when there are at least two of them (I2S_BCLK, I2S_DIN,
414
+ * I2S_LRCLK; SPI_…; BTN_…) — a colour of its own from a fixed palette, in
415
+ * family-name order so the same design colours the same way every time. A
416
+ * lone signal keeps the theme's default: colouring everything in a block
417
+ * one colour would say nothing.
418
+ */
419
+ const FAMILY_PALETTE = [
420
+ [30, 90, 200],
421
+ [130, 50, 170],
422
+ [0, 130, 120],
423
+ [210, 120, 0],
424
+ [180, 30, 140],
425
+ [110, 130, 0],
426
+ [140, 80, 40],
427
+ [40, 60, 140],
428
+ ];
429
+ const RAIL_COLOR = [170, 30, 30];
430
+ const GROUND_COLOR = [70, 70, 70];
431
+ function netColorsOf(nets, classes) {
432
+ const out = {};
433
+ const families = new Map();
434
+ for (const n of nets) {
435
+ const cls = classes.get(n.name)?.cls ?? 'signal';
436
+ if (cls === 'rail')
437
+ out[n.name] = RAIL_COLOR;
438
+ else if (cls === 'ground')
439
+ out[n.name] = GROUND_COLOR;
440
+ else {
441
+ const m = /^([A-Za-z0-9]+)_/.exec(n.name);
442
+ if (m)
443
+ families.set(m[1], [...(families.get(m[1]) ?? []), n.name]);
444
+ }
445
+ }
446
+ const named = [...families.entries()].filter(([, members]) => members.length >= 2).sort((a, b) => a[0].localeCompare(b[0]));
447
+ named.forEach(([, members], i) => {
448
+ for (const name of members)
449
+ out[name] = FAMILY_PALETTE[i % FAMILY_PALETTE.length];
450
+ });
451
+ return out;
452
+ }
260
453
  /** Pin connection point in schematic space for a part placed at (x, y), rot 0. */
261
454
  const pinAt = (p, pin) => ({ x: p.x + pin.x, y: p.y - pin.y });
262
455
  /** Outward direction of a pin (away from the body), schematic space. */
@@ -315,13 +508,64 @@ const boundsOverlap = (a, b) => a.minX < b.maxX - 0.01 && a.maxX > b.minX + 0.01
315
508
  * conservative metrics. Shared by the de-collision pass and the overlap report
316
509
  * so "clear" means one thing in the engine.
317
510
  */
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
- };
511
+ const labelTextBox = (name, x, y, rot, kind = 'local') =>
512
+ // A local label is bottom-justified on its anchor: on paper the text stands
513
+ // on the wire line and rises one full height above it, and the checker's
514
+ // `labelBounds` measures exactly that. A global label's flag is measured as
515
+ // eeschema draws it (`labelBoxAt`).
516
+ labelBoxAt(name, x, y, rot, kind, LABEL_ADVANCE);
517
+ /**
518
+ * Where a wired run's name may sit when it must be a global flag, in
519
+ * preference order: standing up from the top of a vertical trunk (the flag on
520
+ * a pole a reviewer expects), then every other wire end continuing its
521
+ * segment outward, then the interior grid points of each segment with the
522
+ * flag perpendicular to the wire, upward before downward. Every candidate
523
+ * lies on a wire of the run, so the name always attaches.
524
+ */
525
+ export function wiredFlagCandidates(segs) {
526
+ const out = [];
527
+ const seen = new Set();
528
+ const push = (x, y, rot) => {
529
+ const k = `${pointKey(x, y)}/${rot}`;
530
+ if (seen.has(k))
531
+ return;
532
+ seen.add(k);
533
+ out.push({ x, y, rot });
534
+ };
535
+ const vertical = segs.filter((s) => sameCoord(s.x1, s.x2));
536
+ const horizontal = segs.filter((s) => !sameCoord(s.x1, s.x2));
537
+ // Horizontal first: a name reads along the row it names (the drafting
538
+ // standard keeps text horizontal), so the ends of horizontal segments
539
+ // continuing outward come before a flag standing up from a trunk top, and
540
+ // both before a flag perpendicular to a wire's interior.
541
+ const ends = segs.flatMap((s) => [{ x: s.x1, y: s.y1 }, { x: s.x2, y: s.y2 }]).sort((a, b) => a.y - b.y || a.x - b.x);
542
+ for (const p of ends) {
543
+ for (const s of horizontal) {
544
+ if (!sameCoord(s.y1, p.y))
545
+ continue;
546
+ if (sameCoord(p.x, Math.max(s.x1, s.x2)))
547
+ push(p.x, p.y, 0);
548
+ if (sameCoord(p.x, Math.min(s.x1, s.x2)))
549
+ push(p.x, p.y, 180);
550
+ }
551
+ }
552
+ const tops = vertical.map((s) => ({ x: s.x1, y: Math.min(s.y1, s.y2) })).sort((a, b) => a.y - b.y || a.x - b.x);
553
+ for (const t of tops)
554
+ push(t.x, t.y, 90);
555
+ for (const p of ends) {
556
+ for (const s of vertical) {
557
+ if (sameCoord(s.x1, p.x) && sameCoord(p.y, Math.max(s.y1, s.y2)))
558
+ push(p.x, p.y, 270);
559
+ }
560
+ }
561
+ for (const s of segs) {
562
+ for (const rot of sameCoord(s.x1, s.x2) ? [0, 180] : [90, 270]) {
563
+ for (const p of interiorGridPoints([s], U))
564
+ push(p.x, p.y, rot);
565
+ }
566
+ }
567
+ return out;
568
+ }
325
569
  /**
326
570
  * Pairs of labels naming DIFFERENT nets whose text boxes overlap.
327
571
  *
@@ -332,7 +576,7 @@ const labelTextBox = (name, x, y, rot) => {
332
576
  */
333
577
  export function findLabelOverlaps(labels) {
334
578
  const out = new Map();
335
- const boxes = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot));
579
+ const boxes = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
336
580
  for (let i = 0; i < labels.length; i++) {
337
581
  for (let j = i + 1; j < labels.length; j++) {
338
582
  const a = labels[i];
@@ -366,7 +610,174 @@ const segCrossesBody = (x1, y1, x2, y2, b) => {
366
610
  return y1 > b.minY + 0.01 && y1 < b.maxY - 0.01 && inX;
367
611
  return inX && inY; // conservative for diagonals (the engine never draws them)
368
612
  };
613
+ /**
614
+ * Draft, then check the drawn group boxes against one another: a box grows
615
+ * past its tiling estimate when the text it must hold (a flag on a hung
616
+ * part, a rail name at the edge) reaches further than the pin labels the
617
+ * estimate counted, and two boxes then intersect (usb-atmega-node's Status
618
+ * over IO Headers). Each overlap widens the right-hand group's left reserve
619
+ * by the overlap and the sheet is drafted again; placement is a pure
620
+ * function of the intent and these reserves, so the result stays
621
+ * deterministic. Three rounds bound the cost.
622
+ */
369
623
  export function draftSchematicPlacement(validated, projectName, today) {
624
+ const reserves = new Map();
625
+ let frameSlack = 0;
626
+ let wrapGap = 0;
627
+ // The squeeze. The first draft sizes every cell by rule (a margin a side, a
628
+ // power symbol's worth under every hung part, a label beside every pin) and
629
+ // draws into that room; most of the room stays empty. Each further round
630
+ // sizes the cells from what the previous round actually drew — body,
631
+ // wires, labels, power symbols, field text of the cell and of everything
632
+ // hung on it — plus a pad, and draws again. Rounds continue while the
633
+ // group boxes shrink and the engine's own gates hold (no merged nets, the
634
+ // label-overlap budget kept); the smallest passing sheet is the result.
635
+ // This is the step a drafter does by eye after placing: look, then pull
636
+ // things together.
637
+ let measured;
638
+ let best = null;
639
+ let squeezeRounds = 0;
640
+ let areaBefore = 0;
641
+ const boxArea = (rects) => rects.reduce((a, r) => a + (r.x2 - r.x1) * (r.y2 - r.y1), 0);
642
+ let retries = 0;
643
+ // The look. Before any wrap is fitted the engine can only estimate how far
644
+ // a group's labels reach past its cells, and it estimates generously: a
645
+ // label's width beside every pin that might carry one. Those reserves
646
+ // decide how many groups share a row or column, so a wrapped sheet is
647
+ // drafted once more with the reach every box was actually measured to
648
+ // take, and that draft is kept when it fits the same or a smaller sheet
649
+ // with the gates still holding.
650
+ let reachMeasured;
651
+ let look = 'not-yet';
652
+ let lookVerdict;
653
+ for (let round = 0; round < 16; round++) {
654
+ const { model, report, rects, measured: nextMeasured, reach, wrapped } = draftOnce(validated, projectName, today, reserves, frameSlack, measured, wrapGap, reachMeasured);
655
+ let widened = false;
656
+ const stillWrong = [];
657
+ // A row or column wrapped to the full usable width leaves no room for the
658
+ // text its boxes grow to hold afterwards, and a box then crosses the
659
+ // frame (usb-atmega-node's IO Headers on A3). Any box past the frame
660
+ // shrinks the usable frame by the overflow and the sheet is drafted again.
661
+ const paper = PAPERS.find((p) => p.name === report.sheetFit.paper);
662
+ if (paper) {
663
+ const over = Math.max(0, ...rects.flatMap((r) => [FRAME - r.x1, r.x2 - (paper.w - FRAME), FRAME - r.y1, r.y2 - (paper.h - FRAME)]));
664
+ if (over > 0.01) {
665
+ frameSlack += over + U;
666
+ widened = true;
667
+ stillWrong.push(`a box crosses the frame by ${over.toFixed(1)} mm`);
668
+ trace(`a group box crosses the frame by ${over.toFixed(2)} mm; the usable frame shrinks by ${frameSlack.toFixed(2)} mm and the sheet is drafted again`);
669
+ }
670
+ // A sheet fitted into the title strip was judged on its group rects
671
+ // before the boxes grew to their text; a box that grew into the title
672
+ // block's own corner (as the checker reserves it, narrowed on small
673
+ // pages) overflows the usable height by how far it reaches in.
674
+ const cornerX = paper.w - FRAME - Math.min(TITLE_BLOCK_W, (paper.w - 2 * FRAME) / 2);
675
+ const cornerY = paper.h - FRAME - Math.min(TITLE_STRIP, (paper.h - 2 * FRAME) / 4);
676
+ const into = Math.max(0, ...rects.map((r) => (r.x2 > cornerX + 0.01 && r.y2 > cornerY + 0.01 ? r.y2 - cornerY : 0)));
677
+ if (over <= 0.01 && into > 0.01) {
678
+ frameSlack += into + U;
679
+ widened = true;
680
+ stillWrong.push(`a box enters the title block's corner by ${into.toFixed(1)} mm`);
681
+ trace(`a group box enters the title block's corner by ${into.toFixed(2)} mm; the usable frame shrinks by ${frameSlack.toFixed(2)} mm and the sheet is drafted again`);
682
+ }
683
+ }
684
+ for (let i = 0; i < rects.length; i++) {
685
+ for (let j = i + 1; j < rects.length; j++) {
686
+ const a = rects[i];
687
+ const b = rects[j];
688
+ const ox = Math.min(a.x2, b.x2) - Math.max(a.x1, b.x1);
689
+ const oy = Math.min(a.y2, b.y2) - Math.max(a.y1, b.y1);
690
+ if (ox <= 0.01 || oy <= 0.01)
691
+ continue;
692
+ widened = true;
693
+ stillWrong.push(`"${a.name}" and "${b.name}" intersect`);
694
+ if (oy < ox) {
695
+ // boxes in different rows (or columns) touching along the wrap
696
+ // gap: widen the gap between wrapped rows and columns
697
+ wrapGap += oy + U;
698
+ trace(`group boxes "${a.name}" and "${b.name}" overlap by ${oy.toFixed(2)} mm across the wrap; the wrap gap grows to ${wrapGap.toFixed(2)} mm and the sheet is drafted again`);
699
+ continue;
700
+ }
701
+ const right = a.x1 < b.x1 ? b : a;
702
+ const r = reserves.get(right.name) ?? { left: 0, right: 0 };
703
+ r.left += ox + U;
704
+ reserves.set(right.name, r);
705
+ trace(`group boxes "${a.name}" and "${b.name}" overlap by ${ox.toFixed(2)} mm; "${right.name}" reserves ${r.left.toFixed(2)} mm more on its left and the sheet is drafted again`);
706
+ }
707
+ }
708
+ if (widened && retries < 3) {
709
+ retries++;
710
+ continue; // the same cells, re-tiled: not a squeeze round
711
+ }
712
+ const area = boxArea(rects);
713
+ const refusals = report.notes.filter((n) => /refused/.test(n)).length;
714
+ const paperIdx = PAPERS.findIndex((p) => p.name === report.paper);
715
+ // a round whose boxes still overlap or cross the frame after three
716
+ // re-tilings is not a sheet to keep; nor one that lost a hung part, or
717
+ // needs a larger sheet, or shrank by less than three percent
718
+ const passes = !widened && report.mergedNets.length === 0 && !report.labelOverlapBudgetExceeded;
719
+ if (widened)
720
+ report.notes.push(`group boxes still wrong after ${retries} re-tilings (${stillWrong.join('; ')}); the sheet is drawn as it stands`);
721
+ retries = 0;
722
+ reserves.clear();
723
+ frameSlack = 0;
724
+ wrapGap = 0;
725
+ if (!best) {
726
+ areaBefore = area;
727
+ best = { model, report, area, refusals, paperIdx };
728
+ if (look === 'not-yet' && wrapped && passes) {
729
+ look = 'drafting';
730
+ reachMeasured = reach;
731
+ trace(`box reach measured: ${[...reach].map(([g, r]) => `${g.slice(0, 2).trim()} ${r.left.toFixed(1)}/${r.right.toFixed(1)} ${r.top.toFixed(1)}/${r.bottom.toFixed(1)}`).join(', ')}; the wrap is fitted again on it`);
732
+ continue;
733
+ }
734
+ }
735
+ else if (look === 'drafting') {
736
+ look = 'done';
737
+ if (passes && refusals <= best.refusals && (paperIdx < best.paperIdx || (paperIdx === best.paperIdx && area <= best.area))) {
738
+ trace(`measured-reach draft kept: ${report.paper}, boxes ${area.toFixed(0)} mm² (was ${best.report.paper}, ${best.area.toFixed(0)} mm²)`);
739
+ lookVerdict = { kept: true, paperBefore: best.report.paper, paperAfter: report.paper };
740
+ if (paperIdx < best.paperIdx)
741
+ report.notes.push(`label reach measured: the wrap fitted again takes ${report.paper}, not ${best.report.paper}`);
742
+ areaBefore = area;
743
+ best = { model, report, area, refusals, paperIdx };
744
+ }
745
+ else {
746
+ trace(`measured-reach draft not kept: ${passes ? `${report.paper}, boxes ${area.toFixed(0)} mm²` : 'gates failed'} against ${best.report.paper}, ${best.area.toFixed(0)} mm²`);
747
+ lookVerdict = { kept: false, paperBefore: best.report.paper, paperAfter: best.report.paper };
748
+ reachMeasured = undefined;
749
+ }
750
+ }
751
+ else if (passes && refusals <= best.refusals && paperIdx <= best.paperIdx && area < best.area * 0.97) {
752
+ best = { model, report, area, refusals, paperIdx };
753
+ }
754
+ else {
755
+ trace(`squeeze round ${squeezeRounds}: ${passes ? `boxes ${area.toFixed(0)} mm² did not shrink` : 'gates failed'}; keeping the previous round`);
756
+ break;
757
+ }
758
+ // Rounds beyond the first run only when asked for: on the boards drafted
759
+ // so far the rules were within a few percent of the drawing, and the
760
+ // extra drafts cost time and re-pin every fixture. The measurement and
761
+ // the report are always made.
762
+ if (squeezeRounds >= 3 || process.env['COPPERHEAD_DRAFT_SQUEEZE'] !== '1')
763
+ break;
764
+ measured = nextMeasured;
765
+ squeezeRounds++;
766
+ trace(`squeeze round ${squeezeRounds}: cells measured, group boxes ${area.toFixed(0)} mm²; drafting again`);
767
+ }
768
+ const out = best;
769
+ out.report.sheetFit.squeeze = { rounds: squeezeRounds, boxAreaBefore: Math.round(areaBefore), boxAreaAfter: Math.round(out.area) };
770
+ if (lookVerdict)
771
+ out.report.sheetFit.look = lookVerdict;
772
+ if (squeezeRounds > 0) {
773
+ out.report.notes.push(`cells measured: group boxes ${Math.round(areaBefore / 1000)} k mm² to ${Math.round(out.area / 1000)} k mm² over ${squeezeRounds} round(s) on ${out.report.paper}`);
774
+ }
775
+ return { model: out.model, report: out.report };
776
+ }
777
+ function draftOnce(validated, projectName, today, reserves, frameSlack = 0, measured, wrapGap = 0, reachMeasured) {
778
+ // a measured round keeps only a small margin: the measured overhang says
779
+ // where the drawing actually reaches
780
+ const MARGIN = measured ? MEASURED_MARGIN : BASE_MARGIN;
370
781
  const { intent, symbols, docGroups } = validated;
371
782
  const notes = [];
372
783
  // ---------- net classification (deterministic, visible in the report) ----------
@@ -410,6 +821,19 @@ export function draftSchematicPlacement(validated, projectName, today) {
410
821
  }));
411
822
  });
412
823
  const instByKey = new Map(instances.map((i) => [i.key, i]));
824
+ /** The pin that controls a transistor (base or gate), or null for anything else. */
825
+ const controlPinOf = (key) => {
826
+ const inst = instByKey.get(key);
827
+ if (!inst || inst.sym.pins.length !== 3)
828
+ return null;
829
+ if (!/^Transistor|^Device:Q_|^Q\d/.test(inst.part.libId) && !/^Q\d/.test(inst.ref))
830
+ return null;
831
+ return inst.sym.pins.find((p) => /^(B|G|Base|Gate)$/i.test(p.name)) ?? null;
832
+ };
833
+ const isTransistor = (key) => controlPinOf(key) !== null;
834
+ /** Switches are the anchors of a button block: the pull-up, the debounce
835
+ * cap and the ESD diode all hang on the switch's signal pin. */
836
+ const isSwitch = (key) => /^Switch[:_]|^Device:SW_|^Button/i.test(instByKey.get(key)?.part.libId ?? '');
413
837
  /** Placed instances per refdes, for expanding a common pin's endpoint. */
414
838
  const instancesOfRef = new Map();
415
839
  for (const inst of instances)
@@ -509,14 +933,45 @@ export function draftSchematicPlacement(validated, projectName, today) {
509
933
  let right = 0;
510
934
  for (const key of keys) {
511
935
  const inst = instByKey.get(key);
936
+ if (!inst)
937
+ continue;
938
+ // a part with pins only on its top and bottom (a vertical R or C)
939
+ // carries its reference and value beside it on the right
940
+ if (inst.sym.pins.every((pin) => outward(pin).dx === 0)) {
941
+ const fieldW = Math.max(inst.ref.length + 1, inst.part.value.length) * TEXT_RESERVE * LABEL_HEIGHT;
942
+ right = Math.max(right, 1.27 + fieldW + 1.27);
943
+ }
512
944
  for (const pin of inst?.sym.pins ?? []) {
513
- const net = signalNetOfPin.get(`${inst.ref}.${pin.number}`);
514
- if (!net)
515
- continue;
516
945
  const o = outward(pin);
517
946
  if (o.dx === 0)
518
947
  continue;
519
- const extent = STUB * U + Math.max(1, net.length) * LABEL_ADVANCE * LABEL_HEIGHT;
948
+ const ep = `${inst.ref}.${pin.number}`;
949
+ const signal = signalNetOfPin.get(ep);
950
+ const power = signal ? undefined : netByEndpoint.get(ep);
951
+ if (!signal && !power)
952
+ continue;
953
+ // a small signal net whose every endpoint is in this group is wired,
954
+ // not labelled: no flag reaches out from it (counting one for every
955
+ // pin put 27 mm between groups whose facing sides carry no label). A
956
+ // larger in-group net may still end in stub labels, so it keeps its
957
+ // reserve.
958
+ if (signal) {
959
+ const net = netByEndpoint.get(ep);
960
+ const others = (net?.pins ?? []).filter((other) => other !== ep).map((other) => /^([^.]+)\./.exec(other)?.[1] ?? '');
961
+ const leaves = !net || net.pins.length > MAX_WIRED_ENDPOINTS || others.some((ref) => partByRef.get(ref)?.group !== inst.part.group);
962
+ // only when every other endpoint is a two-lead part (or a test
963
+ // point): those hang on the pin and are wired by construction; two
964
+ // ICs of one group may still sit past wire span and take labels
965
+ const hangs = others.every((ref) => (symbols.get(ref)?.pins.length ?? 3) <= 2);
966
+ if (!leaves && hangs)
967
+ continue;
968
+ }
969
+ // a signal: stub plus the label's flag (text, margins and tip); a
970
+ // rail or ground on a sideways pin: the longer power stub, the bar,
971
+ // and the name drawn outward in line
972
+ const extent = signal
973
+ ? STUB * U + Math.max(1, signal.length) * TEXT_RESERVE * LABEL_HEIGHT + FLAG_PAD
974
+ : (STUB + 2) * U + 1.905 + Math.max(1, power.name.length) * TEXT_RESERVE * LABEL_HEIGHT;
520
975
  if (o.dx === -1)
521
976
  left = Math.max(left, extent);
522
977
  else
@@ -526,8 +981,9 @@ export function draftSchematicPlacement(validated, projectName, today) {
526
981
  return { left, right };
527
982
  };
528
983
  /** 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);
984
+ * clearance is `baseUnits`: the two texts, the box padding each group draws
985
+ * around its own text (`BOX_PAD` a side), and the gap between the boxes. */
986
+ const widenBy = (rightOfPrev, leftOfNext, baseUnits) => Math.max(0, ceilU(rightOfPrev + leftOfNext) + 2 * BOX_PAD + BOX_GAP - baseUnits);
531
987
  // ---------- group ordering: hints, then SUBSYSTEMS.md order, then name ----------
532
988
  const groupNames = [...new Set(intent.parts.filter((p) => !symbols.get(p.ref).isPower).map((p) => p.group))];
533
989
  const orderIndex = (g) => {
@@ -544,9 +1000,20 @@ export function draftSchematicPlacement(validated, projectName, today) {
544
1000
  const placed = new Map();
545
1001
  const groupRects = [];
546
1002
  const groupOf = new Map();
1003
+ /** Part key -> the anchor it was hung on or laid beside. A bound part is
1004
+ * wired to its anchor whatever the distance: the shelf placed it there
1005
+ * deliberately, and a lane past the wire span still belongs to its pin. */
1006
+ const boundTo = new Map();
547
1007
  const groupExtents = new Map();
548
1008
  for (const gname of groupNames) {
549
- groupExtents.set(gname, labelExtents(instances.filter((i) => i.part.group === gname && !i.sym.isPower).map((i) => i.key)));
1009
+ // The estimate reserves a label's width beside every pin that might take
1010
+ // one; the drawing seldom uses a tenth of it. A round that has seen the
1011
+ // drawing reserves what its box actually grew past its cells, plus a
1012
+ // unit.
1013
+ const m = reachMeasured?.get(gname);
1014
+ const e = m ? { left: m.left + U, right: m.right + U } : labelExtents(instances.filter((i) => i.part.group === gname && !i.sym.isPower).map((i) => i.key));
1015
+ const r = reserves.get(gname);
1016
+ groupExtents.set(gname, { left: e.left + (r?.left ?? 0), right: e.right + (r?.right ?? 0) });
550
1017
  }
551
1018
  /**
552
1019
  * Place every group's cells. `bandBudgetW` caps a single group's width, in
@@ -594,23 +1061,83 @@ export function draftSchematicPlacement(validated, projectName, today) {
594
1061
  }
595
1062
  }
596
1063
  }
597
- for (let iter = 0; iter < members.length; iter++) {
598
- let changed = false;
1064
+ // The IC leads its group. Edges above are stored refdes-ordered, and
1065
+ // C, D, J, L, Q and R all sort before U, so plain propagation pushed
1066
+ // every IC to the deepest column on the right with its passives strung
1067
+ // out to its left — a reader looked for the part the group is about and
1068
+ // found it last. When a group has ICs (three or more pins, not a
1069
+ // connector), they anchor the first column after the connectors and
1070
+ // every other member sits at its signal-net hop distance from the
1071
+ // nearest IC; a member no signal path reaches stays beside the ICs.
1072
+ // A group with no IC keeps the edge propagation, where the connector
1073
+ // column is the only anchor there is.
1074
+ // A transistor is a three-pin part, but it is not what a group is
1075
+ // about: it belongs at the end of the run that drives its base, the way
1076
+ // a hand-drawn sheet puts a level shifter or a switch right on the pin.
1077
+ const groupIC = (key) => instByKey.get(key).sym.pins.length >= 3 && !isConnector(instByKey.get(key).part) && !isTransistor(key);
1078
+ const anchors = members.filter((m) => groupIC(m.key)).map((m) => m.key);
1079
+ if (anchors.length) {
1080
+ // IC to IC, the signal flow still propagates (a chain of forty
1081
+ // stages is forty columns, not one), refdes-ordered as before
1082
+ const icSet = new Set(anchors);
1083
+ for (let iter = 0; iter < anchors.length; iter++) {
1084
+ let changed = false;
1085
+ for (const e of edges) {
1086
+ if (!icSet.has(e.from) || !icSet.has(e.to))
1087
+ continue;
1088
+ const want = depth.get(e.from) + 1;
1089
+ if (depth.get(e.to) < want) {
1090
+ depth.set(e.to, want);
1091
+ changed = true;
1092
+ }
1093
+ }
1094
+ if (!changed)
1095
+ break;
1096
+ }
1097
+ // then every other member at its hop distance past the nearest IC,
1098
+ // never routing through a connector or another IC
1099
+ const adj = new Map();
599
1100
  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;
1101
+ adj.set(e.from, [...(adj.get(e.from) ?? []), e.to]);
1102
+ adj.set(e.to, [...(adj.get(e.to) ?? []), e.from]);
1103
+ }
1104
+ const dist = new Map(anchors.map((k) => [k, depth.get(k)]));
1105
+ const queue = [...anchors].sort((a, b) => dist.get(a) - dist.get(b));
1106
+ while (queue.length) {
1107
+ const k = queue.shift();
1108
+ for (const o of adj.get(k) ?? []) {
1109
+ if (dist.has(o) || isConnector(instByKey.get(o).part))
1110
+ continue;
1111
+ dist.set(o, dist.get(k) + 1);
1112
+ queue.push(o);
604
1113
  }
605
1114
  }
606
- if (!changed)
607
- break;
1115
+ for (const [k, d] of dist)
1116
+ if (!icSet.has(k))
1117
+ depth.set(k, d);
1118
+ }
1119
+ else {
1120
+ for (let iter = 0; iter < members.length; iter++) {
1121
+ let changed = false;
1122
+ for (const e of edges) {
1123
+ const want = (depth.get(e.from) ?? 0) + 1;
1124
+ if ((depth.get(e.to) ?? 0) < want && want <= members.length) {
1125
+ depth.set(e.to, want);
1126
+ changed = true;
1127
+ }
1128
+ }
1129
+ if (!changed)
1130
+ break;
1131
+ }
608
1132
  }
609
1133
  const depths = [...new Set([...depth.values()])].sort((a, b) => a - b);
610
1134
  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
1135
+ // barycenter row ordering (two sweeps), refdes as the deterministic
1136
+ // tie; an IC stays at the top of its column so the group reads from
1137
+ // its top-left corner
612
1138
  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)));
1139
+ const lead = (ref) => (groupIC(ref) ? 0 : 1);
1140
+ columns.forEach((col) => col.sort((a, b) => lead(a) - lead(b) || a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i)));
614
1141
  for (let sweep = 0; sweep < 2; sweep++) {
615
1142
  for (let ci = 1; ci < columns.length; ci++) {
616
1143
  const col = columns[ci];
@@ -623,33 +1150,760 @@ export function draftSchematicPlacement(validated, projectName, today) {
623
1150
  return rowOf.get(ref);
624
1151
  return neigh.reduce((s, o) => s + rowOf.get(o), 0) / neigh.length;
625
1152
  };
626
- col.sort((a, b) => bary(a) - bary(b) || a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i));
1153
+ col.sort((a, b) => lead(a) - lead(b) || bary(a) - bary(b) || a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i));
627
1154
  }
628
1155
  }
629
1156
  // cells: sized from body plus margins, positions snapped to the grid
630
1157
  const cellDims = new Map();
631
1158
  for (const m of members) {
632
1159
  const b = bodyBoundsOf(m.sym);
1160
+ const mo = measured?.get(m.key);
1161
+ if (mo) {
1162
+ // the cell is what the previous round drew around this body, a pad
1163
+ // past it on every side: the body sits at shelfL + MARGIN from the
1164
+ // cell's left edge and topPad + MARGIN from its top
1165
+ const shelfL = Math.max(0, ceilU(mo.left) + MEASURE_PAD - MARGIN);
1166
+ const shelfR = Math.max(0, ceilU(mo.right) + MEASURE_PAD - MARGIN);
1167
+ const topPad = Math.max(0, ceilU(mo.top) + MEASURE_PAD - VMARGIN);
1168
+ const below = Math.max(VMARGIN, ceilU(mo.bottom) + MEASURE_PAD);
1169
+ cellDims.set(m.key, {
1170
+ w: shelfL + MARGIN + ceilU(b.maxX - b.minX) + MARGIN + shelfR,
1171
+ h: topPad + VMARGIN + ceilU(b.maxY - b.minY) + below,
1172
+ body: b,
1173
+ shelfL,
1174
+ shelfR,
1175
+ topPad,
1176
+ });
1177
+ continue;
1178
+ }
1179
+ // a rail or ground on a pin facing up or down draws a stub, a bar and
1180
+ // a name past the body: that pin's side reserves the symbol's reach
1181
+ const powered = (dy) => m.sym.pins.some((p) => outward(p).dy === dy && netByEndpoint.has(`${m.ref}.${p.number}`) && (netClasses.get(netByEndpoint.get(`${m.ref}.${p.number}`).name)?.cls ?? 'signal') !== 'signal');
1182
+ const topReach = powered(-1) ? POWER_REACH : 0;
1183
+ const botReach = powered(1) ? POWER_REACH : 0;
633
1184
  cellDims.set(m.key, {
634
1185
  w: ceilU(b.maxX - b.minX) + 2 * MARGIN,
635
- h: ceilU(b.maxY - b.minY) + 2 * MARGIN,
1186
+ h: Math.max(0, topReach - VMARGIN) + VMARGIN + ceilU(b.maxY - b.minY) + VMARGIN + Math.max(0, botReach - VMARGIN),
636
1187
  body: b,
1188
+ shelfL: 0,
1189
+ shelfR: 0,
1190
+ topPad: Math.max(0, topReach - VMARGIN),
637
1191
  });
638
1192
  }
1193
+ const hangs = [];
1194
+ const inlines = [];
1195
+ const hungKeys = new Set();
1196
+ for (const [k, v] of boundTo)
1197
+ if (groupOf.get(k) === gname)
1198
+ boundTo.delete(k), void v;
1199
+ /**
1200
+ * The rotation (0, 90, 180, 270) that turns two-lead part `key` so that
1201
+ * pin `nearPin` faces direction `want` (schematic dx/dy) and its other
1202
+ * lead faces the opposite way; null when the part is not a two-lead
1203
+ * part with opposed leads. This is what lets a diode, LED, fuse or
1204
+ * switch (horizontal leads in the library) hang like a resistor, and a
1205
+ * resistor lie along a row like a diode.
1206
+ */
1207
+ /** A test point: one pin, probing the net it sits on. It hangs beside that net's pin. */
1208
+ const isTestPoint = (key) => {
1209
+ const inst = instByKey.get(key);
1210
+ return !!inst && inst.sym.pins.length === 1 && (/TestPoint/i.test(inst.part.libId) || /^TP\d/.test(inst.ref));
1211
+ };
1212
+ const orientFor = (key, nearPin, want) => {
1213
+ const pins = instByKey.get(key)?.sym.pins ?? [];
1214
+ if (pins.length !== 2 && !isTestPoint(key))
1215
+ return null;
1216
+ for (const rot of [0, 90, 180, 270]) {
1217
+ const rs = rotatedSym(instByKey.get(key).sym, rot);
1218
+ const near = rs.pins.find((p) => p.number === nearPin);
1219
+ if (!near)
1220
+ return null;
1221
+ const on = outward(near);
1222
+ if (on.dx !== want.dx || on.dy !== want.dy)
1223
+ continue;
1224
+ if (pins.length === 1)
1225
+ return rot;
1226
+ const other = rs.pins.find((p) => p.number !== nearPin);
1227
+ const oo = outward(other);
1228
+ if (oo.dx === -want.dx && oo.dy === -want.dy)
1229
+ return rot;
1230
+ }
1231
+ return null;
1232
+ };
1233
+ /** Both leads of a two-lead part, un-rotated; a test point's one pin twice. */
1234
+ const leadsOf = (key) => {
1235
+ const pins = instByKey.get(key)?.sym.pins ?? [];
1236
+ if (isTestPoint(key))
1237
+ return { a: pins[0], b: pins[0] };
1238
+ if (pins.length !== 2)
1239
+ return null;
1240
+ const oa = outward(pins[0]);
1241
+ const ob = outward(pins[1]);
1242
+ if (oa.dx !== -ob.dx || oa.dy !== -ob.dy)
1243
+ return null; // leads must oppose
1244
+ return { a: pins[0], b: pins[1] };
1245
+ };
1246
+ const hangable = (key) => memberKeySet.has(key) &&
1247
+ !decapOwner.has(key) &&
1248
+ !hungKeys.has(key) &&
1249
+ !isConnector(instByKey.get(key).part) &&
1250
+ !/crystal|reson/i.test(instByKey.get(key)?.part.libId ?? '') &&
1251
+ leadsOf(key) !== null;
1252
+ /** Lead span along the hang or run, mm: connection point to connection
1253
+ * point, or for a test point the body's reach past its one pin. */
1254
+ const spanOf = (key, rot = 0) => {
1255
+ const ctl = controlPinOf(key);
1256
+ if (ctl) {
1257
+ const b = bodyBoundsOf(instByKey.get(key).sym);
1258
+ return Math.max(b.maxX - ctl.x, ctl.x - b.minX);
1259
+ }
1260
+ const l = leadsOf(key);
1261
+ if (isTestPoint(key)) {
1262
+ const rs = rotatedSym(instByKey.get(key).sym, rot);
1263
+ const pin = rs.pins[0];
1264
+ const b = bodyBoundsOf(rs);
1265
+ const o = outward(pin);
1266
+ // the body lies opposite the pin's outward direction
1267
+ return o.dy !== 0 ? Math.max(0, o.dy === 1 ? b.maxY - pin.y : pin.y - b.minY) : Math.max(0, o.dx === 1 ? pin.x - b.minX : b.maxX - pin.x);
1268
+ }
1269
+ return Math.hypot(l.a.x - l.b.x, l.a.y - l.b.y);
1270
+ };
1271
+ const INLINE_GAP = 4 * U;
1272
+ /**
1273
+ * Claim a hang from IC pin `pin` starting at `first` (an endpoint of the
1274
+ * pin's net). `dir` 1 hangs down from the row, -1 rises above it; the
1275
+ * chain continues through two-endpoint links to a rail, a ground or a
1276
+ * tapped node. The part is turned so its near lead faces the row.
1277
+ */
1278
+ const claimHang = (icKey, pin, dx, first, dir) => {
1279
+ const chain = [first.key];
1280
+ const nearPins = [first.pin];
1281
+ hungKeys.add(first.key);
1282
+ boundTo.set(first.key, icKey);
1283
+ let ends = 'open';
1284
+ for (;;) {
1285
+ const cur = chain[chain.length - 1];
1286
+ if (isTestPoint(cur))
1287
+ break;
1288
+ const l = leadsOf(cur);
1289
+ const near = nearPins[nearPins.length - 1];
1290
+ const far = l.a.number === near ? l.b : l.a;
1291
+ const beyond = netByEndpoint.get(`${instByKey.get(cur).ref}.${far.number}`);
1292
+ if (!beyond)
1293
+ break;
1294
+ if ((netClasses.get(beyond.name)?.cls ?? 'signal') !== 'signal') {
1295
+ ends = 'power';
1296
+ break;
1297
+ }
1298
+ if (beyond.pins.length !== 2)
1299
+ break;
1300
+ const next = beyond.pins.map(epKey).find((e) => e !== null && e.key !== cur);
1301
+ if (!next || !hangable(next.key) || chain.includes(next.key))
1302
+ break;
1303
+ chain.push(next.key);
1304
+ nearPins.push(next.pin);
1305
+ hungKeys.add(next.key);
1306
+ boundTo.set(next.key, icKey);
1307
+ }
1308
+ let h = 0;
1309
+ let w = 0;
1310
+ for (const key of chain) {
1311
+ const inst = instByKey.get(key);
1312
+ // turned to hang: the body's extent across the axis and its span along it
1313
+ const rotH = orientFor(key, nearPins[chain.indexOf(key)], { dx: 0, dy: -dir }) ?? 0;
1314
+ const rs = rotatedSym(inst.sym, rotH);
1315
+ const b = bodyBoundsOf(rs);
1316
+ h += spanOf(key, rotH) + HANG_GAP;
1317
+ // a hung part is narrow: its body, the reference and value
1318
+ // beside it, and a unit of air — not the column cell's margins
1319
+ const fieldW = Math.max(inst.ref.length + 1, inst.part.value.length) * TEXT_RESERVE * LABEL_HEIGHT + 1.27;
1320
+ w = Math.max(w, b.maxX - b.minX + fieldW + U);
1321
+ }
1322
+ if (ends === 'power')
1323
+ h += HANG_POWER_END;
1324
+ hangs.push({ ic: icKey, pin, dx, dir, chain, slot: 0, straight: false, w, h, ends });
1325
+ hangNearPins.set(chain.join('+'), nearPins);
1326
+ };
1327
+ const hangNearPins = new Map();
1328
+ const placedOffset = new Set();
1329
+ /**
1330
+ * Claim a series run along the row from IC pin `pin`: `first` lies on
1331
+ * the row with its near lead toward the IC, and the run continues
1332
+ * through two-endpoint links while the next part is a two-lead part.
1333
+ * The far end gets whatever the wire pass gives it: a wire to a
1334
+ * connector on the row, or a label.
1335
+ */
1336
+ /** Gap before a part on a row: room for the wired net's name above the wire, at least one plain gap. */
1337
+ const gapFor = (netName) => Math.max(INLINE_GAP, grid(ceilU(Math.max(1, netName.length) * TEXT_RESERVE * LABEL_HEIGHT + 2 * U)));
1338
+ const claimInline = (icKey, pin, dx, first) => {
1339
+ const pinNet = netByEndpoint.get(`${instByKey.get(icKey).ref}.${pin.number}`)?.name ?? '';
1340
+ const chain = [{ key: first.key, nearPin: first.pin, gap: gapFor(pinNet) }];
1341
+ hungKeys.add(first.key);
1342
+ boundTo.set(first.key, icKey);
1343
+ let farNet = null;
1344
+ for (let n = 0; n < 3; n++) {
1345
+ const cur = chain[chain.length - 1];
1346
+ if (isTransistor(cur.key)) {
1347
+ farNet = null; // the run ends in the transistor; its other pins take what the wire pass gives
1348
+ break;
1349
+ }
1350
+ const l = leadsOf(cur.key);
1351
+ const far = l.a.number === cur.nearPin ? l.b : l.a;
1352
+ const beyond = netByEndpoint.get(`${instByKey.get(cur.key).ref}.${far.number}`);
1353
+ farNet = beyond?.name ?? null;
1354
+ if (!beyond || beyond.pins.length !== 2)
1355
+ break;
1356
+ const next = beyond.pins.map(epKey).find((e) => e !== null && e.key !== cur.key);
1357
+ if (!next || chain.some((c) => c.key === next.key))
1358
+ break;
1359
+ // a series resistor into a base or gate: the transistor ends the run,
1360
+ // turned so that pin faces the part that drives it
1361
+ if (isTransistor(next.key)) {
1362
+ if (!memberKeySet.has(next.key) || hungKeys.has(next.key) || controlPinOf(next.key).number !== next.pin)
1363
+ break;
1364
+ chain.push({ key: next.key, nearPin: next.pin, gap: gapFor(beyond.name) });
1365
+ hungKeys.add(next.key);
1366
+ boundTo.set(next.key, icKey);
1367
+ farNet = null;
1368
+ break;
1369
+ }
1370
+ if (!hangable(next.key))
1371
+ break;
1372
+ chain.push({ key: next.key, nearPin: next.pin, gap: gapFor(beyond.name) });
1373
+ hungKeys.add(next.key);
1374
+ boundTo.set(next.key, icKey);
1375
+ }
1376
+ let len = 0;
1377
+ for (const c of chain)
1378
+ len += c.gap + spanOf(c.key);
1379
+ // room for the label the far end may carry
1380
+ if (farNet)
1381
+ len += STUB * U + Math.max(1, farNet.length) * TEXT_RESERVE * LABEL_HEIGHT;
1382
+ inlines.push({ ic: icKey, pin, dx, chain, len, farNet, offset: 0 });
1383
+ };
1384
+ const epKey = (ep) => {
1385
+ const m = /^([^.]+)\.(.+)$/.exec(ep);
1386
+ return m ? { key: instKeyOf(m[1], m[2]), pin: m[2] } : null;
1387
+ };
1388
+ // Three units, not four: a hung part's near stub ends one unit past
1389
+ // the gap, and pins sit two units apart, so an even gap parks that
1390
+ // stub end exactly on the neighbouring pin's row, where any run along
1391
+ // that row would join it (every single pull-up beside a pull-down on
1392
+ // the next pin was refused for this). An odd gap keeps it between rows.
1393
+ const HANG_GAP = 3 * U;
1394
+ /**
1395
+ * How far the labels on one side of an anchor actually reach, given
1396
+ * what has been claimed on it: a pin whose parts hang carries no stub
1397
+ * label any more, only its run's name (and only when the net has
1398
+ * endpoints elsewhere), so the lanes may start that much closer to the
1399
+ * IC. `labelExtents` assumes a stub label on every pin and put the
1400
+ * lanes 15 mm further out than the drawing needed.
1401
+ */
1402
+ const sideReach = (icKey, dx) => {
1403
+ const ic = instByKey.get(icKey);
1404
+ let reach = 0;
1405
+ for (const pin of ic.sym.pins) {
1406
+ if (outward(pin).dx !== dx)
1407
+ continue;
1408
+ const net = netByEndpoint.get(`${ic.ref}.${pin.number}`);
1409
+ if (!net)
1410
+ continue;
1411
+ const nameW = Math.max(1, net.name.length) * TEXT_RESERVE * LABEL_HEIGHT;
1412
+ if ((netClasses.get(net.name)?.cls ?? 'signal') !== 'signal') {
1413
+ reach = Math.max(reach, (STUB + 2) * U + 1.905 + nameW);
1414
+ continue;
1415
+ }
1416
+ const eps = net.pins.map(epKey);
1417
+ const claimed = eps.some((e) => e !== null && boundTo.get(e.key) === icKey);
1418
+ const elsewhere = eps.some((e) => e === null || (e.key !== icKey && !memberKeySet.has(e.key) && !decapOwner.has(e.key)));
1419
+ if (claimed)
1420
+ reach = Math.max(reach, elsewhere ? nameW + FLAG_PAD + U : STUB * U);
1421
+ else
1422
+ reach = Math.max(reach, STUB * U + nameW + FLAG_PAD);
1423
+ }
1424
+ return reach;
1425
+ };
1426
+ const HANG_POWER_END = 5 * U;
1427
+ /** Rows a power symbol and its name need past a pin: stub, bar, text. */
1428
+ const POWER_CLEAR_ROWS = 6;
1429
+ // Parts hang on ICs first, then on connectors and switches: a fuse
1430
+ // rises from the jack's pin, a button's pull-up, debounce cap and ESD
1431
+ // diode hang on the switch's signal pin. A switch already hung on an
1432
+ // IC pin (a reset button dropping from EN) anchors nothing itself.
1433
+ const claimAnchors = [
1434
+ ...anchors,
1435
+ ...members.filter((m) => !anchors.includes(m.key) && (isConnector(m.part) || isSwitch(m.key))).map((m) => m.key),
1436
+ ];
1437
+ for (const icKey of claimAnchors) {
1438
+ if (hungKeys.has(icKey))
1439
+ continue;
1440
+ const ic = instByKey.get(icKey);
1441
+ const sidePins = ic.sym.pins
1442
+ .filter((p) => outward(p).dx !== 0)
1443
+ .sort((a, b) => b.y - a.y || a.x - b.x); // top row first (symbol y is up)
1444
+ for (const pin of sidePins) {
1445
+ const dx = outward(pin).dx;
1446
+ const net = netByEndpoint.get(`${ic.ref}.${pin.number}`);
1447
+ if (!net || (netClasses.get(net.name)?.cls ?? 'signal') !== 'signal')
1448
+ continue;
1449
+ // the wire pass draws this net only if it stays in the group with
1450
+ // few endpoints; a net that will be labelled anyway gives no hang
1451
+ const eps = net.pins.map(epKey).filter((e) => e !== null);
1452
+ // A net that leaves the group or carries more endpoints than the
1453
+ // wire pass joins still hangs its local parts: the pull-up on a
1454
+ // fault line that also goes to the MCU, the RC on a button the
1455
+ // MCU reads. The wire pass joins the pin and its hung parts as
1456
+ // one local run and labels it once for the rest of the net
1457
+ // (esp32-amp shipped fourteen such parts as labelled islands).
1458
+ if (eps.length > MAX_WIRED_ENDPOINTS)
1459
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${eps.length} endpoints, local parts still hang`);
1460
+ // a crystal's load caps belong to the flanking idiom below
1461
+ if (eps.some((e) => /crystal|reson/i.test(instByKey.get(e.key)?.part.libId ?? '')))
1462
+ continue;
1463
+ // the chain enters the first part through whichever lead is on the
1464
+ // pin's net: through the top lead it hangs DOWN from the row,
1465
+ // through the bottom lead it rises above it (the pull-up shape)
1466
+ // every hangable endpoint of the pin's net hangs, each as its own
1467
+ // chain: a compensation network is a series RC AND a shunt C on
1468
+ // one pin, and leaving the shunt in a far column keeps the whole
1469
+ // net past wire span, labelled
1470
+ /** The net on the lead of `key` that is NOT `nearPin`. */
1471
+ const farNetOf = (key, nearPin) => {
1472
+ const l = leadsOf(key);
1473
+ const far = l.a.number === nearPin ? l.b : l.a;
1474
+ return netByEndpoint.get(`${instByKey.get(key).ref}.${far.number}`);
1475
+ };
1476
+ /** Bridges from this pin to another pin of the IC, resolved after the pin's other parts. */
1477
+ const pinBridges = [];
1478
+ for (const first of eps) {
1479
+ if (first.key === icKey)
1480
+ continue;
1481
+ // a transistor driven straight from the pin lies on the row, base to the pin
1482
+ if (isTransistor(first.key) && memberKeySet.has(first.key) && !hungKeys.has(first.key) && controlPinOf(first.key).number === first.pin) {
1483
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key).ref} lies on the row (transistor)`);
1484
+ claimInline(icKey, pin, dx, first);
1485
+ continue;
1486
+ }
1487
+ if (!hangable(first.key)) {
1488
+ const why = !memberKeySet.has(first.key) ? 'not a group member' : decapOwner.has(first.key) ? 'a decoupling cap' : hungKeys.has(first.key) ? 'already claimed' : isConnector(instByKey.get(first.key).part) ? 'a connector' : leadsOf(first.key) === null ? 'not a two-lead part' : 'a crystal';
1489
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key).ref} not hangable (${why})`);
1490
+ continue;
1491
+ }
1492
+ // a test point rises above the row of the pin it probes
1493
+ if (isTestPoint(first.key)) {
1494
+ claimHang(icKey, pin, dx, first, -1);
1495
+ continue;
1496
+ }
1497
+ // Look to the END of the run this part starts, through two-endpoint
1498
+ // links: a run that ends on a rail or ground is a SHUNT and hangs
1499
+ // from the row — rising to a rail (a pull-up), dropping to ground
1500
+ // (a pull-down, a filter cap, a series RC to ground). A run whose
1501
+ // far net returns to a pin of this IC BRIDGES the two pins and
1502
+ // hangs toward the other pin's row so that net stays within wire
1503
+ // span (a bootstrap cap between BST and OUT). Everything else is
1504
+ // a SERIES element — a 100 Ω in an I2S line, an output inductor —
1505
+ // and lies along the row, away from the IC, the way the reference
1506
+ // sheet draws it.
1507
+ let cur = first;
1508
+ let farNet = farNetOf(cur.key, cur.pin);
1509
+ const seen = new Set([first.key]);
1510
+ for (let n = 0; n < 4 && farNet && (netClasses.get(farNet.name)?.cls ?? 'signal') === 'signal' && farNet.pins.length === 2; n++) {
1511
+ const next = farNet.pins.map(epKey).find((e) => e !== null && e.key !== cur.key);
1512
+ if (!next || !hangable(next.key) || seen.has(next.key))
1513
+ break;
1514
+ seen.add(next.key);
1515
+ cur = next;
1516
+ farNet = farNetOf(cur.key, cur.pin);
1517
+ }
1518
+ const farCls = farNet ? (netClasses.get(farNet.name)?.cls ?? 'signal') : null;
1519
+ // A bridge (the far net returns to another pin of this IC) lies
1520
+ // on the row of the pin that has NOTHING ELSE on it: claimed
1521
+ // from the busier pin it blocked that pin's row, and the series
1522
+ // part behind it could not be wired (esp32-amp's C28 on OUT_LN
1523
+ // left L3 a labelled island while C27 on BST_AP let L2 wire).
1524
+ const bridgeEp = cur.key === first.key && farNet && farCls === 'signal' && farNet.pins.length === 2
1525
+ ? farNet.pins.find((ep) => ep.startsWith(`${ic.ref}.`) && ep !== `${ic.ref}.${pin.number}`)
1526
+ : undefined;
1527
+ const otherPin = bridgeEp ? ic.sym.pins.find((p) => `${ic.ref}.${p.number}` === bridgeEp) : undefined;
1528
+ if (otherPin) {
1529
+ const others = eps.filter((e) => e.key !== icKey && e.key !== first.key && (hangable(e.key) || boundTo.get(e.key) === icKey));
1530
+ pinBridges.push({ first, otherPin, othersOnPin: others.length > 0 });
1531
+ continue;
1532
+ }
1533
+ // A part between two pins of this IC (a bootstrap cap between
1534
+ // BST and OUT) lies along its row too: hung, its far lead
1535
+ // landed rows away from the other pin and that pin's branch ran
1536
+ // along a third row under a label. On the row, the far net
1537
+ // reaches the other pin's row through the router's trunk.
1538
+ // A bridge to another pin of this IC (a bootstrap cap between BST
1539
+ // and SW) hangs in a lane toward that pin's row, its far lead
1540
+ // joined to that row by one short jog: laid along the row it sat
1541
+ // past every lane and every series part, and the bank under the
1542
+ // group followed the row out (rails 112 mm wide, 91 by hand).
1543
+ if (farCls === 'ground') {
1544
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key).ref} hangs down (to ${farNet.name})`);
1545
+ claimHang(icKey, pin, dx, first, 1);
1546
+ }
1547
+ else if (farCls === 'rail') {
1548
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key).ref} rises (to ${farNet.name})`);
1549
+ claimHang(icKey, pin, dx, first, -1);
1550
+ }
1551
+ else {
1552
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key).ref} lies on the row (far net ${farNet?.name ?? 'none'})`);
1553
+ claimInline(icKey, pin, dx, first);
1554
+ }
1555
+ }
1556
+ // A bridge cap (BST to SW) hangs in the lane this pin already has,
1557
+ // toward the other pin's row, when the pin has a hang to share the
1558
+ // lane with (the buck's L1 rising from SW): one axis, one T, and the
1559
+ // other row reaches the cap's far lead with a single jog. With no
1560
+ // lane of its own to join it stays on the row (hung alone it cost
1561
+ // four attachments on the amplifier's four bootstrap pairs); and it
1562
+ // is left to the other pin when this pin carries series parts and
1563
+ // that pin carries nothing else.
1564
+ const pinHasHang = hangs.some((hg) => hg.ic === icKey && hg.pin.number === pin.number);
1565
+ for (const b of pinBridges) {
1566
+ if (hungKeys.has(b.first.key))
1567
+ continue;
1568
+ if (pinHasHang) {
1569
+ const dir = b.otherPin.y < pin.y ? 1 : -1; // symbol y is up: a lower pin means dropping
1570
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(b.first.key).ref} bridges to ${ic.ref}.${b.otherPin.number}; shares the pin's lane, ${dir === 1 ? 'down' : 'up'}`);
1571
+ claimHang(icKey, pin, dx, b.first, dir);
1572
+ }
1573
+ else if (b.othersOnPin) {
1574
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(b.first.key).ref} bridges to ${ic.ref}.${b.otherPin.number}; left for that pin`);
1575
+ }
1576
+ else {
1577
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(b.first.key).ref} bridges to ${ic.ref}.${b.otherPin.number}; lies on the row`);
1578
+ claimInline(icKey, pin, dx, b.first);
1579
+ }
1580
+ }
1581
+ }
1582
+ // Slots per side. A hang whose rows are free of every other pin on
1583
+ // that side stays dead straight on its stub axis (AC-16.31: the wire
1584
+ // from the pin continues into the chain with no bend); the rest take
1585
+ // the first shelf slot whose rows are free. Rows are relative to the
1586
+ // IC origin, y down; symbol pin y is up.
1587
+ for (const dx of [-1, 1]) {
1588
+ const side = hangs.filter((hg) => hg.ic === icKey && hg.dx === dx);
1589
+ const inlineSide = inlines.filter((il) => il.ic === icKey && il.dx === dx);
1590
+ // Two horizontal parts on rows one pitch apart cannot share an x
1591
+ // range: a run on a row next to an earlier run starts past that
1592
+ // run's end (the reference sheet's output filter alternates the
1593
+ // inductor and the cap across its rows for the same reason).
1594
+ // a part lying on a row carries its reference above and its value
1595
+ // below, so its texts reach the neighbouring rows: runs within
1596
+ // three rows of each other stagger (two left C27's value under L3's
1597
+ // reference)
1598
+ const ROWS_NEAR = 3 * 2.54 + 0.01;
1599
+ for (const il of [...inlineSide].sort((a, b) => b.pin.y - a.pin.y)) {
1600
+ let offset = 0;
1601
+ for (;;) {
1602
+ const clash = inlineSide.find((o) => o !== il && o.offset !== undefined && placedOffset.has(o) && Math.abs(o.pin.y - il.pin.y) <= ROWS_NEAR && offset < o.offset + o.len && offset + il.len > o.offset);
1603
+ if (!clash)
1604
+ break;
1605
+ offset = clash.offset + clash.len + INLINE_GAP;
1606
+ }
1607
+ il.offset = offset;
1608
+ placedOffset.add(il);
1609
+ }
1610
+ if (!side.length && !inlineSide.length)
1611
+ continue;
1612
+ // any connected neighbour blocks the straight axis: a signal pin's
1613
+ // stub end sits on it, and a rail or ground pin's longer stub
1614
+ // crosses it — legal, but a wire through the IC's supply stubs is
1615
+ // exactly the crossing a drafter moves a part to avoid (the Tier C
1616
+ // divider drew two of them). A no-connect neighbour blocks nothing.
1617
+ const sidePinYs = ic.sym.pins
1618
+ .filter((p) => outward(p).dx === dx && netByEndpoint.has(`${ic.ref}.${p.number}`))
1619
+ .map((p) => -p.y);
1620
+ const pitch = Math.max(0, ...side.map((hg) => hg.w)) + U;
1621
+ const slots = [];
1622
+ let anyShelved = false;
1623
+ // Slot order matters: a lower pin's branch runs along its row from
1624
+ // the stub to its chain's axis, and a chain hanging down from a
1625
+ // higher pin must not cross that row inside the branch's span. So
1626
+ // down-hangs take slots from the lowest pin upward and up-hangs from
1627
+ // the highest pin downward: the chain that passes other pins' rows
1628
+ // is always the outer one, and every branch stays clear of every
1629
+ // sibling's stub end (esp32-amp's RT branch ran into the COMP
1630
+ // hang's stub end the first time).
1631
+ // Slots go to PINS, not hangs: a part rising and a part dropping
1632
+ // from one pin share one axis — the divider on its pin — and meet
1633
+ // the row in a single T. A slot's occupied range runs from the pin
1634
+ // ROW to the chain end, because the branch along the row belongs
1635
+ // to it: measured from the first part instead, a chain rising from
1636
+ // a lower pin shared its slot with the higher pin's drop and the
1637
+ // two axes met on that pin's row (esp32-amp's USB_OV divider was
1638
+ // refused for exactly that, four hangs on two adjacent pins).
1639
+ // One axis carries at most one chain up and one chain down; a
1640
+ // third chain on the same pin (the NTC divider's cap beside its
1641
+ // thermistor) takes the next lane out, and each lane is slotted as
1642
+ // its own group.
1643
+ const byPin = new Map();
1644
+ for (const hg of side)
1645
+ byPin.set(hg.pin.number, [...(byPin.get(hg.pin.number) ?? []), hg]);
1646
+ const pinRow = (g) => -g[0].pin.y;
1647
+ const pinGroups = [];
1648
+ for (const hgs of byPin.values()) {
1649
+ const ups = hgs.filter((hg) => hg.dir === -1);
1650
+ const downs = hgs.filter((hg) => hg.dir === 1);
1651
+ for (let i = 0; i < Math.max(ups.length, downs.length); i++) {
1652
+ const lane = [ups[i], downs[i]].filter((hg) => hg !== undefined);
1653
+ pinGroups.push(lane);
1654
+ }
1655
+ }
1656
+ const ordered = [
1657
+ ...pinGroups.filter((g) => g.some((hg) => hg.dir === 1)).sort((a, b) => pinRow(b) - pinRow(a)),
1658
+ ...pinGroups.filter((g) => g.every((hg) => hg.dir === -1)).sort((a, b) => pinRow(a) - pinRow(b)),
1659
+ ];
1660
+ const lowRowAll = Math.max(...side.map((hg) => -hg.pin.y));
1661
+ const highRowAll = Math.min(...side.map((hg) => -hg.pin.y));
1662
+ for (const g of ordered) {
1663
+ const rowY = pinRow(g);
1664
+ const top = Math.min(rowY, ...g.map((hg) => (hg.dir === -1 ? highRowAll - HANG_GAP - hg.h : rowY)));
1665
+ const bot = Math.max(rowY, ...g.map((hg) => (hg.dir === 1 ? lowRowAll + HANG_GAP + hg.h : rowY)));
1666
+ // the clearance check pads the axis range by four units past the
1667
+ // pin row and the chain end, so a neighbour that close blocks it
1668
+ // the same five units (four of pad plus one) the clearance check
1669
+ // itself applies, else a neighbour exactly at the edge passes here
1670
+ // and refuses there (J1's fuse against the CC1 pin two rows down)
1671
+ const straight = !sidePinYs.some((y) => y !== rowY && y > top - 5 * U && y < bot + 5 * U);
1672
+ for (const hg of g)
1673
+ hg.straight = straight;
1674
+ if (straight)
1675
+ continue;
1676
+ anyShelved = true;
1677
+ let k = 0;
1678
+ while ((slots[k] ?? []).some((iv) => top < iv.bot + 2 * U && bot > iv.top - 2 * U))
1679
+ k++;
1680
+ (slots[k] ??= []).push({ top, bot });
1681
+ for (const hg of g)
1682
+ hg.slot = k;
1683
+ }
1684
+ const dims = cellDims.get(icKey);
1685
+ const ext = labelExtents([icKey]);
1686
+ // Lanes come first, past the side's labels; the inline runs start
1687
+ // beyond the lanes. A part lying on the row between the stub and
1688
+ // the lanes blocked the row for the pin's own hung parts (Q1 on
1689
+ // VBUS_FET_EN left R17 unreachable), and a run on another row
1690
+ // starting inside the lanes crossed their bodies.
1691
+ const laneReach = sideReach(icKey, dx);
1692
+ void ext;
1693
+ const lanesW = anyShelved ? slots.length * pitch : 0;
1694
+ for (const il of inlineSide)
1695
+ il.offset += anyShelved ? ceilU(laneReach + 2 * U) * U + lanesW : 0;
1696
+ const inlineW = Math.max(0, ...inlineSide.map((il) => il.offset + il.len));
1697
+ // shelf: the IC's own labels, the stub, the lanes, then the runs. A
1698
+ // straight hang sits within the stub and the cell's own margin and
1699
+ // channel, so a side whose hangs are all straight and carries no
1700
+ // inline run reserves nothing (a five-part board went from A5 to A4
1701
+ // when it did).
1702
+ const shelf = anyShelved ? ceilU(laneReach + STUB * U) + 2 + Math.ceil(lanesW / U) + (inlineSide.length ? ceilU(inlineW - lanesW - laneReach) : 0) : inlineSide.length ? ceilU(inlineW + STUB * U) + 2 : 0;
1703
+ if (!measured?.has(icKey)) {
1704
+ if (dx === 1)
1705
+ dims.shelfR = shelf;
1706
+ else
1707
+ dims.shelfL = shelf;
1708
+ dims.w += shelf;
1709
+ }
1710
+ // y-down relative to the IC origin: the body spans -maxY..-minY
1711
+ const lowRow = Math.max(...side.map((hg) => -hg.pin.y));
1712
+ const highRow = Math.min(...side.map((hg) => -hg.pin.y));
1713
+ const deepest = Math.max(-dims.body.minY, ...side.map((hg) => (hg.dir === 1 ? (hg.straight ? -hg.pin.y : lowRow) + HANG_GAP + hg.h : -hg.pin.y)), ...inlineSide.map((il) => -il.pin.y + 3 * U));
1714
+ // what this side uses below the body's top, and how far its labels
1715
+ // reach: the shelf below that is free for the infill pass
1716
+ const laneDepth = Math.max(-dims.body.maxY, ...side.map((hg) => (hg.dir === 1 ? (hg.straight ? -hg.pin.y : lowRow) + HANG_GAP + hg.h : -hg.pin.y)), ...inlineSide.map((il) => -il.pin.y + 3 * U));
1717
+ const usedBelow = ceilU(laneDepth - -dims.body.maxY) + 2;
1718
+ if (dx === 1) {
1719
+ dims.usedR = usedBelow;
1720
+ dims.reachR = ceilU(laneReach + 2 * U);
1721
+ }
1722
+ else {
1723
+ dims.usedL = usedBelow;
1724
+ dims.reachL = ceilU(laneReach + 2 * U);
1725
+ }
1726
+ const highest = Math.min(-dims.body.maxY, ...side.map((hg) => (hg.dir === -1 ? (hg.straight ? -hg.pin.y : highRow) - HANG_GAP - hg.h : -hg.pin.y)));
1727
+ // an inline run lies on its row; a transistor at its end reaches two
1728
+ // rows above and below it (collector and emitter stubs)
1729
+ for (const il of inlineSide) {
1730
+ for (const c of il.chain) {
1731
+ if (!controlPinOf(c.key))
1732
+ continue;
1733
+ const b = bodyBoundsOf(instByKey.get(c.key).sym);
1734
+ const reach = Math.max(b.maxY, -b.minY) + STUB * U + POWER_CLEAR_ROWS * U;
1735
+ if (measured?.has(icKey))
1736
+ continue;
1737
+ dims.topPad = Math.max(dims.topPad, ceilU(-dims.body.maxY - (-il.pin.y - reach)));
1738
+ dims.h = Math.max(dims.h, ceilU(Math.max(deepest, -il.pin.y + reach) - Math.min(highest, -il.pin.y - reach)) + VMARGIN + 2);
1739
+ }
1740
+ }
1741
+ if (!measured?.has(icKey)) {
1742
+ dims.topPad = Math.max(dims.topPad, ceilU(-dims.body.maxY - highest));
1743
+ // the chain's end already carries the symbol's own room; two
1744
+ // units of air below it, not a full margin (a button block's cell
1745
+ // was 39 mm tall where a drafter draws it on a 25 mm pitch)
1746
+ dims.h = Math.max(dims.h, ceilU(deepest - highest) + VMARGIN + 2);
1747
+ }
1748
+ }
1749
+ }
1750
+ // Node hangs: the far end of a series run is a node too. An RC filter's
1751
+ // cap, a button's debounce cap and ESD diode sit on the net PAST the
1752
+ // series resistor, so they hang from the run's last part, dropping or
1753
+ // rising from the row just beyond its far lead, the first straight down
1754
+ // from the row's end and the rest on lanes past it.
1755
+ const nodeAnchors = new Set();
1756
+ for (const il of [...inlines]) {
1757
+ const last = il.chain[il.chain.length - 1];
1758
+ if (isTransistor(last.key))
1759
+ continue;
1760
+ const l = leadsOf(last.key);
1761
+ const farPin = l.a.number === last.nearPin ? l.b : l.a;
1762
+ const farNet = netByEndpoint.get(`${instByKey.get(last.key).ref}.${farPin.number}`);
1763
+ if (!farNet || (netClasses.get(farNet.name)?.cls ?? 'signal') !== 'signal')
1764
+ continue;
1765
+ const eps = farNet.pins.map(epKey).filter((e) => e !== null);
1766
+ let lanes = 0;
1767
+ for (const first of eps) {
1768
+ if (first.key === last.key || !hangable(first.key))
1769
+ continue;
1770
+ const fl = leadsOf(first.key);
1771
+ const far = fl.a.number === first.pin ? fl.b : fl.a;
1772
+ const beyond = netByEndpoint.get(`${instByKey.get(first.key).ref}.${far.number}`);
1773
+ const cls = beyond ? (netClasses.get(beyond.name)?.cls ?? 'signal') : null;
1774
+ if (cls !== 'ground' && cls !== 'rail')
1775
+ continue; // only shunts; a second series part is a network
1776
+ trace(`${instByKey.get(last.key).ref}.${farPin.number} ${farNet.name} (node): ${instByKey.get(first.key).ref} ${cls === 'ground' ? 'hangs down' : 'rises'}`);
1777
+ claimHang(last.key, farPin, il.dx, first, cls === 'ground' ? 1 : -1);
1778
+ const hg = hangs[hangs.length - 1];
1779
+ hg.slot = lanes;
1780
+ hg.straight = lanes === 0;
1781
+ lanes++;
1782
+ nodeAnchors.add(last.key);
1783
+ }
1784
+ if (!lanes)
1785
+ continue;
1786
+ // the run's owner reserves the lanes and the drop below them
1787
+ const nodeHangs = hangs.filter((hg) => hg.ic === last.key);
1788
+ const pitch = Math.max(0, ...nodeHangs.map((hg) => hg.w)) + U;
1789
+ const add = (lanes - 1) * pitch + 2 * U + Math.max(0, ...nodeHangs.map((hg) => hg.w)) / 2;
1790
+ il.len += add;
1791
+ const dims = cellDims.get(il.ic);
1792
+ if (measured?.has(il.ic))
1793
+ continue;
1794
+ if (il.dx === 1)
1795
+ dims.shelfR += ceilU(add);
1796
+ else
1797
+ dims.shelfL += ceilU(add);
1798
+ dims.w += ceilU(add);
1799
+ const rowRel = -il.pin.y;
1800
+ const hDown = Math.max(0, ...nodeHangs.filter((hg) => hg.dir === 1).map((hg) => hg.h));
1801
+ const hUp = Math.max(0, ...nodeHangs.filter((hg) => hg.dir === -1).map((hg) => hg.h));
1802
+ const bottom = Math.max(-dims.body.minY, rowRel + HANG_GAP + hDown);
1803
+ const top = Math.min(-dims.body.maxY, rowRel - HANG_GAP - hUp);
1804
+ dims.topPad = Math.max(dims.topPad, ceilU(-dims.body.maxY - top));
1805
+ dims.h = Math.max(dims.h, ceilU(bottom - top) + VMARGIN + 2);
1806
+ }
1807
+ void nodeAnchors;
1808
+ for (const col of columns) {
1809
+ for (let i = col.length - 1; i >= 0; i--)
1810
+ if (hungKeys.has(col[i]))
1811
+ col.splice(i, 1);
1812
+ }
1813
+ for (let i = columns.length - 1; i >= 0; i--)
1814
+ if (!columns[i].length)
1815
+ columns.splice(i, 1);
1816
+ // The budget must leave room for the label TEXT facing the sheet edges:
1817
+ // a band filled to the full usable width puts the leftmost column's
1818
+ // left-facing labels outside the frame (#220 phase 1), and no later
1819
+ // shift can fix both edges at once.
1820
+ const ext = groupExtents.get(gname);
1821
+ const bandW = Math.max(1, bandBudgetW - ceilU(ext.left) - ceilU(ext.right));
1822
+ // Height the decoupling bank adds UNDER the columns once they span
1823
+ // `blockW` units: the same row-wrap the bank placement below performs.
1824
+ const bankHeight = (blockW) => {
1825
+ if (!caps.length)
1826
+ return 0;
1827
+ const capBudget = Math.min(bandW, Math.max(64, blockW));
1828
+ let rows = 1;
1829
+ let x = 0;
1830
+ for (const c of caps) {
1831
+ const b = bodyBoundsOf(c.sym);
1832
+ const w = ceilU(b.maxX - b.minX) + 2 * MARGIN;
1833
+ if (x > 0 && x + w > capBudget) {
1834
+ rows++;
1835
+ x = 0;
1836
+ }
1837
+ x += w;
1838
+ }
1839
+ return MARGIN + 4 + rows * (2 * MARGIN + 6);
1840
+ };
639
1841
  // Column height budget (#220 phase 4): a depth whose parts stack taller
640
1842
  // than the budget splits into several side-by-side columns, in row
641
1843
  // order, so a 24-part board stops drafting as one full-height strip on
642
1844
  // a sheet two sizes too large. Cells never shrink; only the arrangement
643
1845
  // changes, so readability is untouched.
644
- const columnsToPlace = colBudgetH === Infinity
645
- ? columns
646
- : columns.flatMap((col) => {
1846
+ //
1847
+ // The bank sits under the columns and counts against the SAME budget:
1848
+ // a group whose columns already fit the budget got no shorter under the
1849
+ // compaction retries while its bulk-cap rows pushed the group past the
1850
+ // sheet (a 30-part power-input group missed A1 by 8 mm that way). The
1851
+ // bank's rows are estimated at the columns' pre-split width — a lower
1852
+ // bound on the width they end up with, so an upper bound on rows.
1853
+ const colsW = columns.reduce((s, col) => s + Math.max(...col.map((r) => cellDims.get(r).w)), 0) +
1854
+ CHANNEL * Math.max(0, columns.length - 1);
1855
+ const sheetBudget = colBudgetH === Infinity ? Infinity : Math.max(1, colBudgetH - bankHeight(colsW));
1856
+ // ---------- in-group column balance ----------
1857
+ // A depth whose parts stack into one strip draws the group as a tall
1858
+ // thin column beside a short IC with the rest of the box empty: an
1859
+ // amplifier group put 20 filter caps in one 500 mm column next to a
1860
+ // 120 mm IC, and the box was two-thirds air. A drafter runs such parts
1861
+ // in several columns no taller than the IC they serve. So every column
1862
+ // is budgeted at the taller of the group's tallest IC cell and the side
1863
+ // of the square the group's cells would fill, and one taller than that
1864
+ // splits into equal-height side-by-side columns. The IC's CELL, not its
1865
+ // column: a transistor or an eFuse sharing a column with eight
1866
+ // resistors must not make that strip the reference height. Row order
1867
+ // is preserved chunk by chunk, exactly as the sheet budget splits, so
1868
+ // barycenter ordering and the trunk idioms are untouched.
1869
+ const colH = (col) => col.reduce((s, r, i) => s + cellDims.get(r).h + (i ? ROW_GAP : 0), 0);
1870
+ const isIC = (key) => instByKey.get(key).sym.pins.length >= 3;
1871
+ const anchorH = Math.max(0, ...members.filter((m) => isIC(m.key)).map((m) => cellDims.get(m.key).h));
1872
+ const cellArea = columns.reduce((s, c) => s + colH(c) * Math.max(...c.map((r) => cellDims.get(r).w)), 0);
1873
+ const squareSide = Math.ceil(Math.sqrt(cellArea));
1874
+ // Under a band budget the group is shaped to FILL the band's width, not
1875
+ // to be square: a column of eight test points re-rows into two of four
1876
+ // and the button blocks stack three deep, the grid a person draws when
1877
+ // the group must sit in a 250 mm column (esp32-amp's UI group went
1878
+ // from 364 × 150 to the width of its neighbours).
1879
+ // A group with no IC to lead it (buttons, test points, LEDs) has no
1880
+ // natural shape, and its columns line up as a strip five blocks wide;
1881
+ // shape it no wider than two and a half times its square side, the grid
1882
+ // a person draws. Groups led by an IC keep the IC's own proportions.
1883
+ const shapeToAspect = anchors.length === 0;
1884
+ const aspectBand = Math.ceil(2.5 * squareSide);
1885
+ const searchBand = shapeToAspect ? Math.min(aspectBand, bandW) : bandW;
1886
+ const targetH = shapeToAspect
1887
+ ? Math.max(Math.ceil(cellArea / searchBand), Math.ceil(squareSide / 2))
1888
+ : bandBudgetW === Infinity
1889
+ ? squareSide
1890
+ : Math.max(Math.ceil(cellArea / bandBudgetW), Math.ceil(squareSide / 2));
1891
+ const balanceH = Math.max(anchorH, targetH);
1892
+ /** Split `col` into the fewest equal-height chunks that each fit
1893
+ * `budget`; a single cell never splits, and a column shorter than
1894
+ * `minCells` is left alone (a drafter does not re-row three parts). */
1895
+ const splitColumn = (col, budget, minCells = 1) => {
1896
+ const total = colH(col);
1897
+ if (total <= budget || col.length < minCells)
1898
+ return [col];
1899
+ const k = Math.ceil(total / budget);
1900
+ const fill = (cap) => {
647
1901
  const chunks = [];
648
1902
  let cur = [];
649
1903
  let h = 0;
650
1904
  for (const ref of col) {
651
1905
  const add = cellDims.get(ref).h + (cur.length ? ROW_GAP : 0);
652
- if (cur.length && h + add > colBudgetH) {
1906
+ if (cur.length && h + add > cap) {
653
1907
  chunks.push(cur);
654
1908
  cur = [ref];
655
1909
  h = cellDims.get(ref).h;
@@ -662,20 +1916,134 @@ export function draftSchematicPlacement(validated, projectName, today) {
662
1916
  if (cur.length)
663
1917
  chunks.push(cur);
664
1918
  return chunks;
665
- });
1919
+ };
1920
+ // Balanced chunks first, for the look of level columns: the split of
1921
+ // the column into k consecutive chunks that minimises the tallest
1922
+ // chunk (a greedy fill to an equal target fell 7-1 on eight test
1923
+ // points of two heights, and 2-2-1 on five equal cells). If even
1924
+ // that tallest chunk is over the budget, fill to the budget instead.
1925
+ const hs = col.map((ref) => cellDims.get(ref).h);
1926
+ const n = hs.length;
1927
+ const runH = (i, j) => hs.slice(i, j + 1).reduce((a, b) => a + b, 0) + ROW_GAP * (j - i);
1928
+ const bestMax = Array.from({ length: k + 1 }, () => new Array(n + 1).fill(Infinity));
1929
+ const cutAt = Array.from({ length: k + 1 }, () => new Array(n + 1).fill(0));
1930
+ bestMax[0][0] = 0;
1931
+ for (let parts = 1; parts <= k; parts++) {
1932
+ for (let j = 1; j <= n; j++) {
1933
+ for (let i = parts - 1; i < j; i++) {
1934
+ const v = Math.max(bestMax[parts - 1][i], runH(i, j - 1));
1935
+ if (v < bestMax[parts][j]) {
1936
+ bestMax[parts][j] = v;
1937
+ cutAt[parts][j] = i;
1938
+ }
1939
+ }
1940
+ }
1941
+ }
1942
+ if (bestMax[k][n] > budget)
1943
+ return fill(budget);
1944
+ const chunks = [];
1945
+ for (let parts = k, j = n; parts >= 1; parts--) {
1946
+ const i = cutAt[parts][j];
1947
+ chunks.unshift(col.slice(i, j));
1948
+ j = i;
1949
+ }
1950
+ return chunks;
1951
+ };
1952
+ const BALANCE_MIN_CELLS = 4;
1953
+ // Under a band budget, the lowest column height at which the group's
1954
+ // columns fit the band side by side in ONE band wins: shorter columns
1955
+ // mean more of them and a group that wraps onto bands anyway, taller
1956
+ // ones a group that fills the band's width in one pass (five button
1957
+ // blocks as two columns of three beside two columns of test points).
1958
+ let chosenH = balanceH;
1959
+ if (bandBudgetW !== Infinity || shapeToAspect) {
1960
+ const widthAt = (h) => {
1961
+ const cols = columns.flatMap((col) => splitColumn(col, h, BALANCE_MIN_CELLS));
1962
+ // the same channel and facing-label widening the placement below applies
1963
+ return cols.reduce((sum, c, i) => {
1964
+ const next = cols[i + 1];
1965
+ return sum + Math.max(...c.map((r) => cellDims.get(r).w)) + (next ? CHANNEL + widenBy(labelExtents(c).right, labelExtents(next).left, 2 * MARGIN + CHANNEL) : 0);
1966
+ }, 0);
1967
+ };
1968
+ // against the band the columns actually get (the budget less the
1969
+ // group's own label reserves), not the raw budget
1970
+ const ceiling = Math.max(balanceH, ...columns.map(colH));
1971
+ for (let h = balanceH; h <= ceiling; h += 2) {
1972
+ if (widthAt(h) <= searchBand) {
1973
+ chosenH = h;
1974
+ break;
1975
+ }
1976
+ }
1977
+ if (widthAt(chosenH) > searchBand)
1978
+ chosenH = shapeToAspect ? balanceH : ceiling;
1979
+ }
1980
+ let columnsToPlace = columns.flatMap((col) => splitColumn(col, chosenH, BALANCE_MIN_CELLS).flatMap((c) => (sheetBudget === Infinity ? [c] : splitColumn(c, sheetBudget))));
1981
+ // Shelf infill. An IC's side shelf is as deep as its tallest lane, but
1982
+ // the lanes hang from the top pins and the shelf below them is empty;
1983
+ // small cells from later columns move into that room (the USB ESD
1984
+ // array under the UART bridge's transistor runs, where the hand-laid
1985
+ // sheet keeps it), and the column they leave narrows or disappears.
1986
+ const infill = new Map();
1987
+ for (let ci = 0; ci < columnsToPlace.length; ci++) {
1988
+ for (const host of columnsToPlace[ci]) {
1989
+ const d = cellDims.get(host);
1990
+ for (const side of [1, -1]) {
1991
+ const shelf = side === 1 ? d.shelfR : d.shelfL;
1992
+ const used = side === 1 ? d.usedR : d.usedL;
1993
+ const reach = side === 1 ? d.reachR ?? 0 : d.reachL ?? 0;
1994
+ if (!shelf || used === undefined)
1995
+ continue;
1996
+ const freeW = shelf - reach - 1;
1997
+ let top = d.topPad + VMARGIN + used;
1998
+ let freeH = d.h - top - 1;
1999
+ trace(`infill room: ${instByKey.get(host).ref} ${side === 1 ? 'right' : 'left'} shelf ${shelf}u reach ${reach}u used ${used}u -> free ${freeW}x${freeH}u`);
2000
+ if (freeW < 8 || freeH < 8)
2001
+ continue;
2002
+ // candidates from every other column, later ones first; a cell
2003
+ // leaving the host's own column shortens it
2004
+ const order = [...columnsToPlace.keys()].sort((a, b) => (a > ci ? 0 : a === ci ? 1 : 2) - (b > ci ? 0 : b === ci ? 1 : 2) || a - b);
2005
+ for (const cj of order) {
2006
+ if (freeH < 8)
2007
+ break;
2008
+ const col = columnsToPlace[cj];
2009
+ for (let k = col.length - 1; k >= 0 && freeH >= 8; k--) {
2010
+ const cand = col[k];
2011
+ if (cand === host)
2012
+ continue;
2013
+ // connectors keep the left column (the reader looks for them
2014
+ // there); anchors and anything with lanes of its own stay put
2015
+ // only a substantial part (four pins or more: an ESD array, a
2016
+ // level shifter) is worth tucking away; scattering test points
2017
+ // and LEDs into shelves reads as clutter, not economy
2018
+ const cinst = instByKey.get(cand);
2019
+ if (isConnector(cinst.part) || cinst.sym.pins.length < 4)
2020
+ continue;
2021
+ const cd = cellDims.get(cand);
2022
+ if (cd.shelfL || cd.shelfR || cd.w > freeW || cd.h > freeH)
2023
+ continue;
2024
+ infill.set(host, [...(infill.get(host) ?? []), { key: cand, side, relY: top }]);
2025
+ col.splice(k, 1);
2026
+ trace(`infill: ${instByKey.get(cand).ref} moves under the ${side === 1 ? 'right' : 'left'} shelf of ${instByKey.get(host).ref}`);
2027
+ top += cd.h + ROW_GAP;
2028
+ freeH -= cd.h + ROW_GAP;
2029
+ }
2030
+ }
2031
+ }
2032
+ }
2033
+ }
2034
+ columnsToPlace = columnsToPlace.filter((c) => c.length);
2035
+ const hostGeom = new Map();
2036
+ trace(`group "${gname}" columns (band ${bandBudgetW === Infinity ? 'inf' : bandBudgetW}u, col budget ${colBudgetH === Infinity ? 'inf' : colBudgetH}u, balance ${balanceH}u chosen ${chosenH}u): ${columnsToPlace.map((c) => `[${c.map((k) => `${instByKey.get(k).ref}:${cellDims.get(k).w}x${cellDims.get(k).h}`).join(' ')}]`).join(' ')}`);
666
2037
  let colX = groupX;
667
2038
  let groupMaxY = 0;
668
2039
  let bandTop = 0; // y origin (units) of the current band of columns
669
2040
  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
2041
  for (let ci = 0; ci < columnsToPlace.length; ci++) {
677
2042
  const col = columnsToPlace[ci];
678
2043
  const colW = Math.max(...col.map((r) => cellDims.get(r).w));
2044
+ const colShelfL = Math.max(...col.map((r) => cellDims.get(r).shelfL));
2045
+ const colShelfR = Math.max(...col.map((r) => cellDims.get(r).shelfR));
2046
+ const colCoreW = Math.max(...col.map((r) => cellDims.get(r).w - cellDims.get(r).shelfL - cellDims.get(r).shelfR));
679
2047
  // Banding (#219): a column that would tile past the width budget starts
680
2048
  // a new band of columns below everything placed so far, the way the
681
2049
  // shelf-wrap below re-rows whole groups. Never before the first column
@@ -689,9 +2057,14 @@ export function draftSchematicPlacement(validated, projectName, today) {
689
2057
  let rowY = bandTop;
690
2058
  for (const ref of col) {
691
2059
  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);
2060
+ // shared column axis (units): the core span between the shelves a
2061
+ // hung IC reserves beside itself
2062
+ const cx = colX + colShelfL + Math.floor((colW - colShelfL - colShelfR - Math.max(0, colW - colShelfL - colShelfR - colCoreW)) / 2);
694
2063
  const b = dims.body;
2064
+ // the body sits at the top of its cell, under the room any up-hang
2065
+ // needs; a cell with nothing hung is body plus margins, so this is
2066
+ // its centre as before
2067
+ const cy = rowY + dims.topPad + VMARGIN + Math.floor(ceilU(b.maxY - b.minY) / 2);
695
2068
  // origin so the body centers on the cell center, snapped to grid
696
2069
  const ox = grid(cx - Math.round((b.minX + b.maxX) / 2 / U));
697
2070
  const oy = grid(cy + Math.round((b.minY + b.maxY) / 2 / U));
@@ -706,7 +2079,10 @@ export function draftSchematicPlacement(validated, projectName, today) {
706
2079
  body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
707
2080
  cellW: dims.w,
708
2081
  cellH: dims.h,
2082
+ rot: 0,
709
2083
  });
2084
+ if (infill.has(ref))
2085
+ hostGeom.set(ref, { colX, colW, colShelfR, rowY });
710
2086
  rowY += dims.h + ROW_GAP;
711
2087
  }
712
2088
  groupMaxY = Math.max(groupMaxY, rowY - ROW_GAP);
@@ -714,7 +2090,20 @@ export function draftSchematicPlacement(validated, projectName, today) {
714
2090
  colX += colW + CHANNEL + (next ? widenBy(labelExtents(col).right, labelExtents(next).left, 2 * MARGIN + CHANNEL) : 0);
715
2091
  }
716
2092
  // 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 }));
2093
+ // by rail first, then by reference: caps of one rail sit side by side
2094
+ // and share one trunk and one pair of symbols (sorted by reference
2095
+ // alone, a +5V cap numbered after the +3V3 caps stood apart with its
2096
+ // own two symbols)
2097
+ const railOf = (key) => {
2098
+ const inst = instByKey.get(key);
2099
+ for (const p of inst.sym.pins) {
2100
+ const n = netByEndpoint.get(`${inst.ref}.${p.number}`);
2101
+ if (n && (netClasses.get(n.name)?.cls ?? 'signal') === 'rail')
2102
+ return n.name;
2103
+ }
2104
+ return '';
2105
+ };
2106
+ const capRefs = caps.map((c) => c.key).sort((a, b) => railOf(a).localeCompare(railOf(b)) || a.localeCompare(b, undefined, { numeric: true }));
718
2107
  if (capRefs.length) {
719
2108
  // The bank stacks under the circuit at the circuit's own width, the
720
2109
  // way a hand-drawn sheet does — a 45-cap ribbon run out to the band
@@ -722,34 +2111,98 @@ export function draftSchematicPlacement(validated, projectName, today) {
722
2111
  // it deserved (#233). The floor keeps a short bank (four typical cap
723
2112
  // cells) on one row even when the circuit above it is narrower.
724
2113
  const blockW = Math.max(64, colX - groupX);
725
- const capBudget = Math.min(bandW, blockW);
2114
+ let capBudget = Math.min(bandW, blockW);
2115
+ // Under a height budget the bank is part of the group's height too:
2116
+ // when its rows at the circuit's width would run past the budget,
2117
+ // widen the bank — only as far as the rows need, never past the
2118
+ // band — instead of letting it decide the sheet on its own.
2119
+ if (colBudgetH !== Infinity) {
2120
+ const capWidths = capRefs.map((ref) => {
2121
+ const b = bodyBoundsOf(instByKey.get(ref).sym);
2122
+ return ceilU(b.maxX - b.minX) + 2 * MARGIN;
2123
+ });
2124
+ const rowsAt = (budget) => {
2125
+ let rows = 1;
2126
+ let x = 0;
2127
+ for (const w of capWidths) {
2128
+ if (x > 0 && x + w > budget) {
2129
+ rows++;
2130
+ x = 0;
2131
+ }
2132
+ x += w;
2133
+ }
2134
+ return rows;
2135
+ };
2136
+ const rowsAllowed = Math.max(1, Math.floor((colBudgetH - groupMaxY - (MARGIN + 4)) / (2 * MARGIN + 6)));
2137
+ if (rowsAt(capBudget) > rowsAllowed) {
2138
+ const total = capWidths.reduce((a, b) => a + b, 0);
2139
+ capBudget = Math.min(bandW, Math.max(capBudget, Math.ceil(total / rowsAllowed) + Math.max(...capWidths)));
2140
+ }
2141
+ }
726
2142
  let capX = groupX;
727
2143
  let capY = groupMaxY + MARGIN + 4;
2144
+ let bankRowH = 2 * MARGIN + 6;
2145
+ // A bank member with horizontal leads (a TVS diode drawn lying down)
2146
+ // stands up like the caps beside it, its rail lead on top; lying in
2147
+ // the row it carried its ground symbol sideways under the next cap's
2148
+ // name. Each member is turned, and measured, by its own leads.
2149
+ const bankRotOf = (ref) => {
2150
+ const inst = instByKey.get(ref);
2151
+ if (inst.sym.pins.length !== 2 || !inst.sym.pins.every((p) => outward(p).dx !== 0))
2152
+ return 0;
2153
+ const railPin = inst.sym.pins.find((p) => (netClasses.get(netByEndpoint.get(`${inst.ref}.${p.number}`)?.name ?? '')?.cls ?? 'signal') === 'rail') ?? inst.sym.pins[0];
2154
+ return orientFor(ref, railPin.number, { dx: 0, dy: -1 }) ?? 90;
2155
+ };
728
2156
  for (const ref of capRefs) {
729
2157
  const inst = instByKey.get(ref);
730
- const b = bodyBoundsOf(inst.sym);
2158
+ const rot = bankRotOf(ref);
2159
+ const sym = rotatedSym(inst.sym, rot);
2160
+ const b = bodyBoundsOf(sym);
2161
+ // a measured cap takes the room its drawing needed (its own fields,
2162
+ // the rail bar and name above, the ground below), plus the pad
2163
+ const mo = measured?.get(ref);
2164
+ const capW = mo ? ceilU(mo.left) + ceilU(b.maxX - b.minX) + ceilU(mo.right) + 2 * MEASURE_PAD : ceilU(b.maxX - b.minX) + 2 * MARGIN;
2165
+ const capLeft = mo ? ceilU(mo.left) + MEASURE_PAD : MARGIN;
2166
+ const capTop = mo ? ceilU(mo.top) + MEASURE_PAD : MARGIN;
2167
+ const capRowH = mo ? capTop + ceilU(b.maxY - b.minY) + ceilU(mo.bottom) + MEASURE_PAD : 2 * MARGIN + 6;
2168
+ bankRowH = Math.max(bankRowH, capRowH);
731
2169
  // 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) {
2170
+ // another uniform row rather than running past the frame; and it
2171
+ // wraps BEFORE a rail whose caps would not all fit the row, so one
2172
+ // rail's caps stay on one trunk (PVDD's pair split across two rows
2173
+ // left the second cap with its own two symbols).
2174
+ const railStart = capRefs.indexOf(ref) === 0 || railOf(capRefs[capRefs.indexOf(ref) - 1]) !== railOf(ref);
2175
+ let railRunW = 0;
2176
+ if (railStart) {
2177
+ for (let k = capRefs.indexOf(ref); k < capRefs.length && railOf(capRefs[k]) === railOf(ref); k++) {
2178
+ const kb = bodyBoundsOf(rotatedSym(instByKey.get(capRefs[k]).sym, bankRotOf(capRefs[k])));
2179
+ const km = measured?.get(capRefs[k]);
2180
+ railRunW += km ? ceilU(km.left) + ceilU(kb.maxX - kb.minX) + ceilU(km.right) + 2 * MEASURE_PAD : ceilU(kb.maxX - kb.minX) + 2 * MARGIN;
2181
+ }
2182
+ }
2183
+ const needW = railStart && railRunW <= capBudget ? railRunW : capW;
2184
+ if (capX > groupX && capX + needW - groupX > capBudget) {
734
2185
  capX = groupX;
735
- capY += 2 * MARGIN + 6;
2186
+ capY += bankRowH;
2187
+ bankRowH = capRowH;
736
2188
  }
737
- const ox = grid(capX + MARGIN);
738
- const oy = grid(capY + MARGIN);
2189
+ const ox = mo ? grid(capX + capLeft - Math.floor(b.minX / U)) : grid(capX + MARGIN);
2190
+ const oy = mo ? grid(capY + capTop + Math.ceil(b.maxY / U)) : grid(capY + MARGIN);
739
2191
  placed.set(ref, {
740
2192
  part: inst.part,
741
2193
  refDes: inst.ref,
742
2194
  unit: inst.unit,
743
- sym: inst.sym,
2195
+ sym,
744
2196
  x: ox,
745
2197
  y: oy,
746
2198
  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,
2199
+ cellW: capW,
2200
+ cellH: capRowH,
2201
+ rot,
749
2202
  });
750
- capX += ceilU(b.maxX - b.minX) + 2 * MARGIN;
2203
+ capX += capW;
751
2204
  }
752
- groupMaxY = capY + 2 * MARGIN + 6;
2205
+ groupMaxY = capY + bankRowH;
753
2206
  }
754
2207
  // ---------- idiom micro-templates and the alignment pass (7.5/7.5a) ----------
755
2208
  // Column placement is correct but reads machine-made for the small
@@ -800,6 +2253,7 @@ export function draftSchematicPlacement(validated, projectName, today) {
800
2253
  body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
801
2254
  cellW: prev.cellW,
802
2255
  cellH: prev.cellH,
2256
+ rot: prev.rot,
803
2257
  };
804
2258
  };
805
2259
  /** Apply candidate moves unless any moved body lands within a grid unit of
@@ -852,8 +2306,10 @@ export function draftSchematicPlacement(validated, projectName, today) {
852
2306
  const end = { x: p.x + o.dx * len * U, y: p.y + o.dy * len * U };
853
2307
  if (!ownEps.has(ep)) {
854
2308
  for (const q of [p, end]) {
855
- if (sameCoord(q.x, axisX) && q.y > yMin - U && q.y < yMax + U)
2309
+ if (sameCoord(q.x, axisX) && q.y > yMin - U && q.y < yMax + U) {
2310
+ trace(`axis x=${axisX} blocked by ${ep} (${net.name}) at y=${q.y}`);
856
2311
  return false;
2312
+ }
857
2313
  }
858
2314
  }
859
2315
  // A horizontal stub SEGMENT crossing the axis exactly at a chain
@@ -871,8 +2327,10 @@ export function draftSchematicPlacement(validated, projectName, today) {
871
2327
  if (row && row.net !== net.name) {
872
2328
  const lo = Math.min(p.x, end.x) - 0.001;
873
2329
  const hi = Math.max(p.x, end.x) + 0.001;
874
- if (axisX >= lo && axisX <= hi)
2330
+ if (axisX >= lo && axisX <= hi) {
2331
+ trace(`axis x=${axisX} crossed by the stub of ${ep} (${net.name}) on a ${row.net} row`);
875
2332
  return false;
2333
+ }
876
2334
  }
877
2335
  }
878
2336
  }
@@ -880,7 +2338,7 @@ export function draftSchematicPlacement(validated, projectName, today) {
880
2338
  return true;
881
2339
  };
882
2340
  /** The room a rail/ground end grows past its pin: stub, bar, value text. */
883
- const POWER_CLEAR = 8 * U;
2341
+ const POWER_CLEAR = 5 * U;
884
2342
  const powerEndBox = (axisX, pinY, dir) => ({
885
2343
  minX: axisX - 3 * U,
886
2344
  maxX: axisX + 3 * U,
@@ -890,8 +2348,8 @@ export function draftSchematicPlacement(validated, projectName, today) {
890
2348
  /** Clearance-check a chain (or flank) move set against its axis and its
891
2349
  * power-end growth, then apply; marks the parts idiom-placed only when
892
2350
  * everything held. */
893
- const finalizeMoves = (segments, moves, clearBoxes = []) => {
894
- const own = ownEndpoints(moves.keys());
2351
+ const finalizeMoves = (segments, moves, clearBoxes = [], ownOverride) => {
2352
+ const own = ownOverride ?? ownEndpoints(moves.keys());
895
2353
  const movedRefs = new Set(moves.keys());
896
2354
  for (const seg of segments) {
897
2355
  // the pad covers the power stub and symbol a rail/ground end grows
@@ -905,8 +2363,10 @@ export function draftSchematicPlacement(validated, projectName, today) {
905
2363
  for (const [oref, op] of placed) {
906
2364
  if (moves.has(oref))
907
2365
  continue;
908
- if (padOverlap(box, op.body, 0))
2366
+ if (padOverlap(box, op.body, 0)) {
2367
+ trace(`power end at x=${(box.minX + box.maxX) / 2} would sit on ${oref}`);
909
2368
  return false;
2369
+ }
910
2370
  }
911
2371
  }
912
2372
  if (!applyMoves(moves))
@@ -956,10 +2416,224 @@ export function draftSchematicPlacement(validated, projectName, today) {
956
2416
  segments.push({ axisX, ys: [at.y, cand.y - v.bot.y] });
957
2417
  clearBoxes.push(powerEndBox(axisX, cand.y - v.bot.y, 1)); // the ground symbol below the cap
958
2418
  }
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);
2419
+ // all-or-nothing per crystal: one dropped cap and one column cap would
2420
+ // read worse than the plain columns the pass is improving on
2421
+ if (moves.size)
2422
+ finalizeMoves(segments, moves, clearBoxes);
2423
+ }
2424
+ // Pin-anchored hangs claimed above: each chain drops from its pin's row
2425
+ // into its shelf slot. Slot 0 sits past the IC's labels on that side,
2426
+ // so the wire pass draws pin, stub, a short branch and the chain's
2427
+ // stub as one local route; deeper slots stagger outward. A hang the
2428
+ // clearance check refuses goes to the leftover column at the group's
2429
+ // right edge, never on top of something.
2430
+ const leftovers = [];
2431
+ const placeInfill = () => {
2432
+ for (const [host, cells] of infill) {
2433
+ const g = hostGeom.get(host);
2434
+ const d = cellDims.get(host);
2435
+ if (!g)
2436
+ continue;
2437
+ for (const f of cells) {
2438
+ const cd = cellDims.get(f.key);
2439
+ const fb = cd.body;
2440
+ const x0 = f.side === 1 ? g.colX + g.colW - g.colShelfR + (d.reachR ?? 0) : g.colX + 1;
2441
+ const fcx = x0 + Math.floor(cd.w / 2);
2442
+ const fcy = g.rowY + f.relY + Math.floor(cd.h / 2);
2443
+ const fox = grid(fcx - Math.round((fb.minX + fb.maxX) / 2 / U));
2444
+ const foy = grid(fcy + Math.round((fb.minY + fb.maxY) / 2 / U));
2445
+ placed.set(f.key, placeCell(f.key, fox, foy));
2446
+ }
2447
+ }
2448
+ };
2449
+ const placeCell = (key, ox, oy, rot = 0, mirror) => {
2450
+ const inst = instByKey.get(key);
2451
+ const dims = cellDims.get(key);
2452
+ const sym = transformSym(inst.sym, rot, mirror);
2453
+ const b = bodyBoundsOf(sym);
2454
+ return {
2455
+ part: inst.part,
2456
+ refDes: inst.ref,
2457
+ unit: inst.unit,
2458
+ sym,
2459
+ x: ox,
2460
+ y: oy,
2461
+ body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
2462
+ cellW: dims.w,
2463
+ cellH: dims.h,
2464
+ rot,
2465
+ ...(mirror ? { mirror } : {}),
2466
+ };
2467
+ };
2468
+ placeInfill();
2469
+ // Inline runs: each part lies on its pin's row with its near lead
2470
+ // toward the IC, one gap past the stub, the next part one gap on. The
2471
+ // wire pass then draws pin, stub, gap and lead as one straight line.
2472
+ for (const il of inlines) {
2473
+ const icPl = placed.get(il.ic);
2474
+ if (!icPl) {
2475
+ leftovers.push(...il.chain.map((c) => c.key));
2476
+ continue;
2477
+ }
2478
+ const at = pinAt(icPl, il.pin);
2479
+ const s = { x: at.x + il.dx * STUB * U, y: at.y };
2480
+ const moves = new Map();
2481
+ let cursor = s.x + il.dx * il.offset; // x of the last connection point on the row
2482
+ let ok = true;
2483
+ for (const c of il.chain) {
2484
+ const ctl = controlPinOf(c.key);
2485
+ // a transistor keeps collector up and emitter down; it is mirrored,
2486
+ // never turned, so its base faces the part that drives it
2487
+ const mirror = ctl ? (outward(ctl).dx === -il.dx ? undefined : 'y') : undefined;
2488
+ const rot = ctl ? 0 : orientFor(c.key, c.nearPin, { dx: -il.dx, dy: 0 });
2489
+ if (rot === null) {
2490
+ ok = false;
2491
+ break;
2492
+ }
2493
+ cursor = cursor + il.dx * c.gap; // the next near lead
2494
+ const rs = transformSym(instByKey.get(c.key).sym, rot, mirror);
2495
+ const near = rs.pins.find((pn) => pn.number === c.nearPin);
2496
+ const ox = grid(Math.round((cursor - near.x) / U));
2497
+ const oy = grid(Math.round((s.y + near.y) / U));
2498
+ moves.set(c.key, placeCell(c.key, ox, oy, rot, mirror));
2499
+ cursor = cursor + il.dx * spanOf(c.key);
2500
+ }
2501
+ if (ok) {
2502
+ for (const [key, cand] of moves)
2503
+ placed.set(key, cand);
2504
+ // no axis to clear: the run's wires are the row itself, which the
2505
+ // wire pass checks segment by segment; bodies must still fit
2506
+ if (!finalizeMoves([], moves, [], new Set())) {
2507
+ for (const key of moves.keys())
2508
+ placed.delete(key);
2509
+ ok = false;
2510
+ }
2511
+ }
2512
+ if (!ok) {
2513
+ leftovers.push(...il.chain.map((c) => c.key));
2514
+ for (const c of il.chain)
2515
+ boundTo.delete(c.key);
2516
+ const refs = il.chain.map((c) => instByKey.get(c.key).ref).join(', ');
2517
+ const note = `inline refused: ${refs} could not lie on ${instByKey.get(il.ic).ref}.${il.pin.number}'s row in "${gname}"; drawn in a column instead`;
2518
+ trace(note);
2519
+ if (!notes.includes(note))
2520
+ notes.push(note);
2521
+ }
2522
+ }
2523
+ for (const hg of hangs) {
2524
+ const icPl = placed.get(hg.ic);
2525
+ if (!icPl) {
2526
+ leftovers.push(...hg.chain);
2527
+ continue;
2528
+ }
2529
+ // a node anchor is a hung part, placed turned: read its pin as drawn
2530
+ const pinNow = icPl.sym.pins.find((p) => p.number === hg.pin.number) ?? hg.pin;
2531
+ const at = pinAt(icPl, pinNow);
2532
+ const s = { x: at.x + hg.dx * STUB * U, y: at.y };
2533
+ const side = hangs.filter((o) => o.ic === hg.ic && o.dx === hg.dx);
2534
+ const pitch = Math.max(0, ...side.map((o) => o.w)) + U;
2535
+ const ext = labelExtents([hg.ic]);
2536
+ const reach = sideReach(hg.ic, hg.dx);
2537
+ void ext;
2538
+ const firstSlot = grid(Math.round((s.x + hg.dx * (reach + 2 * U + pitch / 2)) / U));
2539
+ const slotX = grid(Math.round((firstSlot + hg.dx * hg.slot * pitch) / U));
2540
+ trace(`hang ${hg.chain.map((k) => instByKey.get(k).ref).join('+')} on ${instByKey.get(hg.ic).ref}.${hg.pin.number}: stub (${s.x}, ${s.y}) reach ${reach.toFixed(2)} pitch ${pitch.toFixed(2)} slot ${hg.slot} -> x ${hg.straight ? s.x : slotX}${hg.straight ? ' (straight)' : ''}`);
2541
+ const netOfHang = (key, pinN) => netByEndpoint.get(`${instByKey.get(key).ref}.${pinN}`)?.name ?? '';
2542
+ let placedOk = false;
2543
+ // dead straight on the stub axis when the pin-neighbour test found
2544
+ // the rows free (AC-16.31), else the reserved shelf slot. Trying the
2545
+ // axis regardless passed the clearance check — a wire may legally
2546
+ // cross a neighbouring rail stub — and drew exactly that crossing
2547
+ // on the Tier C divider, twice.
2548
+ const axes = hg.straight ? [s.x] : [slotX];
2549
+ // Chains on one side start level: drops below the side's lowest hung
2550
+ // pin row, rises above its highest. Started at its own row, a chain
2551
+ // from one pin put its body across the next pin's row, and that
2552
+ // pin's run out to its lane hit the body (U1's FLT pull-up against
2553
+ // the OVLO divider). Level bases turn that into a wire crossing at
2554
+ // worst, and the resistors of a comb line up the way a drafter
2555
+ // lines them up. A straight hang keeps its own row.
2556
+ const sidePinYsAbs = side.map((o) => pinAt(icPl, icPl.sym.pins.find((p) => p.number === o.pin.number) ?? o.pin).y);
2557
+ const baseY = hg.straight ? s.y : hg.dir === 1 ? Math.max(...sidePinYsAbs) : Math.min(...sidePinYsAbs);
2558
+ for (const axisX of axes) {
2559
+ for (let lift = 0; lift < 3 && !placedOk; lift++) {
2560
+ // the near lead of the first part sits one gap from the base row,
2561
+ // and each further part one gap on, away from the IC
2562
+ let cursor = grid(Math.round((baseY + hg.dir * HANG_GAP) / U)) + hg.dir * lift * 2 * U;
2563
+ const moves = new Map();
2564
+ const conn = [{ y: s.y, net: netOfHang(hg.ic, hg.pin.number) }];
2565
+ const startY = cursor;
2566
+ const nearPins = hangNearPins.get(hg.chain.join('+'));
2567
+ for (const [i, key] of hg.chain.entries()) {
2568
+ // turned so the near lead faces the row (up for a hang that
2569
+ // drops, down for one that rises) and the far lead points away
2570
+ const rot = orientFor(key, nearPins[i], { dx: 0, dy: -hg.dir }) ?? 0;
2571
+ const rs = rotatedSym(instByKey.get(key).sym, rot);
2572
+ const near = rs.pins.find((pn) => pn.number === nearPins[i]);
2573
+ const farLead = rs.pins.find((pn) => pn.number !== nearPins[i]) ?? near;
2574
+ const span = spanOf(key, rot);
2575
+ moves.set(key, placeCell(key, axisX - near.x, cursor + near.y, rot));
2576
+ conn.push({ y: cursor, net: netOfHang(key, near.number) }, { y: cursor + hg.dir * span, net: netOfHang(key, farLead.number) });
2577
+ cursor = cursor + hg.dir * (span + HANG_GAP);
2578
+ }
2579
+ const endY = cursor - hg.dir * HANG_GAP;
2580
+ const clearBoxes = hg.ends === 'power' ? [powerEndBox(axisX, endY, hg.dir)] : [];
2581
+ const ys = [Math.min(startY, endY, s.y), Math.max(startY, endY, s.y)];
2582
+ // Only the nets that run ALONG the axis are the chain's own: the
2583
+ // anchor net and each link between chain parts. The rail or ground
2584
+ // at the far end is not — a pull-up's rail is also the IC's supply
2585
+ // pin, and counting it as own let an axis run straight through
2586
+ // that pin (esp32-amp's BUCK_EN divider on U4's PVDD pin; the
2587
+ // router refused the wire, so the net shipped labelled).
2588
+ const axisNets = new Set(conn.map((c) => c.net));
2589
+ const own = new Set();
2590
+ for (const net of intent.nets)
2591
+ if (axisNets.has(net.name))
2592
+ for (const ep of net.pins)
2593
+ own.add(ep);
2594
+ // the clearance check reads a chain's OWN connection points from the
2595
+ // placed map; hung parts have no column position, so they go in as
2596
+ // their candidates first and come out again if the check refuses
2597
+ for (const [key, cand] of moves)
2598
+ placed.set(key, cand);
2599
+ if (finalizeMoves([{ axisX, ys, conn }], moves, clearBoxes, own))
2600
+ placedOk = true;
2601
+ else
2602
+ for (const key of moves.keys())
2603
+ placed.delete(key);
2604
+ }
2605
+ if (placedOk)
2606
+ break;
2607
+ }
2608
+ if (!placedOk) {
2609
+ leftovers.push(...hg.chain);
2610
+ for (const k of hg.chain)
2611
+ boundTo.delete(k);
2612
+ const refs = hg.chain.map((k) => instByKey.get(k).ref).join(', ');
2613
+ const note = `hang refused: ${refs} could not be placed cleanly on ${instByKey.get(hg.ic).ref}.${hg.pin.number} in "${gname}"; drawn in a column instead`;
2614
+ trace(note);
2615
+ if (!notes.includes(note))
2616
+ notes.push(note);
2617
+ }
2618
+ }
2619
+ if (leftovers.length) {
2620
+ // a column of their own past everything the group placed so far
2621
+ let x = Math.max(groupX, ...[...placed.values()].filter((p) => groupOf.get([...placed.entries()].find(([, q]) => q === p)?.[0] ?? '') === gname).map((p) => Math.ceil(p.body.maxX / U) + MARGIN)) + CHANNEL;
2622
+ const colW = Math.max(...leftovers.map((k) => cellDims.get(k).w));
2623
+ let rowY = bandTop;
2624
+ for (const key of leftovers) {
2625
+ const dims = cellDims.get(key);
2626
+ const b = dims.body;
2627
+ const cx = x + Math.floor(colW / 2);
2628
+ const cy = rowY + Math.floor(dims.h / 2);
2629
+ const ox = grid(cx - Math.round((b.minX + b.maxX) / 2 / U));
2630
+ const oy = grid(cy + Math.round((b.minY + b.maxY) / 2 / U));
2631
+ placed.set(key, placeCell(key, ox, oy));
2632
+ rowY += dims.h + ROW_GAP;
2633
+ }
2634
+ groupMaxY = Math.max(groupMaxY, rowY - ROW_GAP);
2635
+ colX = x + colW + CHANNEL;
2636
+ void colX;
963
2637
  }
964
2638
  const walk = (start, dir, chain) => {
965
2639
  let current = start;
@@ -971,14 +2645,22 @@ export function draftSchematicPlacement(validated, projectName, today) {
971
2645
  return { kind: 'open' }; // declared no-connect or unused
972
2646
  if (classOf(net.name) !== 'signal')
973
2647
  return { kind: 'power' };
2648
+ // a tapped node is not a series link, but it is a fine END: the chain
2649
+ // stops there and the label that names the node sits on it (R18 over
2650
+ // Q1's drain, its other end on the four-way USB_EN)
974
2651
  if (net.pins.length !== 2)
975
- return { kind: 'invalid' }; // a tapped node is not a series chain
2652
+ return { kind: 'open' };
976
2653
  const otherEp = net.pins.map(parseEp).find((e) => e !== null && e.key !== current);
977
2654
  if (!otherEp)
978
2655
  return { kind: 'invalid' };
979
2656
  const opl = placed.get(otherEp.key);
980
- if (!opl || groupOf.get(otherEp.key) !== gname)
2657
+ if (!opl)
981
2658
  return { kind: 'invalid' };
2659
+ // the other end lives in another group: this end is open, and the
2660
+ // label that names the net across the sheet sits on it (an LED's
2661
+ // resistor driven from the MCU group stacks over its LED here)
2662
+ if (groupOf.get(otherEp.key) !== gname)
2663
+ return { kind: 'open' };
982
2664
  if (chainable(otherEp.key) && !chain.includes(otherEp.key)) {
983
2665
  const ov = vertPins(otherEp.key);
984
2666
  // the link must enter through the lead facing the chain, or the
@@ -1003,12 +2685,33 @@ export function draftSchematicPlacement(validated, projectName, today) {
1003
2685
  if (!chainable(m.key) || chained.has(m.key))
1004
2686
  continue;
1005
2687
  const chain = [m.key];
1006
- const topEnd = walk(m.key, 'up', chain);
1007
- const bottomEnd = walk(chain[chain.length - 1], 'down', chain);
2688
+ let topEnd = walk(m.key, 'up', chain);
2689
+ let bottomEnd = walk(chain[chain.length - 1], 'down', chain);
1008
2690
  for (const ref of chain)
1009
2691
  chained.add(ref);
2692
+ trace(`chain ${chain.map((k) => instByKey.get(k).ref).join('+')}: top ${topEnd.kind} bottom ${bottomEnd.kind}`);
1010
2693
  if (topEnd.kind === 'invalid' || bottomEnd.kind === 'invalid')
1011
2694
  continue;
2695
+ // The anchor sits BELOW the chain but meets its top lead (R18's pin 1
2696
+ // on Q1's drain, which faces up): turn every part half a turn so the
2697
+ // lead that meets the anchor is the bottom one, and stack upward.
2698
+ // The reverse case (an anchor above, met by a bottom lead) mirrors it.
2699
+ // A turned chain that is then abandoned below gets its column's
2700
+ // orientation back: `abandon` restores every part the turn replaced.
2701
+ const unturned = new Map();
2702
+ const flip = () => {
2703
+ for (const key of chain) {
2704
+ const pl = placed.get(key);
2705
+ unturned.set(key, pl);
2706
+ placed.set(key, placeCell(key, pl.x, pl.y, (pl.rot + 180) % 360, pl.mirror));
2707
+ }
2708
+ [topEnd, bottomEnd] = [bottomEnd, topEnd];
2709
+ chain.reverse();
2710
+ };
2711
+ const abandon = () => {
2712
+ for (const [key, pl] of unturned)
2713
+ placed.set(key, pl);
2714
+ };
1012
2715
  if (chain.length > 4)
1013
2716
  continue; // beyond four parts this is a network, not an idiom
1014
2717
  const anchors = [topEnd, bottomEnd].filter((e) => e.kind === 'anchor');
@@ -1016,6 +2719,10 @@ export function draftSchematicPlacement(validated, projectName, today) {
1016
2719
  continue; // a lone floating part has nothing to align to
1017
2720
  if (topEnd.kind === 'open' && bottomEnd.kind === 'open')
1018
2721
  continue;
2722
+ if (topEnd.kind === 'anchor' && outward(topEnd.pin).dy === -1 && bottomEnd.kind !== 'anchor')
2723
+ flip();
2724
+ else if (bottomEnd.kind === 'anchor' && outward(bottomEnd.pin).dy === 1 && topEnd.kind !== 'anchor')
2725
+ flip();
1019
2726
  const stubEndOf = (a) => {
1020
2727
  const at = pinAt(placed.get(a.ref), a.pin);
1021
2728
  const o = outward(a.pin);
@@ -1026,14 +2733,18 @@ export function draftSchematicPlacement(validated, projectName, today) {
1026
2733
  let order = chain;
1027
2734
  if (topEnd.kind === 'anchor') {
1028
2735
  const s = stubEndOf(topEnd);
1029
- if (s.o.dy === -1)
1030
- continue; // an up-facing pin cannot feed a downward run
2736
+ if (s.o.dy === -1) {
2737
+ abandon(); // an up-facing pin cannot feed a downward run
2738
+ continue;
2739
+ }
1031
2740
  if (bottomEnd.kind === 'anchor') {
1032
2741
  const b = stubEndOf(bottomEnd);
1033
2742
  // both ends must sit on one axis with the second anchor below and
1034
2743
  // 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)
2744
+ if (!sameCoord(s.x, b.x) || b.y <= s.y || b.o.dy === 1) {
2745
+ abandon();
1036
2746
  continue;
2747
+ }
1037
2748
  }
1038
2749
  axisX = s.x;
1039
2750
  cursor = s.y + CHAIN_GAP;
@@ -1041,14 +2752,17 @@ export function draftSchematicPlacement(validated, projectName, today) {
1041
2752
  else if (bottomEnd.kind === 'anchor') {
1042
2753
  // rail above, anchor below (a pull-up): stack upward from the anchor
1043
2754
  const s = stubEndOf(bottomEnd);
1044
- if (s.o.dy === 1)
1045
- continue; // a down-facing pin cannot feed an upward run
2755
+ if (s.o.dy === 1) {
2756
+ abandon(); // a down-facing pin cannot feed an upward run
2757
+ continue;
2758
+ }
1046
2759
  axisX = s.x;
1047
2760
  order = [...chain].reverse();
1048
2761
  // Bounded lift: when a connection row would sit on a foreign stub's
1049
2762
  // crossing (axisClear's segment check), raise the whole stack a grid
1050
2763
  // row at a time rather than shipping the contact or losing the idiom.
1051
2764
  const netOf = (key, pinN) => netByEndpoint.get(epOf(key, pinN))?.name ?? '';
2765
+ let lifted = false;
1052
2766
  for (let lift = 0; lift < 3; lift++) {
1053
2767
  let up = s.y - CHAIN_GAP - lift * 2 * U;
1054
2768
  const moves = new Map();
@@ -1064,9 +2778,13 @@ export function draftSchematicPlacement(validated, projectName, today) {
1064
2778
  const topY = up + CHAIN_GAP;
1065
2779
  const topRef = order[order.length - 1];
1066
2780
  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)
2781
+ if (done) {
2782
+ lifted = true;
1068
2783
  break;
2784
+ }
1069
2785
  }
2786
+ if (!lifted)
2787
+ abandon();
1070
2788
  continue;
1071
2789
  }
1072
2790
  else {
@@ -1079,6 +2797,7 @@ export function draftSchematicPlacement(validated, projectName, today) {
1079
2797
  }
1080
2798
  const cursor0 = cursor;
1081
2799
  const netOf2 = (key, pinN) => netByEndpoint.get(epOf(key, pinN))?.name ?? '';
2800
+ let stacked = false;
1082
2801
  for (let lift = 0; lift < 3; lift++) {
1083
2802
  cursor = cursor0 + lift * 2 * U;
1084
2803
  const moves = new Map();
@@ -1109,20 +2828,39 @@ export function draftSchematicPlacement(validated, projectName, today) {
1109
2828
  const endNet = conn.length ? conn[0].net : '';
1110
2829
  const startConn = { y: cursor0 - CHAIN_GAP, net: topEnd.kind === 'anchor' ? netOf2(topEnd.ref, topEnd.pin.number) : endNet };
1111
2830
  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))
2831
+ if (fits && finalizeMoves([{ axisX, ys: [cursor0 - CHAIN_GAP, axisEndY], conn: [startConn, ...conn, lastConn] }], moves, clearBoxes)) {
2832
+ stacked = true;
1113
2833
  break;
2834
+ }
1114
2835
  if (!fits)
1115
2836
  break; // lifting only shrinks the room below; no retry can help
1116
2837
  }
2838
+ if (!stacked)
2839
+ abandon();
1117
2840
  }
1118
2841
  const memberRefs = [...members.map((m) => m.key), ...capRefs];
1119
2842
  const cells = memberRefs.map((r) => placed.get(r));
1120
2843
  if (cells.length) {
1121
2844
  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;
2845
+ // a two-lead part carries its reference and value beside its body;
2846
+ // at the group's right edge that text is what the next group must
2847
+ // clear (the rails bank's "47uF/10V" ran under the amplifier's box)
2848
+ const fieldReach = (c) => c.sym.pins.length <= 2 ? Math.max(c.refDes.length, c.part.value.length) * TEXT_RESERVE * LABEL_HEIGHT + 1.27 : 0;
2849
+ const maxX = Math.max(...cells.map((c) => c.body.maxX + fieldReach(c))) + MARGIN * U;
2850
+ // the top inset holds the caption band (a bold caption at
2851
+ // CAPTION_SIZE, inset 2 mm) above the tallest thing a cell can carry
2852
+ // over its body: a rail symbol on an upward stub with its value text
2853
+ // over it, which with an IC at the top-left corner sat 4.3 mm under
2854
+ // the box edge at six units; CAPTION_BAND keeps the caption's
2855
+ // bottom edge clear of it
2856
+ const minY = Math.min(...cells.map((c) => c.body.minY)) - (MARGIN + CAPTION_BAND) * U;
1124
2857
  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 });
2858
+ // a measured round already knows how far the box will grow above
2859
+ // and below its cells (caption band, text, power symbols): the rect
2860
+ // carries it so every fit stacks boxes, not cells
2861
+ const reach = reachMeasured?.get(gname);
2862
+ groupRects.push({ name: gname, x1: minX, y1: minY - (reach?.top ?? 0), x2: maxX, y2: maxY + (reach?.bottom ?? 0) });
2863
+ trace(`group "${gname}": rect x ${minX.toFixed(1)}..${maxX.toFixed(1)} y ${minY.toFixed(1)}..${maxY.toFixed(1)} (${cells.length} cells)`);
1126
2864
  groupX = Math.round(maxX / U) + GROUP_GAP;
1127
2865
  prevGroup = gname;
1128
2866
  bandsOf.set(gname, bandCount);
@@ -1147,9 +2885,9 @@ export function draftSchematicPlacement(validated, projectName, today) {
1147
2885
  const hinted = paperHint ? PAPERS.find((p) => p.name === paperHint) : undefined;
1148
2886
  // A hint pins the width budget; otherwise try every sheet, smallest first.
1149
2887
  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;
2888
+ const gap = GROUP_GAP * U + wrapGap;
2889
+ const usableW = (p) => p.w - 2 * FRAME - frameSlack;
2890
+ const usableH = (p) => p.h - 2 * FRAME - TITLE_STRIP - frameSlack;
1153
2891
  /**
1154
2892
  * Shelf-wrap the group rects to a width budget; returns per-group offsets.
1155
2893
  *
@@ -1172,22 +2910,286 @@ export function draftSchematicPlacement(validated, projectName, today) {
1172
2910
  let rowLeftExt = leftExtOf(groupRects[0].name);
1173
2911
  let dyUnits = 0;
1174
2912
  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) {
2913
+ // Every box in a row shares the row's top. The single-row pass gives the
2914
+ // boxes different tops when one reserved room above its IC for a
2915
+ // pull-up (esp32-amp's rails box started 16 mm below its neighbours),
2916
+ // and a row height measured box by box then misses that offset, so the
2917
+ // next row landed on the tall box's bottom. Each box moves to the common
2918
+ // top; its height is then its own.
2919
+ const topY = Math.min(...groupRects.map((r) => r.y1));
2920
+ // Rows are consecutive runs of groups (reading order is kept), but WHICH
2921
+ // consecutive runs is chosen to minimise the stack's height, not by
2922
+ // filling each row until the next group no longer fits: first-fit paired
2923
+ // esp32-amp's two tallest groups with two short ones and stacked five
2924
+ // rows where four would do. A group wider than the whole budget still
2925
+ // starts its own row; it will overflow, and the caller rejects this paper
2926
+ // for it.
2927
+ const n = groupRects.length;
2928
+ const heightOf = (r) => r.y2 - r.y1;
2929
+ const runFits = (i, j) => i === j || groupRects[j].x2 - groupRects[i].x1 + leftExtOf(groupRects[i].name) + rightExtOf(groupRects[j].name) <= budgetW;
2930
+ const best = new Array(n + 1).fill(Infinity);
2931
+ const cut = new Array(n + 1).fill(0);
2932
+ best[0] = 0;
2933
+ for (let j = 1; j <= n; j++) {
2934
+ let rowMax = 0;
2935
+ for (let i = j; i >= 1; i--) {
2936
+ rowMax = Math.max(rowMax, heightOf(groupRects[i - 1]));
2937
+ if (!runFits(i - 1, j - 1))
2938
+ break;
2939
+ const h = best[i - 1] + rowMax + (i > 1 ? gap : 0);
2940
+ if (h < best[j]) {
2941
+ best[j] = h;
2942
+ cut[j] = i - 1;
2943
+ }
2944
+ }
2945
+ if (best[j] === Infinity) {
2946
+ // no run ending here fits: the group is its own (overflowing) row
2947
+ best[j] = best[j - 1] + heightOf(groupRects[j - 1]) + (j > 1 ? gap : 0);
2948
+ cut[j] = j - 1;
2949
+ }
2950
+ }
2951
+ const starts = [];
2952
+ for (let j = n; j > 0; j = cut[j])
2953
+ starts.unshift(cut[j]);
2954
+ const rowStart = new Set(starts);
2955
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') {
2956
+ const rows = [];
2957
+ for (let k = 0; k < starts.length; k++) {
2958
+ const a = starts[k];
2959
+ const b = k + 1 < starts.length ? starts[k + 1] : n;
2960
+ const members = groupRects.slice(a, b);
2961
+ rows.push(`[${members.map((r) => r.name.slice(0, 2).trim()).join('+')} h${Math.max(...members.map(heightOf)).toFixed(0)}]`);
2962
+ }
2963
+ trace(`wrap at ${budgetW.toFixed(0)}: rows ${rows.join(' ')} -> ${best[n].toFixed(0)} tall`);
2964
+ }
2965
+ for (const [idx, r] of groupRects.entries()) {
2966
+ if (idx > 0 && rowStart.has(idx)) {
1179
2967
  dyUnits += Math.ceil((rowH + gap) / U);
1180
2968
  rowOriginX = r.x1;
1181
2969
  rowLeftExt = leftExtOf(r.name);
1182
2970
  rowH = 0;
1183
2971
  }
1184
- deltas.push({ dx: grid(Math.round((originX - rowOriginX) / U)), dy: dyUnits * U });
1185
- rowH = Math.max(rowH, r.y2 - r.y1);
2972
+ const lift = grid(Math.round((topY - r.y1) / U));
2973
+ deltas.push({ dx: grid(Math.round((originX - rowOriginX) / U)), dy: dyUnits * U + lift });
2974
+ rowH = Math.max(rowH, r.y2 + lift - topY);
1186
2975
  }
2976
+ void rowLeftExt;
1187
2977
  const xs = groupRects.flatMap((r, i) => [r.x1 + deltas[i].dx, r.x2 + deltas[i].dx]);
1188
2978
  const ys = groupRects.flatMap((r, i) => [r.y1 + deltas[i].dy, r.y2 + deltas[i].dy]);
1189
2979
  return { deltas, w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
1190
2980
  };
2981
+ /**
2982
+ * The column-major counterpart of `wrapTo`: groups keep their declared
2983
+ * order but fill top-to-bottom, then left-to-right, the way a person lays
2984
+ * a newspaper page. Consecutive runs form columns no taller than `budgetH`;
2985
+ * a column is as wide as its widest group (label extents included) and the
2986
+ * breaks are chosen to minimise the total width. Groups of like width stack
2987
+ * well this way (the engagement sheet's three 250 mm groups in one column,
2988
+ * its three 205 mm groups in the next), which rows of mismatched heights
2989
+ * never achieve.
2990
+ */
2991
+ const wrapColumns = (budgetH) => {
2992
+ const originX = groupRects[0].x1;
2993
+ const topY = Math.min(...groupRects.map((r) => r.y1));
2994
+ const leftExtOf = (name) => groupExtents.get(name)?.left ?? 0;
2995
+ const rightExtOf = (name) => groupExtents.get(name)?.right ?? 0;
2996
+ const gap = GROUP_GAP * U + wrapGap;
2997
+ const n = groupRects.length;
2998
+ const hOf = (r) => r.y2 - r.y1;
2999
+ const wOf = (r) => r.x2 - r.x1 + leftExtOf(r.name) + rightExtOf(r.name);
3000
+ const runH = (i, j) => {
3001
+ let h = 0;
3002
+ for (let k = i; k <= j; k++)
3003
+ h += hOf(groupRects[k]) + (k > i ? gap : 0);
3004
+ return h;
3005
+ };
3006
+ const best = new Array(n + 1).fill(Infinity);
3007
+ const cut = new Array(n + 1).fill(0);
3008
+ best[0] = 0;
3009
+ for (let j = 1; j <= n; j++) {
3010
+ let colW = 0;
3011
+ for (let i = j; i >= 1; i--) {
3012
+ colW = Math.max(colW, wOf(groupRects[i - 1]));
3013
+ if (i < j && runH(i - 1, j - 1) > budgetH)
3014
+ break;
3015
+ const w = best[i - 1] + colW + (i > 1 ? gap : 0);
3016
+ if (w < best[j]) {
3017
+ best[j] = w;
3018
+ cut[j] = i - 1;
3019
+ }
3020
+ }
3021
+ }
3022
+ const starts = [];
3023
+ for (let j = n; j > 0; j = cut[j])
3024
+ starts.unshift(cut[j]);
3025
+ const deltas = [];
3026
+ let colOriginUnits = Math.round(originX / U);
3027
+ let maxH = 0;
3028
+ for (let k = 0; k < starts.length; k++) {
3029
+ const a = starts[k];
3030
+ const b = k + 1 < starts.length ? starts[k + 1] : n;
3031
+ const members = groupRects.slice(a, b);
3032
+ const colLeft = Math.max(...members.map((r) => leftExtOf(r.name)));
3033
+ let yUnits = Math.round(topY / U);
3034
+ for (const r of members) {
3035
+ deltas.push({ dx: (colOriginUnits + Math.ceil(colLeft / U)) * U - grid(Math.round(r.x1 / U)), dy: yUnits * U - grid(Math.round(r.y1 / U)) });
3036
+ yUnits += Math.ceil(hOf(r) / U) + GROUP_GAP + Math.ceil(wrapGap / U);
3037
+ }
3038
+ maxH = Math.max(maxH, (yUnits - GROUP_GAP) * U - topY);
3039
+ colOriginUnits += Math.ceil(Math.max(...members.map(wOf)) / U) + GROUP_GAP + Math.ceil(wrapGap / U);
3040
+ }
3041
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') {
3042
+ trace(`column wrap at ${budgetH.toFixed(0)}: ${starts.map((a, k) => `[${groupRects.slice(a, k + 1 < starts.length ? starts[k + 1] : n).map((r) => r.name.slice(0, 2).trim()).join('+')}]`).join(' ')} -> ${best[n].toFixed(0)} wide`);
3043
+ }
3044
+ return { deltas, w: best[n], h: maxH, kind: 'columns' };
3045
+ };
3046
+ /**
3047
+ * The grid a person lays when neither rows nor columns fit: k columns,
3048
+ * groups dealt to them in declared order (group i in column i mod k), each
3049
+ * column stacking its own groups from the top. Reading order runs along
3050
+ * the rows as before, but a short group no longer holds a whole row's
3051
+ * height open beside a tall one (the hand-laid A2 sheet: power, amplifier
3052
+ * and UI in one column, PD, MCU and UART in the next, rails in the third).
3053
+ */
3054
+ /** Masonry deals already searched in this draft, by budget and group sizes. */
3055
+ const masonryDeals = new Map();
3056
+ const wrapMasonry = (k, budgetH) => {
3057
+ const originX = groupRects[0].x1;
3058
+ const topY = Math.min(...groupRects.map((r) => r.y1));
3059
+ const leftExtOf = (name) => groupExtents.get(name)?.left ?? 0;
3060
+ const rightExtOf = (name) => groupExtents.get(name)?.right ?? 0;
3061
+ const n = groupRects.length;
3062
+ const wOf = (i) => groupRects[i].x2 - groupRects[i].x1 + leftExtOf(groupRects[i].name) + rightExtOf(groupRects[i].name);
3063
+ const hOf = (i) => groupRects[i].y2 - groupRects[i].y1;
3064
+ const gap = GROUP_GAP * U + wrapGap;
3065
+ // Every way of dealing n groups to k columns (order kept within a
3066
+ // column, columns in the order their first groups are declared) is
3067
+ // judged and the narrowest that fits the height wins; ties go to the
3068
+ // shorter. Seven groups in three columns is 301 deals, but twelve in four
3069
+ // is 611 501, and the fit asks for this at every budget of every sheet:
3070
+ // enumerating them all made a twelve-group board draft in tens of
3071
+ // seconds. So the deals are walked as a tree, a group at a time, and a
3072
+ // branch is cut as soon as a column is already too tall, or the columns
3073
+ // opened so far are already wider than the best deal found (or as wide
3074
+ // and as tall): no deal below it could replace the best. The walk visits
3075
+ // deals in the enumeration's order and cuts only branches that could not
3076
+ // win, so the deal it keeps is the one the full enumeration keeps. Past
3077
+ // a node budget the walk stops with the best deal so far, and deals
3078
+ // round-robin when it has found none. Shapes recur across the budgets
3079
+ // the fit tries, so a search is remembered by the groups' sizes.
3080
+ const sizesKey = `${k}|${budgetH}|${gap}|${groupRects.map((_, i) => `${wOf(i)}x${hOf(i)}`).join(',')}`;
3081
+ let cols = masonryDeals.get(sizesKey);
3082
+ if (cols === undefined) {
3083
+ let found = null;
3084
+ let bestW = Infinity;
3085
+ let bestH = Infinity;
3086
+ const judge = (assign) => {
3087
+ const cs = Array.from({ length: k }, () => []);
3088
+ assign.forEach((c, i) => cs[c].push(i));
3089
+ if (cs.some((c) => !c.length))
3090
+ return;
3091
+ const hs = cs.map((c) => c.reduce((h, i, m) => h + hOf(i) + (m ? gap : 0), 0));
3092
+ const maxH = Math.max(...hs);
3093
+ if (maxH > budgetH)
3094
+ return;
3095
+ const w = cs.reduce((acc, c) => acc + Math.max(...c.map(wOf)), 0) + (k - 1) * gap;
3096
+ if (w < bestW - 1e-6 || (Math.abs(w - bestW) <= 1e-6 && maxH < bestH)) {
3097
+ bestW = w;
3098
+ bestH = maxH;
3099
+ found = cs;
3100
+ }
3101
+ };
3102
+ // columns are opened in reading order: the first group starts the
3103
+ // first column, and a group may start a new column only when every
3104
+ // earlier one is open (a restricted-growth string), so the same deal
3105
+ // is never judged under k! column orders
3106
+ const assign = new Array(n).fill(0);
3107
+ const colH = new Array(k).fill(0);
3108
+ const colW = new Array(k).fill(0);
3109
+ const colN = new Array(k).fill(0);
3110
+ let nodes = 0;
3111
+ const grow = (i, open, widthSoFar, tallest) => {
3112
+ if (nodes > MASONRY_NODE_BUDGET)
3113
+ return;
3114
+ nodes++;
3115
+ if (n - i < k - open)
3116
+ return; // too few groups left to open every column
3117
+ if (i === n) {
3118
+ judge(assign);
3119
+ return;
3120
+ }
3121
+ for (let c = 0; c < Math.min(open + 1, k); c++) {
3122
+ const h = colH[c] + hOf(i) + (colN[c] ? gap : 0);
3123
+ if (h > budgetH)
3124
+ continue;
3125
+ const w = Math.max(colW[c], wOf(i));
3126
+ const lowerW = widthSoFar - colW[c] + w + (k - 1) * gap;
3127
+ const tall = Math.max(tallest, h);
3128
+ if (lowerW > bestW + 1e-6 || (lowerW >= bestW - 1e-6 && tall >= bestH))
3129
+ continue;
3130
+ const [h0, w0] = [colH[c], colW[c]];
3131
+ assign[i] = c;
3132
+ colH[c] = h;
3133
+ colW[c] = w;
3134
+ colN[c]++;
3135
+ grow(i + 1, Math.max(open, c + 1), lowerW - (k - 1) * gap, tall);
3136
+ colN[c]--;
3137
+ colH[c] = h0;
3138
+ colW[c] = w0;
3139
+ }
3140
+ };
3141
+ grow(0, 0, 0, 0);
3142
+ if (nodes > MASONRY_NODE_BUDGET) {
3143
+ trace(`masonry k=${k} at ${budgetH.toFixed(0)}: search stopped at ${MASONRY_NODE_BUDGET} nodes, ${found ? 'keeping the best deal so far' : 'dealing round-robin'}`);
3144
+ if (!found)
3145
+ judge(groupRects.map((_, i) => i % k));
3146
+ }
3147
+ cols = found;
3148
+ masonryDeals.set(sizesKey, cols);
3149
+ }
3150
+ if (!cols)
3151
+ return null;
3152
+ const colW = cols.map((c) => Math.max(0, ...c.map(wOf)));
3153
+ const colH = cols.map((c) => c.reduce((h, i, m) => h + hOf(i) + (m ? gap : 0), 0));
3154
+ const deltas = new Array(groupRects.length);
3155
+ let colOriginUnits = Math.round(originX / U);
3156
+ for (const [ci, c] of cols.entries()) {
3157
+ const colLeft = Math.max(0, ...c.map((i) => leftExtOf(groupRects[i].name)));
3158
+ let yUnits = Math.round(topY / U);
3159
+ for (const i of c) {
3160
+ const r = groupRects[i];
3161
+ deltas[i] = { dx: (colOriginUnits + Math.ceil(colLeft / U)) * U - grid(Math.round(r.x1 / U)), dy: yUnits * U - grid(Math.round(r.y1 / U)) };
3162
+ yUnits += Math.ceil((r.y2 - r.y1) / U) + GROUP_GAP + Math.ceil(wrapGap / U);
3163
+ }
3164
+ colOriginUnits += Math.ceil(colW[ci] / U) + (ci + 1 < k ? GROUP_GAP + Math.ceil(wrapGap / U) : 0);
3165
+ }
3166
+ const w = (colOriginUnits - Math.round(originX / U)) * U;
3167
+ trace(`masonry wrap k=${k} at ${budgetH.toFixed(0)}: ${cols.map((c) => `[${c.map((i) => groupRects[i].name.slice(0, 2).trim()).join('+')}]`).join(' ')} -> ${w.toFixed(0)} wide, ${Math.max(...colH).toFixed(0)} tall`);
3168
+ return { deltas, w, h: Math.max(...colH), kind: 'masonry' };
3169
+ };
3170
+ /**
3171
+ * Does wrapped content of `w` × `h` fit sheet `p`? The title strip is
3172
+ * reserved across the whole width by default; content taller than that
3173
+ * may still fit when the group boxes that reach into the strip stay clear
3174
+ * of the title block's own corner (a person lays the left-hand column down
3175
+ * to the frame and keeps the bottom-right for the block).
3176
+ */
3177
+ const fitsFrame = (p, w, h, rects) => {
3178
+ if (w > usableW(p))
3179
+ return { ok: false, intoStrip: false };
3180
+ if (h <= usableH(p))
3181
+ return { ok: true, intoStrip: false };
3182
+ if (h > usableH(p) + TITLE_STRIP - 4 * U)
3183
+ return { ok: false, intoStrip: false };
3184
+ // final placement: centred in width, top-aligned (the frame plus four units) in height
3185
+ const minX = Math.min(...rects.map((r) => r.x1));
3186
+ const minY = Math.min(...rects.map((r) => r.y1));
3187
+ const x0 = FRAME + Math.max(0, (usableW(p) - w) / 2) - minX;
3188
+ const y0 = FRAME + 4 * U - minY;
3189
+ const corner = { minX: p.w - FRAME - TITLE_BLOCK_W, minY: p.h - FRAME - TITLE_STRIP, maxX: p.w - FRAME, maxY: p.h - FRAME };
3190
+ const clear = rects.every((r) => !(r.x1 + x0 < corner.maxX && r.x2 + x0 > corner.minX && r.y1 + y0 < corner.maxY && r.y2 + y0 > corner.minY));
3191
+ return { ok: clear, intoStrip: clear };
3192
+ };
1191
3193
  /**
1192
3194
  * Whether the current placement fits sheet `p`, with the group shelf-wrap
1193
3195
  * deltas that make it fit. `wrap` is null when there is nothing to reflow:
@@ -1196,8 +3198,29 @@ export function draftSchematicPlacement(validated, projectName, today) {
1196
3198
  */
1197
3199
  const fitsOn = (p) => {
1198
3200
  if (groupRects.length > 1) {
3201
+ const wrappedRects = (d) => groupRects.map((r, i) => ({ x1: r.x1 + d[i].dx, y1: r.y1 + d[i].dy, x2: r.x2 + d[i].dx, y2: r.y2 + d[i].dy }));
1199
3202
  const w = wrapTo(usableW(p));
1200
- return w.w <= usableW(p) && w.h <= usableH(p) ? { paper: p, wrap: w } : null;
3203
+ const fw = fitsFrame(p, w.w, w.h, wrappedRects(w.deltas));
3204
+ if (fw.ok)
3205
+ return { paper: p, wrap: { ...w, kind: 'rows' }, intoStrip: fw.intoStrip };
3206
+ // rows of mismatched heights may miss where columns of like widths fit
3207
+ for (const budgetH of [usableH(p), usableH(p) + TITLE_STRIP - 4 * U]) {
3208
+ const c = wrapColumns(budgetH);
3209
+ const fc = fitsFrame(p, c.w, c.h, wrappedRects(c.deltas));
3210
+ if (fc.ok)
3211
+ return { paper: p, wrap: c, intoStrip: fc.intoStrip };
3212
+ }
3213
+ for (const budgetH of [usableH(p), usableH(p) + TITLE_STRIP - 4 * U]) {
3214
+ for (let k = 2; k <= Math.min(4, groupRects.length - 1); k++) {
3215
+ const m = wrapMasonry(k, budgetH);
3216
+ if (!m)
3217
+ continue;
3218
+ const fm = fitsFrame(p, m.w, m.h, wrappedRects(m.deltas));
3219
+ if (fm.ok)
3220
+ return { paper: p, wrap: m, intoStrip: fm.intoStrip };
3221
+ }
3222
+ }
3223
+ return null;
1201
3224
  }
1202
3225
  const r = groupRects[0];
1203
3226
  return !r || (r.x2 - r.x1 <= usableW(p) && r.y2 - r.y1 <= usableH(p)) ? { paper: p, wrap: null } : null;
@@ -1219,15 +3242,36 @@ export function draftSchematicPlacement(validated, projectName, today) {
1219
3242
  * spread the same cells into more side-by-side columns, so walk the height
1220
3243
  * fractions until the content matches the sheet's aspect or nothing fits.
1221
3244
  */
3245
+ /** Closest miss per sheet a budgeted attempt could not fit, for the notes. */
3246
+ const budgetMisses = [];
1222
3247
  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));
3248
+ let best = null;
3249
+ // finer fractions re-row a tall group's columns until the groups are
3250
+ // short enough for two or three rows of them to stack on the sheet
3251
+ for (const [frac, div] of [1, 0.7, 0.5, 0.4, 0.35, 0.3, 0.25].flatMap((fr) => [1, 2, 2.5, 3].map((d) => [fr, d]))) {
3252
+ // band budgets of a half and a third of the sheet fold a wide flat
3253
+ // group (five button blocks in a row) toward the width of a column
3254
+ const b = placeAllGroups(Math.floor(usableW(p) / div / U), Math.floor((usableH(p) * frac) / U));
1225
3255
  const f = fitsOn(p);
3256
+ if (groupRects.length > 1) {
3257
+ const w = wrapTo(usableW(p));
3258
+ trace(`try ${p.name} frac ${frac} div ${div}: wrapped ${w.w.toFixed(0)}x${w.h.toFixed(0)} vs ${usableW(p)}x${usableH(p)}; ${groupRects.map((r) => `${r.name.slice(0, 12)}=${(r.x2 - r.x1).toFixed(0)}x${(r.y2 - r.y1).toFixed(0)}`).join(' ')}`);
3259
+ }
1226
3260
  if (f)
1227
3261
  return { fit: f, bands: b };
3262
+ // remember the closest miss so a failed pass can say how far off it was
3263
+ if (groupRects.length) {
3264
+ const w = groupRects.length > 1 ? wrapTo(usableW(p)) : { w: groupRects[0].x2 - groupRects[0].x1, h: groupRects[0].y2 - groupRects[0].y1 };
3265
+ const over = Math.max(w.w - usableW(p), w.h - usableH(p));
3266
+ if (!best || over < Math.max(best.w - usableW(p), best.h - usableH(p)))
3267
+ best = { w: w.w, h: w.h };
3268
+ }
1228
3269
  }
3270
+ if (best)
3271
+ budgetMisses.push(`${p.name} ${Math.round(best.w)}×${Math.round(best.h)} mm vs ${usableW(p)}×${usableH(p)} usable`);
1229
3272
  return null;
1230
3273
  };
3274
+ let compaction = hinted ? 'pinned' : 'not-needed';
1231
3275
  let bands = placeAllGroups(Infinity);
1232
3276
  let fit = bestFit();
1233
3277
  if (!fit) {
@@ -1244,10 +3288,12 @@ export function draftSchematicPlacement(validated, projectName, today) {
1244
3288
  if (t) {
1245
3289
  bands = t.bands;
1246
3290
  fit = t.fit;
3291
+ compaction = 'banded';
1247
3292
  break;
1248
3293
  }
1249
3294
  }
1250
3295
  if (!fit) {
3296
+ compaction = 'overflow';
1251
3297
  const largest = candidates[candidates.length - 1];
1252
3298
  bands = placeAllGroups(Math.floor(usableW(largest) / U), Math.floor(usableH(largest) / U));
1253
3299
  fit = { paper: largest, wrap: groupRects.length > 1 ? wrapTo(usableW(largest)) : null };
@@ -1288,6 +3334,7 @@ export function draftSchematicPlacement(validated, projectName, today) {
1288
3334
  }
1289
3335
  }
1290
3336
  if (compacted) {
3337
+ compaction = 'compacted';
1291
3338
  bands = compactedBands;
1292
3339
  fit = compacted;
1293
3340
  notes.push(`sheet compacted: the natural layout fit ${naturalPaper.name} at ${Math.round(naturalUtil * 100)}% utilization; reflowed onto ${fit.paper.name}`);
@@ -1297,8 +3344,10 @@ export function draftSchematicPlacement(validated, projectName, today) {
1297
3344
  }
1298
3345
  }
1299
3346
  else {
1300
- // nothing smaller holds the reflowed content: restore the natural
1301
- // placement byte for byte
3347
+ // nothing smaller holds the reflowed content: say so, with how far
3348
+ // each sheet missed, then restore the natural placement byte for byte
3349
+ compaction = 'failed';
3350
+ notes.push(`sheet not compacted: the natural layout fits ${naturalPaper.name} at ${Math.round(naturalUtil * 100)}% utilization, but no smaller sheet holds the reflowed content (${budgetMisses.join('; ')})`);
1302
3351
  bands = placeAllGroups(Infinity);
1303
3352
  fit = bestFit();
1304
3353
  }
@@ -1306,9 +3355,21 @@ export function draftSchematicPlacement(validated, projectName, today) {
1306
3355
  }
1307
3356
  if (fit.wrap) {
1308
3357
  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`);
3358
+ if (wrap.kind === 'columns') {
3359
+ const cols = new Set(wrap.deltas.map((d, i) => Math.round((groupRects[i].x1 + d.dx) / U))).size;
3360
+ notes.push(`groups wrapped onto ${cols} columns to fit the sheet (declared order runs down each column)`);
3361
+ }
3362
+ else if (wrap.kind === 'masonry') {
3363
+ const cols = new Set(wrap.deltas.map((d, i) => Math.round((groupRects[i].x1 + d.dx) / U))).size;
3364
+ notes.push(`groups laid in ${cols} columns to fit the sheet (declared order runs along the rows; each column stacks its own groups)`);
3365
+ }
3366
+ else {
3367
+ // rows, like columns above, are counted by where their boxes land, not
3368
+ // by the shift each took to get there (every group starts elsewhere)
3369
+ const rows = new Set(wrap.deltas.map((d, i) => Math.round((groupRects[i].y1 + d.dy) / U))).size;
3370
+ if (rows > 1)
3371
+ notes.push(`groups wrapped onto ${rows} rows to fit the sheet`);
3372
+ }
1312
3373
  groupRects.forEach((r, i) => {
1313
3374
  const d = wrap.deltas[i];
1314
3375
  if (!d.dx && !d.dy)
@@ -1555,7 +3616,8 @@ export function draftSchematicPlacement(validated, projectName, today) {
1555
3616
  const joinClear = (prev, c) => {
1556
3617
  const y = c.ep.at.y + c.o.dy * STUB * U;
1557
3618
  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 &&
3619
+ return (groupOf.get(prev.ep.ref) === groupOf.get(c.ep.ref) &&
3620
+ c.ep.at.x - prev.ep.at.x <= BANK_PITCH_MAX * U &&
1559
3621
  !powerBodies.some((b) => segCrossesBody(seg.x1, seg.y1, seg.x2, seg.y2, b)) &&
1560
3622
  !touchesForeign([seg], net.name, ownEps, { predictStubs: true }) &&
1561
3623
  !crossesForeignStubLine(seg));
@@ -1595,10 +3657,15 @@ export function draftSchematicPlacement(validated, projectName, today) {
1595
3657
  if (len > maxLen)
1596
3658
  len = maxLen;
1597
3659
  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
- });
3660
+ // A vertical stub carries its name beyond the bar, in line. A HORIZONTAL
3661
+ // stub used to put the name 3.556 mm above (rails) or below (grounds)
3662
+ // the bar: 1.4 pin rows off, so on any IC with pins one row apart the
3663
+ // rail name of pin 5 sat on the label of pin 4 (esp32-amp's eFuse,
3664
+ // VBUS_FUSED over EFUSE_USB_FLT). The name now continues outward along
3665
+ // the row, past the bar, where the row is its own.
3666
+ const valueAtOf = (end) => o.dy !== 0
3667
+ ? { x: end.x, y: end.y + o.dy * 3.556 }
3668
+ : { x: end.x + o.dx * (Math.max(1, net.name.length) * LABEL_ADVANCE * LABEL_HEIGHT / 2 + 1.905), y: end.y };
1602
3669
  // Adjacent power pins collide their value texts two ways, resolved two
1603
3670
  // ways. The SAME net repeated (a TQFP's VCC pins one row apart) hides
1604
3671
  // the duplicates: one visible name per cluster carries the same
@@ -1762,27 +3829,25 @@ export function draftSchematicPlacement(validated, projectName, today) {
1762
3829
  const o = outward(ep.pin);
1763
3830
  return { ep, end: { x: ep.at.x + o.dx * STUB * U, y: ep.at.y + o.dy * STUB * U }, o };
1764
3831
  });
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);
3832
+ const routeStubs = (subset) => {
3833
+ const xs = subset.map((s) => s.end.x).sort((a, b) => a - b);
3834
+ const ys = subset.map((s) => s.end.y);
3835
+ const admit = (candidate) => {
3836
+ if (candidate.some((c) => bodies.some((b) => segCrossesBody(c.x1, c.y1, c.x2, c.y2, b))))
3837
+ return false;
3838
+ return !touchesForeign(candidate, net.name, new Set(net.pins));
3839
+ };
3840
+ const found = [];
3841
+ // A vertical trunk with horizontal branches, at the median stub x,
3842
+ // right of everything, left of everything.
1777
3843
  const trunkCandidates = [
1778
3844
  grid(Math.round(xs[Math.floor(xs.length / 2)] / U)),
1779
3845
  grid(Math.round(xs[xs.length - 1] / U) + STUB),
1780
3846
  grid(Math.round(xs[0] / U) - STUB),
1781
3847
  ];
1782
- let routed = false;
1783
3848
  for (const trunkX of trunkCandidates) {
1784
3849
  const candidate = [];
1785
- for (const s of stubs) {
3850
+ for (const s of subset) {
1786
3851
  candidate.push({ x1: s.ep.at.x, y1: s.ep.at.y, x2: s.end.x, y2: s.end.y });
1787
3852
  if (!sameCoord(s.end.x, trunkX))
1788
3853
  candidate.push({ x1: s.end.x, y1: s.end.y, x2: trunkX, y2: s.end.y });
@@ -1793,74 +3858,269 @@ export function draftSchematicPlacement(validated, projectName, today) {
1793
3858
  for (let i = 1; i < meetYs.length; i++) {
1794
3859
  candidate.push({ x1: trunkX, y1: meetYs[i - 1], x2: trunkX, y2: meetYs[i] });
1795
3860
  }
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 };
3861
+ if (admit(candidate))
3862
+ found.push({ segs: candidate, trunkX });
3863
+ }
3864
+ // A comb: the trunk lies ALONG one endpoint's row the IC pin's row
3865
+ // when the net has one and every other stub rises or drops to it.
3866
+ // This is how a pin with parts hung on it is drawn: the row runs out to
3867
+ // the axis, the chain above and the chain below meet it in one T. The
3868
+ // vertical trunk alone could not route a pull-up and a pull-down on one
3869
+ // pin (its branch to the second part ran along a foreign row), so one
3870
+ // of them shipped labelled.
3871
+ const rows = [...new Map(subset.map((s) => [knum(s.end.y), s])).values()].sort((a, b) => {
3872
+ const ica = (placed.get(a.ep.ref)?.sym.pins.length ?? 0) >= 3 ? 0 : 1;
3873
+ const icb = (placed.get(b.ep.ref)?.sym.pins.length ?? 0) >= 3 ? 0 : 1;
3874
+ return ica - icb || a.end.y - b.end.y;
3875
+ });
3876
+ for (const rowStub of rows) {
3877
+ const trunkY = rowStub.end.y;
3878
+ const candidate = [];
3879
+ for (const s of subset) {
3880
+ candidate.push({ x1: s.ep.at.x, y1: s.ep.at.y, x2: s.end.x, y2: s.end.y });
3881
+ if (!sameCoord(s.end.y, trunkY))
3882
+ candidate.push({ x1: s.end.x, y1: s.end.y, x2: s.end.x, y2: trunkY });
3883
+ }
3884
+ const meetXs = [...new Map(xs.map((x) => [knum(x), x])).values()].sort((a, b) => a - b);
3885
+ for (let i = 1; i < meetXs.length; i++) {
3886
+ candidate.push({ x1: meetXs[i - 1], y1: trunkY, x2: meetXs[i], y2: trunkY });
3887
+ }
3888
+ if (admit(candidate))
3889
+ found.push({ segs: candidate, trunkX: meetXs[0], trunkY });
3890
+ }
3891
+ // A hook, for two stubs where one lies on a row and the other is a
3892
+ // lead below or above it on an axis the row would have to cross: run
3893
+ // the row past the lead by two units, turn to the lead's level, and
3894
+ // come back to its stub end. This is how a bootstrap cap's far lead is
3895
+ // reached from the row it bridges to (the other lead sits on the same
3896
+ // axis, in the way of a straight drop).
3897
+ if (!found.length && subset.length === 2) {
3898
+ for (const [a, b] of [[subset[0], subset[1]], [subset[1], subset[0]]]) {
3899
+ if (a.o.dx === 0 || b.o.dy === 0)
3900
+ continue; // a: a sideways pin (the row); b: a vertical lead
3901
+ for (const past of [2 * U, -2 * U]) {
3902
+ const turnX = grid(Math.round((b.end.x + past) / U));
3903
+ const candidate = [
3904
+ { x1: a.ep.at.x, y1: a.ep.at.y, x2: a.end.x, y2: a.end.y },
3905
+ { x1: a.end.x, y1: a.end.y, x2: turnX, y2: a.end.y },
3906
+ { x1: turnX, y1: a.end.y, x2: turnX, y2: b.end.y },
3907
+ { x1: turnX, y1: b.end.y, x2: b.end.x, y2: b.end.y },
3908
+ { x1: b.ep.at.x, y1: b.ep.at.y, x2: b.end.x, y2: b.end.y },
3909
+ ].filter((c) => !(sameCoord(c.x1, c.x2) && sameCoord(c.y1, c.y2)));
3910
+ if (admit(candidate)) {
3911
+ found.push({ segs: candidate, trunkX: turnX });
3912
+ break;
3913
+ }
3914
+ }
3915
+ if (found.length)
3916
+ break;
3917
+ }
3918
+ }
3919
+ if (!found.length) {
3920
+ trace(`route ${net.name}: no clean route for ${subset.map((s) => `${s.ep.ref}.${s.ep.pin.number}`).join(', ')}`);
3921
+ return null;
3922
+ }
3923
+ // the fewest segments is the fewest bends; ties keep the classic order
3924
+ return found.reduce((best, r) => (r.segs.length < best.segs.length ? r : best), found[0]);
3925
+ };
3926
+ /** Labels naming this net's wired runs, promoted to flags below if the net also leaves through a stub. */
3927
+ const netWired = [];
3928
+ /** Draw a routed subset: its wires, one label naming the net, and junction dots. */
3929
+ const commitRoute = (subset, r) => {
3930
+ for (const c of r.segs)
3931
+ addWire(net.name, c.x1, c.y1, c.x2, c.y2);
3932
+ // one label names the wired run (topmost-leftmost wire point): the net
3933
+ // stays identifiable to PINOUT/drift and to a reviewer without a
3934
+ // label-per-pin, matching hand-drafting practice
3935
+ const pts = r.segs.flatMap((c) => [
3936
+ { x: c.x1, y: c.y1 },
3937
+ { x: c.x2, y: c.y2 },
3938
+ ]);
3939
+ pts.sort((a, b) => a.y - b.y || a.x - b.x);
3940
+ // The name stands above the wire from its anchor rightward. A trunk's
3941
+ // top is the classic place; a run that is one horizontal row (a part
3942
+ // on its pin's row) has no trunk, and its left end is the pin, so the
3943
+ // name sits over the stub and the gap the row left for it. Whichever
3944
+ // candidate clears every body wins; the run's top-left point is the
3945
+ // last resort.
3946
+ const vertTops = r.segs.filter((c) => sameCoord(c.x1, c.x2)).map((c) => ({ x: c.x1, y: Math.min(c.y1, c.y2) }));
3947
+ const candidates = [...vertTops.sort((a, b) => a.y - b.y || a.x - b.x), pts[0]];
3948
+ const clearOfBodies = (pt) => {
3949
+ const b = labelTextBox(net.name, pt.x, pt.y, 0);
3950
+ if ([...placed.values()].some((pl) => boundsOverlap(b, pl.body)))
3951
+ return false;
3952
+ return !labels.some((o) => o.name !== net.name && boundsOverlap(padBox(b), labelTextBox(o.name, o.x, o.y, o.rot)));
3953
+ };
3954
+ const at = candidates.find(clearOfBodies) ?? pts[0];
3955
+ labels.push({ name: net.name, x: at.x, y: at.y, rot: 0, kind: 'local' });
3956
+ netWired.push(labels.length - 1);
3957
+ wiredLabels.push({ label: labels.length - 1, pts, segs: r.segs.map((c) => ({ ...c })) });
3958
+ wired++;
3959
+ if (subset.length > 2) {
3960
+ if (r.trunkY !== undefined) {
3961
+ // comb: a branch meeting the row trunk strictly inside its span is a T
3962
+ const xs = subset.map((s) => s.end.x);
3963
+ for (const s of subset) {
3964
+ const meet = sameCoord(s.end.y, r.trunkY) ? s.end : { x: s.end.x, y: r.trunkY };
3965
+ if (meet.x > Math.min(...xs) + 0.01 && meet.x < Math.max(...xs) - 0.01)
3966
+ junctions.push(meet);
3967
+ }
3968
+ }
3969
+ else {
3970
+ const ys = subset.map((s) => s.end.y);
3971
+ for (const s of subset) {
3972
+ const meet = sameCoord(s.end.x, r.trunkX) ? s.end : { x: r.trunkX, y: s.end.y };
1820
3973
  if (meet.y > Math.min(...ys) && meet.y < Math.max(...ys))
1821
3974
  junctions.push(meet);
1822
3975
  }
1823
3976
  }
1824
- routed = true;
1825
- break;
1826
3977
  }
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
- }
3978
+ };
3979
+ // Clusters: endpoints in one group whose stub ends sit within wire span of
3980
+ // one another, in x order. A net that stays in one group and fits the
3981
+ // span is one cluster — the classic case, drawn as before. A net that
3982
+ // also leaves the group, or runs longer, still gets each local run wired
3983
+ // and labelled once: adjacency before labelling (#220 phase 3), so a part
3984
+ // hung on a pin or lying on its row is wired to it whatever the rest of
3985
+ // the net does. Lone endpoints keep a stub and a label.
3986
+ const sorted = [...stubs].sort((a, b) => a.end.x - b.end.x || a.end.y - b.end.y);
3987
+ const clusters = [];
3988
+ const anchorKey = (ref) => {
3989
+ let k = ref;
3990
+ for (let i = 0; i < 4 && boundTo.has(k); i++)
3991
+ k = boundTo.get(k);
3992
+ return k;
3993
+ };
3994
+ const near = (a, b) => (Math.abs(a.end.x - b.end.x) <= MAX_WIRE_SPAN && Math.abs(a.end.y - b.end.y) <= MAX_WIRE_SPAN) ||
3995
+ // a part hung on a pin or lying on its row joins that pin's cluster
3996
+ // however far its lane sits: the fourth lane of a comb is past the
3997
+ // wire span, and it is still that pin's part
3998
+ anchorKey(a.ep.ref) === anchorKey(b.ep.ref);
3999
+ for (const s of sorted) {
4000
+ const home = clusters.find((cl) => groupOf.get(cl[0].ep.ref) === groupOf.get(s.ep.ref) && cl.every((o) => near(o, s)));
4001
+ if (home)
4002
+ home.push(s);
4003
+ else
4004
+ clusters.push([s]);
4005
+ }
4006
+ const wiredStubs = new Set();
4007
+ for (const found of clusters) {
4008
+ // A cluster past the wired-net size is a pin with its hung parts plus
4009
+ // whatever else sits within span: it is narrowed to the largest set of
4010
+ // endpoints bound to one anchor, so the parts hung on a pin are wired
4011
+ // to it however many there are, and the rest keep stubs and labels.
4012
+ let cl = found;
4013
+ if (cl.length > MAX_WIRED_ENDPOINTS) {
4014
+ const byAnchor = new Map();
4015
+ for (const s of cl)
4016
+ byAnchor.set(anchorKey(s.ep.ref), [...(byAnchor.get(anchorKey(s.ep.ref)) ?? []), s]);
4017
+ cl = [...byAnchor.values()].reduce((a, b) => (b.length > a.length ? b : a));
4018
+ trace(`route ${net.name}: cluster of ${found.length} narrowed to ${cl.length} bound to ${anchorKey(cl[0].ep.ref)}`);
4019
+ }
4020
+ if (cl.length < 2)
4021
+ continue;
4022
+ // the whole cluster, else the largest subset that routes: a run on the
4023
+ // IC pin's row still draws as a wire when a third endpoint's branch
4024
+ // cannot be cleared, and only that endpoint keeps a stub label. Subsets
4025
+ // are tried largest first in index order, at most ROUTE_ATTEMPTS.
4026
+ let attempts = 0;
4027
+ let done = false;
4028
+ const cur = [];
4029
+ const pick = (start, size) => {
4030
+ if (done || attempts >= ROUTE_ATTEMPTS)
4031
+ return;
4032
+ if (cur.length === size) {
4033
+ attempts++;
4034
+ const r = routeStubs(cur);
4035
+ if (!r)
4036
+ return;
4037
+ const sub = [...cur];
4038
+ commitRoute(sub, r);
4039
+ for (const s of sub)
4040
+ wiredStubs.add(s);
4041
+ done = true;
4042
+ return;
4043
+ }
4044
+ for (let i = start; i <= cl.length - (size - cur.length) && !done; i++) {
4045
+ cur.push(cl[i]);
4046
+ pick(i + 1, size);
4047
+ cur.pop();
4048
+ }
4049
+ };
4050
+ for (let size = cl.length; size >= 2 && !done && attempts < ROUTE_ATTEMPTS; size--)
4051
+ pick(0, size);
4052
+ if (!done && attempts >= ROUTE_ATTEMPTS)
4053
+ trace(`route ${net.name}: ${ROUTE_ATTEMPTS} subsets of ${cl.length} tried, none routed`);
4054
+ }
4055
+ for (const s of stubs) {
4056
+ if (wiredStubs.has(s))
4057
+ continue;
4058
+ // A stub is a wire too: its endpoint resting on a foreign net's wire
4059
+ // or connection point merges nets exactly like a trunk would (the
4060
+ // cap-to-ground drop's 2-unit stub ended on the neighbouring power
4061
+ // pin's stub interior — I22's third face). Grow the stub a grid unit
4062
+ // at a time until the endpoint is clear; the interior then CROSSES
4063
+ // the foreign wire mid-segment, which does not connect. If no length
4064
+ // clears, emit the plain stub and let the merged-net gate refuse
4065
+ // loudly rather than ship the contact.
4066
+ let end = s.end;
4067
+ const own = new Set(net.pins);
4068
+ // Rungs in preference order: the classic 0..2 extensions first so
4069
+ // clear cases stay byte-identical, then deeper extensions, then a
4070
+ // one-unit retreat. A stub that ships with NO clear rung still ends
4071
+ // touching a foreign wire and the merge gate refuses the draft, so
4072
+ // every extra rung here is a board that drafts instead of refusing
4073
+ // (jetson's twelve wire-contact refusals were exactly this fallback).
4074
+ for (const len of [STUB, STUB + 1, STUB + 2, STUB + 3, STUB + 4, STUB + 5, STUB + 6, 1]) {
4075
+ const cand = {
4076
+ x: s.ep.at.x + s.o.dx * len * U,
4077
+ y: s.ep.at.y + s.o.dy * len * U,
4078
+ };
4079
+ if (!touchesForeign([{ x1: s.ep.at.x, y1: s.ep.at.y, x2: cand.x, y2: cand.y }], net.name, own)) {
4080
+ end = cand;
4081
+ break;
1857
4082
  }
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++;
4083
+ }
4084
+ addWire(net.name, s.ep.at.x, s.ep.at.y, end.x, end.y);
4085
+ // a stub ends in a global flag that continues the stub's direction,
4086
+ // the way a person hangs a flag on a wire end: a leftward stub reads
4087
+ // leftward, an upward one reads upward; the shape says what the pin is
4088
+ labels.push({ name: net.name, x: end.x, y: end.y, rot: rotOutward(s.o), kind: 'global', shape: flagShape(s.ep.pin.etype) });
4089
+ stubbedLabels.push({ label: labels.length - 1, wire: wires.length - 1, o: s.o, pins: net.pins });
4090
+ labelled++;
4091
+ }
4092
+ // A net that also leaves its run through a stub is named by global flags
4093
+ // there, and a local label of the same name would not connect to them in
4094
+ // KiCad: the run's own name becomes a flag too, re-anchored where a flag
4095
+ // fits (a local name stands over its wire; a flag has a body of its own).
4096
+ // A net wired whole keeps its plain local name on the run.
4097
+ if (netWired.length && stubs.some((s) => !wiredStubs.has(s))) {
4098
+ for (const idx of netWired) {
4099
+ const lb = labels[idx];
4100
+ const rec = wiredLabels.find((w) => w.label === idx);
4101
+ lb.kind = 'global';
4102
+ lb.shape = 'passive';
4103
+ const clear = (c) => ![...placed.values()].some((pl) => boundsOverlap(labelTextBox(lb.name, c.x, c.y, c.rot, 'global'), pl.body));
4104
+ const at = wiredFlagCandidates(rec.segs).find(clear) ?? { x: lb.x, y: lb.y, rot: 90 };
4105
+ trace(`label ${lb.name}: run name becomes a flag at (${at.x}, ${at.y}, ${at.rot})`);
4106
+ lb.x = at.x;
4107
+ lb.y = at.y;
4108
+ lb.rot = at.rot;
4109
+ }
4110
+ }
4111
+ }
4112
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') {
4113
+ // every crossing of two nets' wires, for the trace: a comb whose lanes
4114
+ // cross another pin's row is legal, but a drafter wants to know where
4115
+ const H = wires.filter((w) => sameCoord(w.y1, w.y2));
4116
+ const V = wires.filter((w) => sameCoord(w.x1, w.x2));
4117
+ for (const h of H) {
4118
+ for (const v of V) {
4119
+ if (h.net === v.net)
4120
+ continue;
4121
+ const hx1 = Math.min(h.x1, h.x2), hx2 = Math.max(h.x1, h.x2), vy1 = Math.min(v.y1, v.y2), vy2 = Math.max(v.y1, v.y2);
4122
+ if (v.x1 > hx1 + 0.01 && v.x1 < hx2 - 0.01 && h.y1 > vy1 + 0.01 && h.y1 < vy2 - 0.01)
4123
+ trace(`crossing ${h.net} (row y=${h.y1}) x ${v.net} (x=${v.x1})`);
1864
4124
  }
1865
4125
  }
1866
4126
  }
@@ -1876,11 +4136,39 @@ export function draftSchematicPlacement(validated, projectName, today) {
1876
4136
  /** What KiCad renders for the reference: a multi-unit instance shows its
1877
4137
  * unit letter (U1A, U1B), so width metrics must measure the rendered text. */
1878
4138
  const displayRefOf = (pl) => pl.unit !== null ? `${pl.refDes}${String.fromCharCode(64 + Math.min(pl.unit, 26))}` : pl.refDes;
4139
+ /**
4140
+ * Width of the body interior left free by the pin NAMES drawn inside it on
4141
+ * the rows a ref/value pair at `cy` would occupy. A large module's names
4142
+ * (ESP32-WROVER's "MTMS/GPIO14/ADC2_CH6") run most of the way across, and a
4143
+ * value dropped on the centre line read as one word with them.
4144
+ */
4145
+ const interiorSpan = (pl, cy) => {
4146
+ let leftW = 0;
4147
+ let rightW = 0;
4148
+ for (const pin of pl.sym.pins) {
4149
+ const o = outward(pin);
4150
+ if (o.dx === 0)
4151
+ continue;
4152
+ const py = pinAt(pl, pin).y;
4153
+ if (Math.abs(py - cy) > 2.54 + 0.7)
4154
+ continue;
4155
+ const w = Math.max(0, pin.name.length) * NAME_ADVANCE * LABEL_HEIGHT;
4156
+ if (o.dx === -1)
4157
+ leftW = Math.max(leftW, w);
4158
+ else
4159
+ rightW = Math.max(rightW, w);
4160
+ }
4161
+ return { left: pl.body.minX + leftW, right: pl.body.maxX - rightW };
4162
+ };
4163
+ /** A library symbol that draws text of its own inside the body (ESP32-WROVER
4164
+ * prints its family name across the middle) has no free interior. */
4165
+ const hasGraphicText = (pl) => /\(text\s/.test(pl.sym.sourceText ?? '');
4166
+ const pinSidesOf = (pl) => new Set(pl.sym.pins.map((p) => {
4167
+ const o = outward(p);
4168
+ return o.dx === -1 ? 'left' : o.dx === 1 ? 'right' : o.dy === -1 ? 'top' : 'bottom';
4169
+ }));
1879
4170
  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
- }));
4171
+ const pinSides = pinSidesOf(pl);
1884
4172
  const cy = (pl.body.minY + pl.body.maxY) / 2;
1885
4173
  const cx = (pl.body.minX + pl.body.maxX) / 2;
1886
4174
  const textW = Math.max(displayRefOf(pl).length, pl.part.value.length) * 0.8 * 1.27;
@@ -1892,14 +4180,21 @@ export function draftSchematicPlacement(validated, projectName, today) {
1892
4180
  // below the body otherwise
1893
4181
  valueAt = pinSides.has('bottom') ? { x: cx, y: pl.body.minY - 5.08 } : { x: cx, y: pl.body.maxY + 2.54 };
1894
4182
  }
1895
- else if (pl.body.maxX - pl.body.minX >= textW + 2.54 &&
1896
- pl.body.maxY - pl.body.minY >= 7.62) {
4183
+ else if (pl.body.maxY - pl.body.minY >= 7.62 &&
4184
+ !hasGraphicText(pl) &&
4185
+ // the name at its real advance, centred in the interval the pin names
4186
+ // leave free, with two text heights of air each side: measured from
4187
+ // the body centre instead, ESP32-WROVER's value printed across the
4188
+ // ends of its long left-hand pin names
4189
+ interiorSpan(pl, cy).right - interiorSpan(pl, cy).left >= (textW * NAME_ADVANCE) / LABEL_ADVANCE + 4 * 2.54) {
1897
4190
  // pins on top AND a body big enough to hold its own name: a TQFP-class
1898
4191
  // part carries pins on all four sides, so every outside slot lands on
1899
4192
  // some pin's stub or label; the body interior is the one guaranteed-free
1900
4193
  // 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 };
4194
+ const span = interiorSpan(pl, cy);
4195
+ const mid = grid(Math.round((span.left + span.right) / 2 / U));
4196
+ refAt = { x: mid, y: cy - 1.27 };
4197
+ valueAt = { x: mid, y: cy + 1.27 };
1903
4198
  }
1904
4199
  else {
1905
4200
  refAt = { x: pl.body.maxX + textW / 2 + 1.27, y: cy - 1.27 };
@@ -1910,7 +4205,8 @@ export function draftSchematicPlacement(validated, projectName, today) {
1910
4205
  libId: pl.sym.libId,
1911
4206
  value: pl.part.value,
1912
4207
  footprint: pl.part.footprint ?? '',
1913
- at: { x: pl.x, y: pl.y, rot: 0 },
4208
+ at: { x: pl.x, y: pl.y, rot: pl.rot },
4209
+ ...(pl.mirror ? { mirror: pl.mirror } : {}),
1914
4210
  refAt,
1915
4211
  valueAt,
1916
4212
  pinNumbers: pl.sym.pins.map((p) => p.number),
@@ -1944,6 +4240,8 @@ export function draftSchematicPlacement(validated, projectName, today) {
1944
4240
  const fieldBoxes = extraSymbols
1945
4241
  .filter((s) => !s.hideValue)
1946
4242
  .map((s) => centeredTextBox(s.value, s.valueAt.x, s.valueAt.y));
4243
+ const powerBodies = extraSymbols.map((s) => ({ minX: s.at.x - 2 * U, minY: s.at.y - 2 * U, maxX: s.at.x + 2 * U, maxY: s.at.y + 2 * U }));
4244
+ const labelBoxes = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
1947
4245
  for (const { sym, pl } of emitPairs) {
1948
4246
  const dref = displayRefOf(pl);
1949
4247
  const cx = (pl.body.minX + pl.body.maxX) / 2;
@@ -1957,15 +4255,54 @@ export function draftSchematicPlacement(validated, projectName, today) {
1957
4255
  if (op !== pl && boundsOverlap(b, op.body))
1958
4256
  return false;
1959
4257
  }
1960
- if (fieldBoxes.some((t) => boundsOverlap(t, b)))
4258
+ // a rail bar or ground symbol is a drawn body too, not just its
4259
+ // value text (R23's reference printed through PVDD's bar)
4260
+ if (powerBodies.some((pb) => boundsOverlap(b, pb)))
4261
+ return false;
4262
+ // the part's own pins: the pin line and its number, from the pin
4263
+ // end to the body edge (U8's name printed over its no-connect pins,
4264
+ // which draw no wire for the wire check to see)
4265
+ for (const pin of pl.sym.pins) {
4266
+ const p = pinAt(pl, pin);
4267
+ const o = outward(pin);
4268
+ const band = o.dx !== 0
4269
+ ? { minX: Math.min(p.x, o.dx === 1 ? pl.body.maxX : pl.body.minX), maxX: Math.max(p.x, o.dx === 1 ? pl.body.maxX : pl.body.minX), minY: p.y - 1.5, maxY: p.y + 1.5 }
4270
+ : { minX: p.x - 1.5, maxX: p.x + 1.5, minY: Math.min(p.y, o.dy === 1 ? pl.body.maxY : pl.body.minY), maxY: Math.max(p.y, o.dy === 1 ? pl.body.maxY : pl.body.minY) };
4271
+ if (boundsOverlap(b, band))
4272
+ return false;
4273
+ }
4274
+ if (fieldBoxes.some((t) => boundsOverlap(t, padBox(b))))
4275
+ return false;
4276
+ // net labels are on the sheet by now; a slot on top of one is no slot
4277
+ if (labelBoxes.some((t) => boundsOverlap(t, padBox(b))))
1961
4278
  return false;
1962
4279
  }
1963
4280
  return true;
1964
4281
  };
1965
4282
  if (!pairClear(sym.refAt, sym.valueAt)) {
1966
4283
  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 }], [
4284
+ // corner slots: a part with a stub on its top or bottom centre (a
4285
+ // module's rail pin) still has its top-left and top-right free
4286
+ const xl = pl.body.minX + textW / 2 + 1.27;
4287
+ const xr = pl.body.maxX - textW / 2 - 1.27;
4288
+ // A part lying on a row (leads left and right) has rows one pitch
4289
+ // above and below it; its texts fit BETWEEN the rows, half a pitch
4290
+ // out from the body, wherever the neighbouring row carries no wire
4291
+ // there. Reference above and value below first, then both on one
4292
+ // side, then side by side on one line.
4293
+ if (pinSidesOf(pl).has('left') && pinSidesOf(pl).has('right') && !pinSidesOf(pl).has('top')) {
4294
+ const hh = 1.27;
4295
+ // Both texts on ONE side first, and side by side on one line before
4296
+ // stacked: two parts on rows two pitches apart then keep their texts
4297
+ // in their own half of the gap (C27's value and L3's reference met
4298
+ // in the middle of the five millimetres between their rows).
4299
+ ladder.push([{ x: cx, y: pl.body.minY - hh - 2.54 }, { x: cx, y: pl.body.minY - hh }], [{ x: cx - textW / 2 - 0.635, y: pl.body.minY - hh }, { x: cx + textW / 2 + 0.635, y: pl.body.minY - hh }], [{ x: cx, y: pl.body.maxY + hh }, { x: cx, y: pl.body.maxY + hh + 2.54 }], [{ x: cx - textW / 2 - 0.635, y: pl.body.maxY + hh }, { x: cx + textW / 2 + 0.635, y: pl.body.maxY + hh }], [{ x: cx, y: pl.body.minY - hh }, { x: cx, y: pl.body.maxY + hh }]);
4300
+ }
4301
+ // rungs reach past a rail symbol and its name on a top or bottom
4302
+ // stub (about 6 mm out), so a module pinned on all four sides still
4303
+ // finds a slot above or below itself instead of on its own labels
4304
+ for (const extra of [0, 2.54, 5.08, 7.62, 10.16]) {
4305
+ 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 }], [{ x: xl, y: pl.body.minY - 5.08 - extra }, { x: xl, y: pl.body.minY - 2.54 - extra }], [{ x: xr, y: pl.body.minY - 5.08 - extra }, { x: xr, y: pl.body.minY - 2.54 - extra }], [{ x: xl, y: pl.body.maxY + 2.54 + extra }, { x: xl, y: pl.body.maxY + 5.08 + extra }], [{ x: xr, y: pl.body.maxY + 2.54 + extra }, { x: xr, y: pl.body.maxY + 5.08 + extra }], [
1969
4306
  { x: pl.body.maxX + textW / 2 + 1.27 + extra, y: cy - 1.27 },
1970
4307
  { x: pl.body.maxX + textW / 2 + 1.27 + extra, y: cy + 1.27 },
1971
4308
  ], [
@@ -1973,13 +4310,17 @@ export function draftSchematicPlacement(validated, projectName, today) {
1973
4310
  { x: pl.body.minX - textW / 2 - 1.27 - extra, y: cy + 1.27 },
1974
4311
  ]);
1975
4312
  }
4313
+ let slotted = false;
1976
4314
  for (const [r, v] of ladder) {
1977
4315
  if (pairClear(r, v)) {
1978
4316
  sym.refAt = r;
1979
4317
  sym.valueAt = v;
4318
+ slotted = true;
1980
4319
  break;
1981
4320
  }
1982
4321
  }
4322
+ if (!slotted)
4323
+ trace(`fields of ${dref}: no clear slot in ${ladder.length} rungs; heuristic kept`);
1983
4324
  }
1984
4325
  fieldBoxes.push(centeredTextBox(dref, sym.refAt.x, sym.refAt.y), centeredTextBox(sym.value, sym.valueAt.x, sym.valueAt.y));
1985
4326
  }
@@ -2015,24 +4356,95 @@ export function draftSchematicPlacement(validated, projectName, today) {
2015
4356
  // are the label's own attachment and never count as collisions.
2016
4357
  for (const rec of wiredLabels) {
2017
4358
  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)))
4359
+ const clearWired = (x, y, rot, pad = TEXT_PAD) => {
4360
+ const box = labelTextBox(lb.name, x, y, rot, lb.kind);
4361
+ const padBox = (b) => ({ minX: b.minX - pad, minY: b.minY - pad, maxX: b.maxX + pad, maxY: b.maxY + pad });
4362
+ const blocked = (why) => {
4363
+ trace(`label ${lb.name} at (${x}, ${y}, ${rot}) blocked by ${why}`);
2023
4364
  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)));
4365
+ };
4366
+ // A wire through the anchor is the label's own attachment — for a
4367
+ // plain label, whose text stands above the line. A flag is drawn
4368
+ // CENTRED on its anchor line, so a wire continuing under its text
4369
+ // runs straight through the flag; only the wire on the pole side
4370
+ // (behind the flag's tip) is its attachment.
4371
+ const attached = (w) => {
4372
+ if (!segContains(w, x, y))
4373
+ return false;
4374
+ if (lb.kind !== 'global')
4375
+ return true;
4376
+ const r = ((rot % 360) + 360) % 360;
4377
+ if (r === 0)
4378
+ return Math.max(w.x1, w.x2) <= x + 0.01;
4379
+ if (r === 180)
4380
+ return Math.min(w.x1, w.x2) >= x - 0.01;
4381
+ if (r === 90)
4382
+ return Math.min(w.y1, w.y2) >= y - 0.01;
4383
+ return Math.max(w.y1, w.y2) <= y + 0.01;
4384
+ };
4385
+ const hitBody = [...placed.entries()].find(([, pl]) => boundsOverlap(box, pl.body));
4386
+ if (hitBody)
4387
+ return blocked(`the body of ${hitBody[0]} (${JSON.stringify(hitBody[1].body)})`);
4388
+ if (textObstacles.some((b) => boundsOverlap(padBox(box), b)))
4389
+ return blocked('field text');
4390
+ if (wires.some((w) => !attached(w) && segHitsBox(w, box)))
4391
+ return blocked('a wire');
4392
+ return !labels.some((o, i) => i !== rec.label && o.name !== lb.name && boundsOverlap(padBox(box), labelTextBox(o.name, o.x, o.y, o.rot, o.kind)));
2027
4393
  };
2028
- if (clearWired(lb.x, lb.y))
4394
+ if (clearWired(lb.x, lb.y, lb.rot))
2029
4395
  continue;
2030
- const alt = rec.pts.find((p) => clearWired(p.x, p.y));
4396
+ trace(`label ${lb.name}: (${lb.x}, ${lb.y}, ${lb.rot}) not clear, searching`);
4397
+ // A flag may turn as well as move — its candidates each carry a rotation
4398
+ // — and prefers a trunk top; a local name stays horizontal and walks the
4399
+ // run's points in anchor-preference order.
4400
+ if (lb.kind === 'global') {
4401
+ // a run's name reads along the row (the drafting standard keeps text
4402
+ // horizontal); a flag that could only stand up leaves the net to
4403
+ // local labels below
4404
+ const alt = wiredFlagCandidates(rec.segs)
4405
+ .filter((c) => c.rot === 0 || c.rot === 180)
4406
+ .find((c) => clearWired(c.x, c.y, c.rot));
4407
+ if (alt) {
4408
+ lb.x = alt.x;
4409
+ lb.y = alt.y;
4410
+ lb.rot = alt.rot;
4411
+ continue;
4412
+ }
4413
+ // A flag that fits nowhere on its run (a part on a pin row of a
4414
+ // 2.54 mm-pitch IC has no free end and no room for a flag standing up
4415
+ // between the rows) leaves the net named by plain labels instead:
4416
+ // every flag of this net becomes a local label, which KiCad connects
4417
+ // across the sheet just the same, and the run's name goes back to
4418
+ // standing on its wire.
4419
+ for (const o of labels) {
4420
+ if (o.name !== lb.name)
4421
+ continue;
4422
+ o.kind = 'local';
4423
+ delete o.shape;
4424
+ if (o.rot === 90 || o.rot === 270)
4425
+ o.rot = 0; // plain text reads along the row
4426
+ }
4427
+ lb.rot = 0;
4428
+ trace(`label ${lb.name}: no flag position clears; the net keeps local labels`);
4429
+ }
4430
+ const alt = rec.pts.find((p) => clearWired(p.x, p.y, 0));
2031
4431
  if (alt) {
4432
+ trace(`label ${lb.name} moved along its run (${lb.x}, ${lb.y}) -> (${alt.x}, ${alt.y})`);
2032
4433
  lb.x = alt.x;
2033
4434
  lb.y = alt.y;
2034
4435
  continue;
2035
4436
  }
4437
+ // reading leftward from a point on the run is the same text on the same
4438
+ // wire; a run whose right-hand end is a part's body (a resistor on the
4439
+ // row) often clears only that way
4440
+ const altLeft = [...rec.pts, ...interiorGridPoints(rec.segs, U)].find((p) => clearWired(p.x, p.y, 180));
4441
+ if (altLeft) {
4442
+ trace(`label ${lb.name} reads leftward from (${altLeft.x}, ${altLeft.y})`);
4443
+ lb.x = altLeft.x;
4444
+ lb.y = altLeft.y;
4445
+ lb.rot = 180;
4446
+ continue;
4447
+ }
2036
4448
  // No segment endpoint clears, but the label may sit at ANY point of its
2037
4449
  // own net's wires: walk the interior grid points of each segment too
2038
4450
  // (#220 phase 2). Endpoints stay the first choice so a run that used to
@@ -2045,20 +4457,39 @@ export function draftSchematicPlacement(validated, projectName, today) {
2045
4457
  // one the IR gave it. Three corpus nets shipped exactly that way —
2046
4458
  // Net-(F201-Pad1), Net-(U8-BIN) and Net-(U8-RIN), each anchored on no
2047
4459
  // wire of its own run.
2048
- const inner = interiorGridPoints(rec.segs, U).find((p) => clearWired(p.x, p.y));
4460
+ const inner = interiorGridPoints(rec.segs, U).find((p) => clearWired(p.x, p.y, 0));
2049
4461
  if (inner) {
4462
+ trace(`label ${lb.name} moved to an interior point (${inner.x}, ${inner.y})`);
2050
4463
  lb.x = inner.x;
2051
4464
  lb.y = inner.y;
4465
+ continue;
4466
+ }
4467
+ // Last resort before leaving the name on a body: the full pad cannot be
4468
+ // had on a 2.54 mm pin row under a flag (a flag's half height plus a
4469
+ // standing label's height already fill the pitch), so accept a quarter
4470
+ // pad at the first point that clears everything else.
4471
+ // (A flag on the row above already reaches a full height below its line,
4472
+ // so against it even a quarter pad is too much; touching boxes are not a
4473
+ // collision to the checker, and are the last rung.)
4474
+ const tightCandidates = [...rec.pts.map((p) => ({ ...p, rot: 0 })), ...interiorGridPoints(rec.segs, U).map((p) => ({ ...p, rot: 0 })), ...rec.pts.map((p) => ({ ...p, rot: 180 }))];
4475
+ const tight = tightCandidates.find((c) => clearWired(c.x, c.y, c.rot, TEXT_PAD / 4)) ?? tightCandidates.find((c) => clearWired(c.x, c.y, c.rot, 0));
4476
+ if (tight) {
4477
+ trace(`label ${lb.name} placed at (${tight.x}, ${tight.y}, ${tight.rot}) with a reduced pad`);
4478
+ lb.x = tight.x;
4479
+ lb.y = tight.y;
4480
+ lb.rot = tight.rot;
4481
+ continue;
2052
4482
  }
4483
+ trace(`label ${lb.name}: no clear point on its run; left at (${lb.x}, ${lb.y})`);
2053
4484
  }
2054
4485
  for (const rec of stubbedLabels) {
2055
4486
  const lb = labels[rec.label];
2056
4487
  const stub = wires[rec.wire];
2057
4488
  const clearAt = (x, y, rot = lb.rot) => {
2058
- const box = labelTextBox(lb.name, x, y, rot);
4489
+ const box = labelTextBox(lb.name, x, y, rot, lb.kind);
2059
4490
  if (bodies.some((b) => boundsOverlap(box, b)))
2060
4491
  return false;
2061
- if (textObstacles.some((b) => boundsOverlap(box, b)))
4492
+ if (textObstacles.some((b) => boundsOverlap(padBox(box), b)))
2062
4493
  return false;
2063
4494
  if (wires.some((w, i) => i !== rec.wire && segHitsBox(w, box)))
2064
4495
  return false;
@@ -2070,9 +4501,10 @@ export function draftSchematicPlacement(validated, projectName, today) {
2070
4501
  // coordinates. Read live from `labels`, so already-nudged neighbours are
2071
4502
  // seen at their final positions and immovable wired-net labels (which
2072
4503
  // 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)));
4504
+ // A label of the SAME net is still text: two BUCK_EN labels one over
4505
+ // the other read as one smudge and the checker flags them (a hung
4506
+ // part's stub label rode down onto the IC pin's).
4507
+ return !labels.some((o, i) => i !== rec.label && boundsOverlap(padBox(box), labelTextBox(o.name, o.x, o.y, o.rot, o.kind)));
2076
4508
  };
2077
4509
  /**
2078
4510
  * Does another net's label sit on exactly this point? That is the fatal
@@ -2096,11 +4528,24 @@ export function draftSchematicPlacement(validated, projectName, today) {
2096
4528
  predictStubs: false,
2097
4529
  });
2098
4530
  const rideTo = (x, y) => {
4531
+ if (!sameCoord(x, lb.x) || !sameCoord(y, lb.y))
4532
+ trace(`label ${lb.name}: rides its stub from (${lb.x}, ${lb.y}) to (${x}, ${y})`);
2099
4533
  stub.x2 = x;
2100
4534
  stub.y2 = y;
2101
4535
  lb.x = x;
2102
4536
  lb.y = y;
2103
4537
  };
4538
+ // A flag on a vertical stub reads along the row when nothing stands
4539
+ // there (the drafting standard keeps text horizontal; the checker names
4540
+ // every rotated label a horizontal one would have fitted).
4541
+ if (lb.kind === 'global' && rec.o.dy !== 0 && (lb.rot === 90 || lb.rot === 270)) {
4542
+ const flat = [0, 180].find((r) => clearAt(lb.x, lb.y, r) && !mergesAt(lb.x, lb.y));
4543
+ if (flat !== undefined) {
4544
+ lb.rot = flat;
4545
+ continue;
4546
+ }
4547
+ trace(`flag ${lb.name} at (${lb.x}, ${lb.y}) stays vertical: no horizontal draw clears`);
4548
+ }
2104
4549
  if (clearAt(lb.x, lb.y))
2105
4550
  continue;
2106
4551
  /** Candidate points along the stub, nearest first. */
@@ -2139,6 +4584,7 @@ export function draftSchematicPlacement(validated, projectName, today) {
2139
4584
  !mergesAt(c.x, c.y) &&
2140
4585
  (sameCoord(c.x, lb.x) && sameCoord(c.y, lb.y) ? true : wireClearAt(c.x, c.y)));
2141
4586
  if (flip) {
4587
+ trace(`label ${lb.name}: turns to rotation ${flipRot} to clear`);
2142
4588
  lb.rot = flipRot;
2143
4589
  rideTo(flip.x, flip.y);
2144
4590
  continue;
@@ -2171,7 +4617,7 @@ export function draftSchematicPlacement(validated, projectName, today) {
2171
4617
  // measure as clean. Nothing clear keeps the placed slot so the report stays
2172
4618
  // honest, and a value that is already clean does not move at all.
2173
4619
  {
2174
- const labelBoxesFinal = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot));
4620
+ const labelBoxesFinal = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
2175
4621
  const memberText = emitPairs.flatMap(({ sym: s, pl }) => [
2176
4622
  centeredTextBox(displayRefOf(pl), s.refAt.x, s.refAt.y),
2177
4623
  centeredTextBox(s.value, s.valueAt.x, s.valueAt.y),
@@ -2180,9 +4626,9 @@ export function draftSchematicPlacement(validated, projectName, today) {
2180
4626
  const liveBoxes = new Map(valueEntries.map((s) => [s, powerValueBox(s.value, s.valueAt.x, s.valueAt.y)]));
2181
4627
  const clearFor = (self, b) => !bodies.some((bd) => boundsOverlap(b, bd)) &&
2182
4628
  !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));
4629
+ !labelBoxesFinal.some((lb) => boundsOverlap(lb, padBox(b))) &&
4630
+ !memberText.some((t) => boundsOverlap(t, padBox(b))) &&
4631
+ ![...liveBoxes].some(([o, ob]) => o !== self && boundsOverlap(ob, padBox(b)));
2186
4632
  for (const s of valueEntries) {
2187
4633
  if (clearFor(s, liveBoxes.get(s)))
2188
4634
  continue;
@@ -2232,6 +4678,470 @@ export function draftSchematicPlacement(validated, projectName, today) {
2232
4678
  }
2233
4679
  }
2234
4680
  // ---------- sheet: content-derived paper, balanced placement ----------
4681
+ // ---------- group boxes enclose their text ----------
4682
+ // what the wrap was fitted on, to measure how far the text grew each box
4683
+ const rectsBeforeText = groupRects.map((r) => ({ name: r.name, x1: r.x1, x2: r.x2, y1: r.y1, y2: r.y2 }));
4684
+ const reachOut = new Map();
4685
+ // A box drawn from bodies plus a fixed margin cuts through the text its
4686
+ // parts carry: with the IC in the leftmost column, its left-facing pin
4687
+ // labels ran out through the box edge (EN, BTN_PLAY, SPK_L+ on esp32-amp),
4688
+ // and a rail name on a connector stub crossed the line on every reference
4689
+ // board. Grow each box to hold its members' stub labels, their reference
4690
+ // and value fields, and the power symbols on their pins, plus BOX_PAD; the
4691
+ // group gaps above were widened for exactly this text, so boxes stay clear
4692
+ // of one another.
4693
+ {
4694
+ const pinGroup = new Map();
4695
+ for (const [key, pl] of placed) {
4696
+ const g = groupOf.get(key);
4697
+ if (!g)
4698
+ continue;
4699
+ for (const pin of pl.sym.pins) {
4700
+ const p = pinAt(pl, pin);
4701
+ pinGroup.set(pointKey(p.x, p.y), g);
4702
+ }
4703
+ }
4704
+ const boxesOf = new Map();
4705
+ const addBox = (g, b) => {
4706
+ if (!g)
4707
+ return;
4708
+ boxesOf.set(g, [...(boxesOf.get(g) ?? []), b]);
4709
+ };
4710
+ // a label at the end of a stub that starts on a member pin
4711
+ for (const lb of labels) {
4712
+ const stub = wires.find((w) => sameCoord(w.x2, lb.x) && sameCoord(w.y2, lb.y) && pinGroup.has(pointKey(w.x1, w.y1)));
4713
+ const g = stub ? pinGroup.get(pointKey(stub.x1, stub.y1)) : undefined;
4714
+ addBox(g, labelReserveBox(lb.name, lb.x, lb.y, lb.rot, lb.kind));
4715
+ }
4716
+ // reference and value fields of every member, at the reserve advance
4717
+ const fieldBox = (text, x, y) => {
4718
+ const w = Math.max(1, text.length) * TEXT_RESERVE * LABEL_HEIGHT;
4719
+ return { minX: x - w / 2, minY: y - LABEL_HEIGHT / 2, maxX: x + w / 2, maxY: y + LABEL_HEIGHT / 2 };
4720
+ };
4721
+ const keyOfPlaced = new Map([...placed.entries()].map(([k, p]) => [p, k]));
4722
+ for (const { sym, pl } of emitPairs) {
4723
+ const g = groupOf.get(keyOfPlaced.get(pl) ?? '');
4724
+ addBox(g, fieldBox(displayRefOf(pl), sym.refAt.x, sym.refAt.y));
4725
+ addBox(g, fieldBox(sym.value, sym.valueAt.x, sym.valueAt.y));
4726
+ }
4727
+ // power symbols on member stubs, with their value text
4728
+ for (const ps of extraSymbols) {
4729
+ const stub = wires.find((w) => sameCoord(w.x2, ps.at.x) && sameCoord(w.y2, ps.at.y) && pinGroup.has(pointKey(w.x1, w.y1)));
4730
+ const g = stub ? pinGroup.get(pointKey(stub.x1, stub.y1)) : undefined;
4731
+ if (!g)
4732
+ continue;
4733
+ addBox(g, { minX: ps.at.x - 2 * U, minY: ps.at.y - 2 * U, maxX: ps.at.x + 2 * U, maxY: ps.at.y + 2 * U });
4734
+ if (!ps.hideValue)
4735
+ addBox(g, powerValueBox(ps.value, ps.valueAt.x, ps.valueAt.y));
4736
+ }
4737
+ // every member body too: a hung part on a shared lane or an infilled
4738
+ // cell may sit past the cells the rect was first drawn from
4739
+ for (const [key, pl] of placed) {
4740
+ const g = groupOf.get(key);
4741
+ if (g)
4742
+ addBox(g, pl.body);
4743
+ }
4744
+ for (const r of groupRects) {
4745
+ for (const b of boxesOf.get(r.name) ?? []) {
4746
+ r.x1 = Math.min(r.x1, b.minX - BOX_PAD * U);
4747
+ r.x2 = Math.max(r.x2, b.maxX + BOX_PAD * U);
4748
+ r.y1 = Math.min(r.y1, b.minY - BOX_PAD * U);
4749
+ r.y2 = Math.max(r.y2, b.maxY + BOX_PAD * U);
4750
+ }
4751
+ // The caption is a band across the top of the box, not a corner the
4752
+ // parts may rise into: nothing drawn starts above the caption's
4753
+ // bottom plus a unit of air, whatever its column (a rail name beside
4754
+ // the caption read as part of it).
4755
+ const contentTop = Math.min(...(boxesOf.get(r.name) ?? []).map((b) => b.minY));
4756
+ if (Number.isFinite(contentTop))
4757
+ r.y1 = Math.min(r.y1, contentTop - (2 + CAPTION_SIZE + U));
4758
+ r.y1 = grid(Math.floor(r.y1 / U));
4759
+ }
4760
+ // measured before the boxes are aligned: alignment is a choice, not text
4761
+ for (const [i, r] of groupRects.entries()) {
4762
+ const before = rectsBeforeText[i];
4763
+ const applied = reachMeasured?.get(r.name);
4764
+ reachOut.set(r.name, {
4765
+ left: Math.max(0, before.x1 - r.x1),
4766
+ right: Math.max(0, r.x2 - before.x2),
4767
+ top: (applied?.top ?? 0) + Math.max(0, before.y1 - r.y1),
4768
+ bottom: (applied?.bottom ?? 0) + Math.max(0, r.y2 - before.y2),
4769
+ });
4770
+ }
4771
+ // Boxes in one row of the wrap share a bottom edge, boxes in one column
4772
+ // a right edge, when the neighbour's space is free anyway: a row of
4773
+ // boxes ending at three different heights reads as three afterthoughts.
4774
+ // A line of the wrap (a row, or a column) is the set of boxes whose
4775
+ // fitted rects landed at one top (one left): the shifts that took them
4776
+ // there differ box by box, since the single-row pass gave them
4777
+ // different tops.
4778
+ const columnar = fit.wrap?.kind === 'columns' || fit.wrap?.kind === 'masonry';
4779
+ // Two boxes share a line when their fitted rects overlap across it (in
4780
+ // height for a row, in width for a column): a row's boxes may start at
4781
+ // different tops (the single-row pass gives a box room above its IC for
4782
+ // a pull-up) and two rows never overlap, the wrap having left a gap.
4783
+ const lineIndex = new Array(groupRects.length).fill(-1);
4784
+ {
4785
+ const lo = (i) => (columnar ? rectsBeforeText[i].x1 : rectsBeforeText[i].y1);
4786
+ const hi = (i) => (columnar ? rectsBeforeText[i].x2 : rectsBeforeText[i].y2);
4787
+ const order = groupRects.map((_, i) => i).sort((a, b) => lo(a) - lo(b));
4788
+ let line = -1;
4789
+ let reach = -Infinity;
4790
+ for (const i of order) {
4791
+ if (lo(i) >= reach - 1e-6)
4792
+ line++;
4793
+ lineIndex[i] = line;
4794
+ reach = Math.max(reach, hi(i));
4795
+ }
4796
+ }
4797
+ const lineOf = (i) => lineIndex[i];
4798
+ if (fit.wrap) {
4799
+ const byLine = new Map();
4800
+ for (const [i, r] of groupRects.entries()) {
4801
+ byLine.set(lineOf(i), [...(byLine.get(lineOf(i)) ?? []), r]);
4802
+ }
4803
+ for (const line of byLine.values()) {
4804
+ if (line.length < 2)
4805
+ continue;
4806
+ if (fit.wrap.kind === 'columns' || fit.wrap.kind === 'masonry') {
4807
+ const right = Math.max(...line.map((r) => r.x2));
4808
+ for (const r of line)
4809
+ r.x2 = right;
4810
+ }
4811
+ else {
4812
+ const bottom = Math.max(...line.map((r) => r.y2));
4813
+ for (const r of line)
4814
+ r.y2 = bottom;
4815
+ }
4816
+ }
4817
+ }
4818
+ // The boxes grew into the gaps the wrap left between cells, each by its
4819
+ // own text, so two boxes might nearly touch where two others stood 10 mm
4820
+ // apart, and a row's boxes started at three different heights. Every box
4821
+ // now moves, with everything drawn in it, so that BOX_LINE_GAP separates
4822
+ // neighbours along and across the lines of the wrap, and a row shares
4823
+ // its top (a column its left). Content moves by whole units to keep the
4824
+ // grid; the box takes the exact position, so its padding varies by less
4825
+ // than a unit while the gaps do not.
4826
+ {
4827
+ const lineGap = BOX_LINE_GAP * U;
4828
+ const byLine = new Map();
4829
+ for (const [i, r] of groupRects.entries()) {
4830
+ byLine.set(lineOf(i), [...(byLine.get(lineOf(i)) ?? []), { r, i }]);
4831
+ }
4832
+ // Every drawn item belongs to one group, decided once before anything
4833
+ // moves: a wire, label, junction, no-connect or power symbol to the
4834
+ // group of the pins on its run (wires joined end to end form the run),
4835
+ // anything unwired to the box it lies in. Deciding by box while boxes
4836
+ // move would hand a moved group's items to the next box they land in.
4837
+ const parent = new Map();
4838
+ const find = (k) => {
4839
+ let x = k;
4840
+ while (parent.has(x) && parent.get(x) !== x)
4841
+ x = parent.get(x);
4842
+ return x;
4843
+ };
4844
+ const union = (a, b) => {
4845
+ if (!parent.has(a))
4846
+ parent.set(a, a);
4847
+ if (!parent.has(b))
4848
+ parent.set(b, b);
4849
+ const ra = find(a);
4850
+ const rb = find(b);
4851
+ if (ra !== rb)
4852
+ parent.set(ra, rb);
4853
+ };
4854
+ for (const w of wires)
4855
+ union(pointKey(w.x1, w.y1), pointKey(w.x2, w.y2));
4856
+ const runGroup = new Map();
4857
+ for (const [pt, g] of pinGroup) {
4858
+ const root = find(pt);
4859
+ if (!runGroup.has(root))
4860
+ runGroup.set(root, g);
4861
+ }
4862
+ const inside = (x, y, r) => x >= r.x1 - 1e-6 && x <= r.x2 + 1e-6 && y >= r.y1 - 1e-6 && y <= r.y2 + 1e-6;
4863
+ const groupAt = (x, y) => runGroup.get(find(pointKey(x, y))) ?? groupRects.find((r) => inside(x, y, r))?.name;
4864
+ const wireGroup = new Map(wires.map((w) => [w, groupAt(w.x1, w.y1) ?? groupAt(w.x2, w.y2)]));
4865
+ const pointGroup = new Map();
4866
+ for (const it of [...labels, ...uniqJunctions, ...noConnects])
4867
+ pointGroup.set(it, groupAt(it.x, it.y));
4868
+ const powerGroup = new Map(extraSymbols.map((ps) => [ps, groupAt(ps.at.x, ps.at.y)]));
4869
+ const moveGroup = (r, dx, dy) => {
4870
+ if (!dx && !dy)
4871
+ return;
4872
+ for (const [key, pl] of placed) {
4873
+ if (groupOf.get(key) !== r.name)
4874
+ continue;
4875
+ pl.x += dx;
4876
+ pl.y += dy;
4877
+ pl.body.minX += dx;
4878
+ pl.body.maxX += dx;
4879
+ pl.body.minY += dy;
4880
+ pl.body.maxY += dy;
4881
+ }
4882
+ for (const { sym, pl } of emitPairs) {
4883
+ if (groupOf.get(keyOfPlaced.get(pl) ?? '') !== r.name)
4884
+ continue;
4885
+ sym.at.x += dx;
4886
+ sym.at.y += dy;
4887
+ sym.refAt.x += dx;
4888
+ sym.refAt.y += dy;
4889
+ sym.valueAt.x += dx;
4890
+ sym.valueAt.y += dy;
4891
+ }
4892
+ for (const ps of extraSymbols) {
4893
+ if (powerGroup.get(ps) !== r.name)
4894
+ continue;
4895
+ ps.at.x += dx;
4896
+ ps.at.y += dy;
4897
+ ps.valueAt.x += dx;
4898
+ ps.valueAt.y += dy;
4899
+ }
4900
+ for (const w of wires) {
4901
+ if (wireGroup.get(w) !== r.name)
4902
+ continue;
4903
+ w.x1 += dx;
4904
+ w.x2 += dx;
4905
+ w.y1 += dy;
4906
+ w.y2 += dy;
4907
+ }
4908
+ for (const it of [...labels, ...uniqJunctions, ...noConnects]) {
4909
+ if (pointGroup.get(it) !== r.name)
4910
+ continue;
4911
+ it.x += dx;
4912
+ it.y += dy;
4913
+ }
4914
+ r.x1 += dx;
4915
+ r.x2 += dx;
4916
+ r.y1 += dy;
4917
+ r.y2 += dy;
4918
+ };
4919
+ const snap = (d) => grid(Math.round(d / U));
4920
+ const lines = [...byLine.entries()].sort((a, b) => a[0] - b[0]).map(([, members]) => members);
4921
+ const left0 = Math.min(...groupRects.map((r) => r.x1));
4922
+ const top0 = Math.min(...groupRects.map((r) => r.y1));
4923
+ // what each box was before the alignment above shared its edges
4924
+ const ownSize = new Map(groupRects.map((r, i) => [r.name, { w: Math.max(...(boxesOf.get(r.name) ?? []).map((b) => b.maxX + BOX_PAD * U), rectsBeforeText[i].x2) - r.x1, h: Math.max(...(boxesOf.get(r.name) ?? []).map((b) => b.maxY + BOX_PAD * U), rectsBeforeText[i].y2) - r.y1 }]));
4925
+ if (columnar) {
4926
+ let colLeft = left0;
4927
+ for (const members of lines) {
4928
+ members.sort((a, b) => a.r.y1 - b.r.y1);
4929
+ const colW = Math.max(...members.map((m) => m.r.x2 - m.r.x1));
4930
+ let y = top0;
4931
+ for (const { r } of members) {
4932
+ const h = r.y2 - r.y1;
4933
+ moveGroup(r, snap(colLeft - r.x1), snap(y - r.y1));
4934
+ r.x1 = colLeft;
4935
+ r.x2 = colLeft + colW;
4936
+ r.y1 = y;
4937
+ r.y2 = y + h;
4938
+ y += h + lineGap;
4939
+ }
4940
+ colLeft += colW + lineGap;
4941
+ }
4942
+ }
4943
+ else {
4944
+ let rowTop = top0;
4945
+ for (const members of lines) {
4946
+ members.sort((a, b) => a.r.x1 - b.r.x1);
4947
+ const rowH = Math.max(...members.map((m) => m.r.y2 - m.r.y1));
4948
+ let x = left0;
4949
+ for (const { r } of members) {
4950
+ const w = r.x2 - r.x1;
4951
+ moveGroup(r, snap(x - r.x1), snap(rowTop - r.y1));
4952
+ r.x1 = x;
4953
+ r.x2 = x + w;
4954
+ r.y1 = rowTop;
4955
+ r.y2 = rowTop + rowH;
4956
+ x += w + lineGap;
4957
+ }
4958
+ rowTop += rowH + lineGap;
4959
+ }
4960
+ }
4961
+ // A shared edge may reach where the box's own text did not: the
4962
+ // title-block corner. The sheet pass below centres the content in the
4963
+ // frame; predicted here the same way, any box whose aligned edge would
4964
+ // enter the corner falls back to its own width or height there.
4965
+ {
4966
+ const paper = fit.paper;
4967
+ const xs = groupRects.flatMap((r) => [r.x1, r.x2]);
4968
+ const ys = groupRects.flatMap((r) => [r.y1, r.y2]);
4969
+ const contentW = Math.max(...xs) - Math.min(...xs);
4970
+ const contentH = Math.max(...ys) - Math.min(...ys);
4971
+ const dx = grid(Math.round((FRAME + Math.max(0, (paper.w - 2 * FRAME - contentW) / 2) - Math.min(...xs)) / U));
4972
+ const dy = grid(Math.round((FRAME + 4 * U + Math.max(0, (paper.h - 2 * FRAME - TITLE_STRIP - contentH) / 2) - Math.min(...ys)) / U));
4973
+ const cornerX = paper.w - FRAME - TITLE_BLOCK_W - dx;
4974
+ const cornerY = paper.h - FRAME - TITLE_STRIP - dy;
4975
+ for (const r of groupRects) {
4976
+ if (r.x2 <= cornerX || r.y2 <= cornerY)
4977
+ continue;
4978
+ const o = ownSize.get(r.name);
4979
+ const ownX2 = r.x1 + o.w;
4980
+ const ownY2 = r.y1 + o.h;
4981
+ // give back the aligned width first (a wide short row), then the height
4982
+ if (ownX2 <= cornerX && r.x2 > ownX2)
4983
+ r.x2 = ownX2;
4984
+ if (r.x2 > cornerX && ownY2 <= cornerY && r.y2 > ownY2)
4985
+ r.y2 = ownY2;
4986
+ }
4987
+ }
4988
+ }
4989
+ }
4990
+ // ---------- measure what every cell drew (for the squeeze), before the sheet shift ----------
4991
+ // Every drawn item is attributed to the cells it belongs to: a body and its
4992
+ // fields to their instance; a wire, with the labels and power symbols on
4993
+ // it, to every instance with a pin on its run (a comb belongs to the IC and
4994
+ // the parts hung on it, which roll up to the IC below). A cell's overhang is
4995
+ // then how far that union reaches past its body on each side.
4996
+ const measuredOut = new Map();
4997
+ {
4998
+ const parent = new Map();
4999
+ const find = (k) => {
5000
+ let r = k;
5001
+ while (parent.has(r) && parent.get(r) !== r)
5002
+ r = parent.get(r);
5003
+ return r;
5004
+ };
5005
+ const union = (a, b) => {
5006
+ if (!parent.has(a))
5007
+ parent.set(a, a);
5008
+ if (!parent.has(b))
5009
+ parent.set(b, b);
5010
+ const ra = find(a);
5011
+ const rb = find(b);
5012
+ if (ra !== rb)
5013
+ parent.set(ra, rb);
5014
+ };
5015
+ for (const w of wires)
5016
+ union(pointKey(w.x1, w.y1), pointKey(w.x2, w.y2));
5017
+ const boxes = new Map();
5018
+ const dbgSrc = new Map();
5019
+ const extend = (key, b, why = 'body') => {
5020
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1')
5021
+ dbgSrc.set(key, [...(dbgSrc.get(key) ?? []), `${why}[${b.minX.toFixed(0)},${b.minY.toFixed(0)}..${b.maxX.toFixed(0)},${b.maxY.toFixed(0)}]`]);
5022
+ const cur = boxes.get(key);
5023
+ boxes.set(key, cur ? { minX: Math.min(cur.minX, b.minX), minY: Math.min(cur.minY, b.minY), maxX: Math.max(cur.maxX, b.maxX), maxY: Math.max(cur.maxY, b.maxY) } : { ...b });
5024
+ };
5025
+ const compOwners = new Map();
5026
+ for (const [key, pl] of placed) {
5027
+ extend(key, pl.body);
5028
+ for (const pin of pl.sym.pins) {
5029
+ const p = pinAt(pl, pin);
5030
+ const pk = pointKey(p.x, p.y);
5031
+ if (!parent.has(pk))
5032
+ continue;
5033
+ const c = find(pk);
5034
+ compOwners.set(c, (compOwners.get(c) ?? new Set()).add(key));
5035
+ }
5036
+ }
5037
+ const keyOf = new Map([...placed.entries()].map(([k, p]) => [p, k]));
5038
+ for (const { sym, pl } of emitPairs) {
5039
+ const key = keyOf.get(pl);
5040
+ if (!key)
5041
+ continue;
5042
+ extend(key, centeredTextBox(displayRefOf(pl), sym.refAt.x, sym.refAt.y), 'ref');
5043
+ extend(key, centeredTextBox(sym.value, sym.valueAt.x, sym.valueAt.y), 'value');
5044
+ }
5045
+ const compBox = new Map();
5046
+ const extendComp = (c, b) => {
5047
+ const cur = compBox.get(c);
5048
+ compBox.set(c, cur ? { minX: Math.min(cur.minX, b.minX), minY: Math.min(cur.minY, b.minY), maxX: Math.max(cur.maxX, b.maxX), maxY: Math.max(cur.maxY, b.maxY) } : { ...b });
5049
+ };
5050
+ for (const w of wires)
5051
+ extendComp(find(pointKey(w.x1, w.y1)), { minX: Math.min(w.x1, w.x2), minY: Math.min(w.y1, w.y2), maxX: Math.max(w.x1, w.x2), maxY: Math.max(w.y1, w.y2) });
5052
+ const compAt = (x, y) => {
5053
+ const pk = pointKey(x, y);
5054
+ if (parent.has(pk))
5055
+ return find(pk);
5056
+ const w = wires.find((s) => segContains(s, x, y));
5057
+ return w ? find(pointKey(w.x1, w.y1)) : null;
5058
+ };
5059
+ for (const l of labels) {
5060
+ const c = compAt(l.x, l.y);
5061
+ if (c)
5062
+ extendComp(c, labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
5063
+ }
5064
+ for (const ps of extraSymbols) {
5065
+ const c = compAt(ps.at.x, ps.at.y);
5066
+ if (!c)
5067
+ continue;
5068
+ extendComp(c, { minX: ps.at.x - 2 * U, minY: ps.at.y - 2 * U, maxX: ps.at.x + 2 * U, maxY: ps.at.y + 2 * U });
5069
+ if (!ps.hideValue)
5070
+ extendComp(c, powerValueBox(ps.value, ps.valueAt.x, ps.valueAt.y));
5071
+ }
5072
+ const rootOf = (k) => {
5073
+ let r = k;
5074
+ for (let i = 0; i < 6 && boundTo.has(r); i++)
5075
+ r = boundTo.get(r);
5076
+ return r;
5077
+ };
5078
+ // A run with one owner cell (an IC and the parts hung on it) is that
5079
+ // cell's; a run shared by several independent cells (a bank's rail and
5080
+ // ground trunks, a cluster wire between two column parts) gives each
5081
+ // owner only the wires that touch its own pins, with the labels and
5082
+ // symbols at their far ends — attributed whole, a bank's trunk made every
5083
+ // capacitor's cell as wide as the bank.
5084
+ const pinComp = new Map(); // pin point -> owner key
5085
+ for (const [key, pl] of placed)
5086
+ for (const pin of pl.sym.pins) {
5087
+ const p = pinAt(pl, pin);
5088
+ pinComp.set(pointKey(p.x, p.y), key);
5089
+ }
5090
+ const itemsAt = new Map(); // point -> boxes of labels/symbols anchored there
5091
+ const addItem = (x, y, b) => {
5092
+ itemsAt.set(pointKey(x, y), [...(itemsAt.get(pointKey(x, y)) ?? []), b]);
5093
+ };
5094
+ for (const l of labels)
5095
+ addItem(l.x, l.y, labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
5096
+ for (const ps of extraSymbols) {
5097
+ addItem(ps.at.x, ps.at.y, { minX: ps.at.x - 2 * U, minY: ps.at.y - 2 * U, maxX: ps.at.x + 2 * U, maxY: ps.at.y + 2 * U });
5098
+ if (!ps.hideValue)
5099
+ addItem(ps.at.x, ps.at.y, powerValueBox(ps.value, ps.valueAt.x, ps.valueAt.y));
5100
+ }
5101
+ for (const [c, owners] of compOwners) {
5102
+ const roots = new Set([...owners].map(rootOf));
5103
+ const b = compBox.get(c);
5104
+ if (!b)
5105
+ continue;
5106
+ if (roots.size === 1) {
5107
+ extend([...roots][0], b, `run(${[...owners].join('+')})`);
5108
+ continue;
5109
+ }
5110
+ for (const w of wires) {
5111
+ if (find(pointKey(w.x1, w.y1)) !== c)
5112
+ continue;
5113
+ const ends = [pointKey(w.x1, w.y1), pointKey(w.x2, w.y2)];
5114
+ const touching = new Set(ends.map((e) => pinComp.get(e)).filter((k) => k !== undefined).map(rootOf));
5115
+ for (const r of touching) {
5116
+ extend(r, { minX: Math.min(w.x1, w.x2), minY: Math.min(w.y1, w.y2), maxX: Math.max(w.x1, w.x2), maxY: Math.max(w.y1, w.y2) }, `wire(${w.net})`);
5117
+ for (const e of ends)
5118
+ for (const ib of itemsAt.get(e) ?? [])
5119
+ extend(r, ib, `item@${e}`);
5120
+ }
5121
+ }
5122
+ }
5123
+ for (const [key, b] of [...boxes.entries()]) {
5124
+ const r = rootOf(key);
5125
+ if (r !== key)
5126
+ extend(r, b, `child ${key}`);
5127
+ }
5128
+ for (const [key, pl] of placed) {
5129
+ if (rootOf(key) !== key)
5130
+ continue;
5131
+ const b = boxes.get(key);
5132
+ if (!b)
5133
+ continue;
5134
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] && (b.maxX - b.minX > 4 * (pl.body.maxX - pl.body.minX) + 30 || b.maxY - b.minY > 4 * (pl.body.maxY - pl.body.minY) + 30)) {
5135
+ trace(`measured ${key}: drawn extent ${(b.maxX - b.minX).toFixed(0)}x${(b.maxY - b.minY).toFixed(0)} mm around a ${(pl.body.maxX - pl.body.minX).toFixed(0)}x${(pl.body.maxY - pl.body.minY).toFixed(0)} body <- ${(dbgSrc.get(key) ?? []).slice(0, 12).join(' ')}`);
5136
+ }
5137
+ measuredOut.set(key, {
5138
+ left: Math.max(0, pl.body.minX - b.minX),
5139
+ right: Math.max(0, b.maxX - pl.body.maxX),
5140
+ top: Math.max(0, pl.body.minY - b.minY),
5141
+ bottom: Math.max(0, b.maxY - pl.body.maxY),
5142
+ });
5143
+ }
5144
+ }
2235
5145
  const allX = [...groupRects.map((r) => r.x1), ...groupRects.map((r) => r.x2)];
2236
5146
  const allY = [...groupRects.map((r) => r.y1), ...groupRects.map((r) => r.y2)];
2237
5147
  const contentW = allX.length ? Math.max(...allX) - Math.min(...allX) : 0;
@@ -2257,11 +5167,15 @@ export function draftSchematicPlacement(validated, projectName, today) {
2257
5167
  // the frame, and an extent wider than the window keeps the centered offset
2258
5168
  // (that overflow was already noted by the fit pass).
2259
5169
  const textBoxes = [
2260
- ...labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot)),
5170
+ ...labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind)),
2261
5171
  // power VALUE text is measured by the checker too, and the sweep above
2262
5172
  // may have slid it past its group rect's margin (pic_programmer put a
2263
5173
  // rail name 1 mm over the top edge of a compacted sheet)
2264
5174
  ...extraSymbols.filter((s) => !s.hideValue).map((s) => powerValueBox(s.value, s.valueAt.x, s.valueAt.y)),
5175
+ // group captions: left-top justified at CAPTION_SIZE from the box
5176
+ // corner, the way emit.ts writes them and the checker measures them; a
5177
+ // long caption on a narrow group can outrun the box's right edge
5178
+ ...groupRects.map((r) => ({ minX: r.x1 + 2, minY: r.y1 + 2, maxX: r.x1 + 2 + Math.max(1, r.name.length) * LABEL_ADVANCE * CAPTION_SIZE, maxY: r.y1 + 2 + CAPTION_SIZE })),
2265
5179
  ];
2266
5180
  const fullMinX = Math.min(minX, ...textBoxes.map((b) => b.minX));
2267
5181
  const fullMaxX = Math.max(minX + contentW, ...textBoxes.map((b) => b.maxX));
@@ -2286,7 +5200,7 @@ export function draftSchematicPlacement(validated, projectName, today) {
2286
5200
  // the bottom edge is the engine's own usable bottom, ABOVE the title strip:
2287
5201
  // content that fills the sheet's height exactly would otherwise carry the
2288
5202
  // centering pass's 4-unit downward offset into the reserved corner
2289
- dy = clampShift(dy, fullMinY, fullMaxY, FRAME, paper.h - FRAME - TITLE_STRIP);
5203
+ dy = clampShift(dy, fullMinY, fullMaxY, FRAME, fit.intoStrip ? paper.h - FRAME : paper.h - FRAME - TITLE_STRIP);
2290
5204
  const shift = (o) => {
2291
5205
  if (o.x !== undefined)
2292
5206
  o.x += dx;
@@ -2362,6 +5276,7 @@ export function draftSchematicPlacement(validated, projectName, today) {
2362
5276
  noConnects,
2363
5277
  rectangles: groupRects.map((r) => ({ x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2, stroke: 'solid', name: r.name })),
2364
5278
  captions: groupRects.map((r) => ({ text: r.name, x: r.x1 + 2, y: r.y1 + 2, name: r.name })),
5279
+ netColors: netColorsOf(intent.nets, netClasses),
2365
5280
  };
2366
5281
  const report = {
2367
5282
  groups: groupNames.map((g) => ({
@@ -2378,12 +5293,18 @@ export function draftSchematicPlacement(validated, projectName, today) {
2378
5293
  labelCount: labels.length,
2379
5294
  pwrFlags,
2380
5295
  noConnects: noConnects.length,
5296
+ sheetFit: {
5297
+ paper: paper.name,
5298
+ inkUtilization: [...placed.values()].reduce((a, p) => a + p.cellW * p.cellH * U * U, 0) / (usableW(paper) * usableH(paper)),
5299
+ compaction,
5300
+ misses: [...budgetMisses],
5301
+ },
2381
5302
  paper: paper.name,
2382
5303
  notes,
2383
5304
  mergedNets,
2384
5305
  labelOverlaps,
2385
5306
  labelOverlapBudgetExceeded,
2386
5307
  };
2387
- return { model, report };
5308
+ return { model, report, rects: groupRects, measured: measuredOut, reach: reachOut, wrapped: fit.wrap !== null };
2388
5309
  }
2389
5310
  //# sourceMappingURL=engine.js.map