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,5 +1,5 @@
1
1
  import type { Bounds } from '../sexp.js';
2
- import { knum, type PlacementModel, type EmitSymbol } from '../emit.js';
2
+ import { knum, CAPTION_SIZE, type PlacementModel, type EmitSymbol, type EmitLabel, type LabelShape } from '../emit.js';
3
3
  import { powerSymbolSource, pwrFlagSource, type ResolvedSymbol, type DraftPin } from './symsource.js';
4
4
  import type { SchematicIntent, IntentNet, IntentPart, ValidatedIntent } from './ir.js';
5
5
 
@@ -14,20 +14,139 @@ import type { SchematicIntent, IntentNet, IntentPart, ValidatedIntent } from './
14
14
  const U = 1.27;
15
15
  /** Stub length from a pin to its label/power symbol, in grid units. */
16
16
  const STUB = 2;
17
- /** Cell margin around a symbol body (room for stubs, labels, text), in units. */
18
- const MARGIN = 6;
17
+ /** Cell margin around a symbol body (room for stubs, labels, text), in units,
18
+ * for the first draft; a measured round shrinks it to `MEASURED_MARGIN`. */
19
+ const BASE_MARGIN = 4;
20
+ /** Cell margin once the cell is sized from what it actually drew. */
21
+ const MEASURED_MARGIN = 2;
22
+ /** Vertical cell margin, units: rows are tighter than columns because a
23
+ * part's texts sit beside it, not above and below; a pin that faces up or
24
+ * down with a rail or ground on it reserves `POWER_REACH` instead. */
25
+ const VMARGIN = 2;
26
+ const POWER_REACH = 6;
27
+ /** Clear grid units kept beyond a cell's measured drawn extent. */
28
+ const MEASURE_PAD = 1;
29
+ /** What a cell drew beyond its body, mm on each side, measured on a finished draft. */
30
+ type Overhang = { left: number; right: number; top: number; bottom: number };
31
+ /** Padding, grid units, a group box keeps around the label and field text it
32
+ * encloses, and the clearance kept between two such boxes. */
33
+ const BOX_PAD = 1;
34
+ /** Grid units between two drawn group boxes, after the boxes have grown to
35
+ * their text: the one distance a reader sees between blocks. Equal to
36
+ * GROUP_GAP, the gap the wrap fits between rects, so a fit on measured
37
+ * boxes is exact. */
38
+ const BOX_LINE_GAP = 4;
39
+ /** How far a group's drawn box grew past the rect the wrap was fitted on,
40
+ * per side, in mm: measured by one draft, fitted by the next. */
41
+ type Reach = { left: number; right: number; top: number; bottom: number };
42
+ const BOX_GAP = 4;
19
43
  /** Vertical gap between rows and horizontal channel between columns, units. */
20
- const ROW_GAP = 4;
21
- const CHANNEL = 8;
44
+ const ROW_GAP = 3;
45
+ const CHANNEL = 4;
22
46
  /** Gap between group boxes, units. */
23
- const GROUP_GAP = 8;
24
- /** Local nets up to this many endpoints may be wired (design D2). */
47
+ const GROUP_GAP = 4;
48
+ /** Local nets up to this many endpoints may be wired (design D2); a larger
49
+ * cluster is narrowed to the endpoints bound to one anchor before routing. */
25
50
  const MAX_WIRED_ENDPOINTS = 4;
51
+ /** Subsets of one cluster the wire pass tries to route before it gives up. */
52
+ const ROUTE_ATTEMPTS = 64;
53
+ /** Nodes the masonry wrap's deal search visits before it keeps the best deal found so far. */
54
+ const MASONRY_NODE_BUDGET = 200_000;
26
55
  /** Wire-span budget in mm beyond which a net becomes labels. */
27
56
  const MAX_WIRE_SPAN = 50.8;
28
57
  /** Label text metrics, matching the legibility checker's conservative box. */
29
58
  const LABEL_HEIGHT = 1.27;
30
- const LABEL_ADVANCE = 0.6;
59
+ /**
60
+ * Advance the ENGINE gives text when it spaces groups, clears labels and
61
+ * fields of one another, and draws the box around a group. The checker's 0.6
62
+ * is tuned below KiCad's stroke font on purpose (design C3: never a false
63
+ * collision); measured on a plotted sheet the font advances about 0.72 of
64
+ * its height, so an engine that cleared text at 0.6 drew sheets where a rail
65
+ * name ran into the label on the next pin row while the checker called them
66
+ * clean. The engine is the side that must be conservative: it reserves 0.8.
67
+ */
68
+ const LABEL_ADVANCE = 0.8;
69
+ const TEXT_RESERVE = LABEL_ADVANCE;
70
+ /** Advance of KiCad's stroke font for the upper-case pin names inside a
71
+ * body, per height: wider than the mixed-case reserve above. */
72
+ const NAME_ADVANCE = 1.0;
73
+ /** Air between any two texts, mm. Boxes that merely do not overlap still
74
+ * read as one smudge on paper (esp32-amp's "100k" sat 0.6 mm above
75
+ * "USB_OV"); every clearance check pads the candidate by this. */
76
+ const TEXT_PAD = 0.8;
77
+ const padBox = (b: Bounds, p = TEXT_PAD): Bounds => ({ minX: b.minX - p, minY: b.minY - p, maxX: b.maxX + p, maxY: b.maxY + p });
78
+ /** `COPPERHEAD_DRAFT_TRACE=1` prints every placement decision the engine
79
+ * makes silently: what it claimed, what it skipped and why, what it refused. */
80
+ const trace = (msg: string): void => {
81
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') console.error(`[draft] ${msg}`);
82
+ };
83
+ /** `labelTextBox` at the reserve advance: what the text takes on paper. */
84
+ const labelReserveBox = (name: string, x: number, y: number, rot: number, kind: EmitLabel['kind'] = 'local'): Bounds =>
85
+ labelBoxAt(name, x, y, rot, kind, TEXT_RESERVE);
86
+ /**
87
+ * Along-axis length a global label's flag adds beyond its text: the margin
88
+ * either side of the text plus the pointed tip. Measured from eeschema's own
89
+ * outline at 1.27 mm (a 12-character name draws 17.0 mm long against about
90
+ * 14.5 of text), and kept a little generous because the advance estimate
91
+ * under-reads wide glyphs.
92
+ */
93
+ const FLAG_PAD = 2.5 * LABEL_HEIGHT;
94
+ /** Grid units of the caption band at the top of a group box: the bold
95
+ * caption at `CAPTION_SIZE` plus clearance over the tallest thing a cell can
96
+ * carry above its body (a rail symbol on an upward stub with its value text). */
97
+ const CAPTION_BAND = 7;
98
+ /**
99
+ * The box a label occupies at `advance` per character. A local label is bare
100
+ * text standing on its anchor (see `labelTextBox`); a global label is a flag
101
+ * two text heights tall, centred on the anchor line, whose length runs away
102
+ * from the anchor in the rotation's direction — 0 right, 90 up, 180 left,
103
+ * 270 down in schematic Y-down coordinates, exactly as eeschema draws it.
104
+ */
105
+ const labelBoxAt = (name: string, x: number, y: number, rot: number, kind: EmitLabel['kind'], advance: number): Bounds => {
106
+ const textW = Math.max(1, name.length) * advance * LABEL_HEIGHT;
107
+ if (kind === 'global') {
108
+ const w = textW + FLAG_PAD;
109
+ const h = LABEL_HEIGHT;
110
+ const r = ((rot % 360) + 360) % 360;
111
+ if (r === 90) return { minX: x - h, minY: y - w, maxX: x + h, maxY: y };
112
+ if (r === 270) return { minX: x - h, minY: y, maxX: x + h, maxY: y + w };
113
+ if (r === 180) return { minX: x - w, minY: y - h, maxX: x, maxY: y + h };
114
+ return { minX: x, minY: y - h, maxX: x + w, maxY: y + h };
115
+ }
116
+ // A plain label STANDS on its anchor line: the checker measures it that way
117
+ // (`labelBounds`), and so does the engine. The union box that also reached
118
+ // half a height below the line dated from the checker's centred days; kept,
119
+ // it made every name on a row collide with that row's own continuing wire
120
+ // (esp32-amp's UART_TXD could stand nowhere on its 9 mm run).
121
+ return rot === 180
122
+ ? { minX: x - textW, minY: y - LABEL_HEIGHT, maxX: x, maxY: y }
123
+ : { minX: x, minY: y - LABEL_HEIGHT, maxX: x + textW, maxY: y };
124
+ };
125
+ /** KiCad's label rotation for text running outward along a stub direction. */
126
+ const rotOutward = (o: { dx: number; dy: number }): number => (o.dx === -1 ? 180 : o.dy === -1 ? 90 : o.dy === 1 ? 270 : 0);
127
+ /**
128
+ * Flag shape for a global label at a pin, from the pin's electrical type:
129
+ * an output pin's net leaves through an output flag, an input's arrives
130
+ * through an input flag, a bidirectional or tri-state pin's points both
131
+ * ways, and everything else (passive parts, power, unspecified) gets the
132
+ * plain box. Cosmetic to KiCad's ERC, informative to a reader.
133
+ */
134
+ export function flagShape(etype: string): LabelShape {
135
+ switch (etype) {
136
+ case 'output':
137
+ case 'open_collector':
138
+ case 'open_emitter':
139
+ case 'power_out':
140
+ return 'output';
141
+ case 'input':
142
+ return 'input';
143
+ case 'bidirectional':
144
+ case 'tri_state':
145
+ return 'bidirectional';
146
+ default:
147
+ return 'passive';
148
+ }
149
+ }
31
150
  /** How far a colliding label may ride its stub outward, in grid units.
32
151
  * Deep enough to carry a bottom-pin label past the routing channel that runs
33
152
  * under its connector (#220 phase 2); rungs stay ordered nearest-first, so a
@@ -78,6 +197,8 @@ const PAPERS: { name: string; w: number; h: number }[] = [
78
197
  ];
79
198
  const FRAME = 10;
80
199
  const TITLE_STRIP = 30;
200
+ /** The title block's width along the bottom edge, as the checker measures it. */
201
+ const TITLE_BLOCK_W = 110;
81
202
  /** Max pin-to-pin gap, grid units, for chaining a passive bank on one trunk
82
203
  * (#233): wide enough for two-pin parts sitting in adjacent COLUMNS (cell
83
204
  * width plus the channel, ~23 units), tight enough that a trunk never spans
@@ -107,6 +228,28 @@ export interface SchematicDraftReport {
107
228
  pwrFlags: string[];
108
229
  noConnects: number;
109
230
  paper: string;
231
+ /**
232
+ * The paper pass's verdict, for callers that gate on it rather than parse
233
+ * the notes: which sheet, how much of it the placed cells ink, and whether
234
+ * compaction ran, succeeded, or failed (with each smaller sheet's closest
235
+ * miss when it did). `pinned` is a paper hint; `overflow` means nothing
236
+ * holds the content and the frame will be crossed; `banded` means no sheet
237
+ * held the natural ribbon and columns were wrapped to fit the smallest one.
238
+ */
239
+ sheetFit: {
240
+ paper: string;
241
+ inkUtilization: number;
242
+ compaction: 'not-needed' | 'compacted' | 'banded' | 'failed' | 'pinned' | 'overflow';
243
+ misses: string[];
244
+ /** The squeeze: cells re-sized from what they drew, round by round. */
245
+ squeeze?: { rounds: number; boxAreaBefore: number; boxAreaAfter: number };
246
+ /**
247
+ * The look: a wrapped sheet drafted again with each group's label reach
248
+ * measured from the first draft instead of estimated. `paperBefore` is
249
+ * the first draft's sheet, `paperAfter` the sheet kept.
250
+ */
251
+ look?: { kept: boolean; paperBefore: string; paperAfter: string };
252
+ };
110
253
  notes: string[];
111
254
  /**
112
255
  * Points where labels of two or more distinct nets landed together, merging
@@ -324,8 +467,45 @@ interface Placed {
324
467
  body: Bounds; // schematic space, absolute
325
468
  cellW: number; // units
326
469
  cellH: number; // units
470
+ /** Placement rotation, degrees CCW in symbol space (KiCad's `(at x y rot)`); `sym` is already rotated. */
471
+ rot: number;
472
+ /** KiCad `(mirror y)`: the symbol flipped left-for-right before rotation (a
473
+ * transistor whose base must face the pin that drives it); `sym` is already mirrored. */
474
+ mirror?: 'y';
327
475
  }
328
476
 
477
+ /** `sym` mirrored left-for-right (KiCad's `(mirror y)`), then turned by `rot`. */
478
+ const transformSym = (sym: ResolvedSymbol, rot: number, mirror?: 'y'): ResolvedSymbol => {
479
+ if (!mirror) return rotatedSym(sym, rot);
480
+ const pins = sym.pins.map((p) => ({ ...p, x: -p.x, angle: (((180 - p.angle) % 360) + 360) % 360 }));
481
+ const b = sym.body ? bodyBoundsOf(sym) : null;
482
+ const body = b ? { minX: -b.maxX, maxX: -b.minX, minY: b.minY, maxY: b.maxY } : null;
483
+ return rotatedSym({ ...sym, pins, body }, rot);
484
+ };
485
+
486
+ /**
487
+ * The symbol as KiCad draws it at rotation `rot` (0, 90, 180, 270): pins,
488
+ * their direction angles and the body box all turned in symbol space, so
489
+ * every downstream helper (pinAt, outward, bodyBoundsOf) reads the drawn
490
+ * geometry without knowing the part was turned. Matches `pinAbsolute`:
491
+ * rx = x cos − y sin, ry = x sin + y cos.
492
+ */
493
+ const rotatedSym = (sym: ResolvedSymbol, rot: number): ResolvedSymbol => {
494
+ const r = ((rot % 360) + 360) % 360;
495
+ if (r === 0) return sym;
496
+ const turn = (x: number, y: number): { x: number; y: number } =>
497
+ r === 90 ? { x: -y, y: x } : r === 180 ? { x: -x, y: -y } : { x: y, y: -x };
498
+ const pins = sym.pins.map((p) => ({ ...p, ...turn(p.x, p.y), angle: (p.angle + r) % 360 }));
499
+ const b = sym.body ? bodyBoundsOf(sym) : null;
500
+ let body: Bounds | null = null;
501
+ if (b) {
502
+ const c1 = turn(b.minX, b.minY);
503
+ const c2 = turn(b.maxX, b.maxY);
504
+ 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) };
505
+ }
506
+ return { ...sym, pins, body };
507
+ };
508
+
329
509
  /**
330
510
  * One placeable thing. A single-unit part is one instance whose key IS its
331
511
  * refdes. A multi-unit part (an opamp, a gate pack) becomes one instance per
@@ -351,6 +531,46 @@ const bodyBoundsOf = (sym: ResolvedSymbol): Bounds => {
351
531
  return { minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys) };
352
532
  };
353
533
 
534
+ /**
535
+ * Wire and label colours by function: rails one colour, grounds another,
536
+ * and every FAMILY of signal nets — the nets sharing a prefix before the
537
+ * first underscore, when there are at least two of them (I2S_BCLK, I2S_DIN,
538
+ * I2S_LRCLK; SPI_…; BTN_…) — a colour of its own from a fixed palette, in
539
+ * family-name order so the same design colours the same way every time. A
540
+ * lone signal keeps the theme's default: colouring everything in a block
541
+ * one colour would say nothing.
542
+ */
543
+ const FAMILY_PALETTE: [number, number, number][] = [
544
+ [30, 90, 200],
545
+ [130, 50, 170],
546
+ [0, 130, 120],
547
+ [210, 120, 0],
548
+ [180, 30, 140],
549
+ [110, 130, 0],
550
+ [140, 80, 40],
551
+ [40, 60, 140],
552
+ ];
553
+ const RAIL_COLOR: [number, number, number] = [170, 30, 30];
554
+ const GROUND_COLOR: [number, number, number] = [70, 70, 70];
555
+ function netColorsOf(nets: IntentNet[], classes: Map<string, { cls: NetClass }>): Record<string, [number, number, number]> {
556
+ const out: Record<string, [number, number, number]> = {};
557
+ const families = new Map<string, string[]>();
558
+ for (const n of nets) {
559
+ const cls = classes.get(n.name)?.cls ?? 'signal';
560
+ if (cls === 'rail') out[n.name] = RAIL_COLOR;
561
+ else if (cls === 'ground') out[n.name] = GROUND_COLOR;
562
+ else {
563
+ const m = /^([A-Za-z0-9]+)_/.exec(n.name);
564
+ if (m) families.set(m[1]!, [...(families.get(m[1]!) ?? []), n.name]);
565
+ }
566
+ }
567
+ const named = [...families.entries()].filter(([, members]) => members.length >= 2).sort((a, b) => a[0].localeCompare(b[0]));
568
+ named.forEach(([, members], i) => {
569
+ for (const name of members) out[name] = FAMILY_PALETTE[i % FAMILY_PALETTE.length]!;
570
+ });
571
+ return out;
572
+ }
573
+
354
574
  /** Pin connection point in schematic space for a part placed at (x, y), rot 0. */
355
575
  const pinAt = (p: Placed, pin: DraftPin): { x: number; y: number } => ({ x: p.x + pin.x, y: p.y - pin.y });
356
576
 
@@ -410,13 +630,58 @@ const boundsOverlap = (a: Bounds, b: Bounds): boolean =>
410
630
  * conservative metrics. Shared by the de-collision pass and the overlap report
411
631
  * so "clear" means one thing in the engine.
412
632
  */
413
- const labelTextBox = (name: string, x: number, y: number, rot: number): Bounds => {
414
- const w = Math.max(1, name.length) * LABEL_ADVANCE * LABEL_HEIGHT;
415
- const h = LABEL_HEIGHT / 2;
416
- return rot === 180
417
- ? { minX: x - w, minY: y - h, maxX: x, maxY: y + h }
418
- : { minX: x, minY: y - h, maxX: x + w, maxY: y + h };
419
- };
633
+ const labelTextBox = (name: string, x: number, y: number, rot: number, kind: EmitLabel['kind'] = 'local'): Bounds =>
634
+ // A local label is bottom-justified on its anchor: on paper the text stands
635
+ // on the wire line and rises one full height above it, and the checker's
636
+ // `labelBounds` measures exactly that. A global label's flag is measured as
637
+ // eeschema draws it (`labelBoxAt`).
638
+ labelBoxAt(name, x, y, rot, kind, LABEL_ADVANCE);
639
+
640
+ /**
641
+ * Where a wired run's name may sit when it must be a global flag, in
642
+ * preference order: standing up from the top of a vertical trunk (the flag on
643
+ * a pole a reviewer expects), then every other wire end continuing its
644
+ * segment outward, then the interior grid points of each segment with the
645
+ * flag perpendicular to the wire, upward before downward. Every candidate
646
+ * lies on a wire of the run, so the name always attaches.
647
+ */
648
+ export function wiredFlagCandidates(segs: Seg[]): { x: number; y: number; rot: number }[] {
649
+ const out: { x: number; y: number; rot: number }[] = [];
650
+ const seen = new Set<string>();
651
+ const push = (x: number, y: number, rot: number): void => {
652
+ const k = `${pointKey(x, y)}/${rot}`;
653
+ if (seen.has(k)) return;
654
+ seen.add(k);
655
+ out.push({ x, y, rot });
656
+ };
657
+ const vertical = segs.filter((s) => sameCoord(s.x1, s.x2));
658
+ const horizontal = segs.filter((s) => !sameCoord(s.x1, s.x2));
659
+ // Horizontal first: a name reads along the row it names (the drafting
660
+ // standard keeps text horizontal), so the ends of horizontal segments
661
+ // continuing outward come before a flag standing up from a trunk top, and
662
+ // both before a flag perpendicular to a wire's interior.
663
+ 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);
664
+ for (const p of ends) {
665
+ for (const s of horizontal) {
666
+ if (!sameCoord(s.y1, p.y)) continue;
667
+ if (sameCoord(p.x, Math.max(s.x1, s.x2))) push(p.x, p.y, 0);
668
+ if (sameCoord(p.x, Math.min(s.x1, s.x2))) push(p.x, p.y, 180);
669
+ }
670
+ }
671
+ 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);
672
+ for (const t of tops) push(t.x, t.y, 90);
673
+ for (const p of ends) {
674
+ for (const s of vertical) {
675
+ if (sameCoord(s.x1, p.x) && sameCoord(p.y, Math.max(s.y1, s.y2))) push(p.x, p.y, 270);
676
+ }
677
+ }
678
+ for (const s of segs) {
679
+ for (const rot of sameCoord(s.x1, s.x2) ? [0, 180] : [90, 270]) {
680
+ for (const p of interiorGridPoints([s], U)) push(p.x, p.y, rot);
681
+ }
682
+ }
683
+ return out;
684
+ }
420
685
 
421
686
  /**
422
687
  * Pairs of labels naming DIFFERENT nets whose text boxes overlap.
@@ -427,10 +692,10 @@ const labelTextBox = (name: string, x: number, y: number, rot: number): Bounds =
427
692
  * colliding label position, nets sorted, so the same pair is not listed twice.
428
693
  */
429
694
  export function findLabelOverlaps(
430
- labels: { name: string; x: number; y: number; rot: number }[],
695
+ labels: { name: string; x: number; y: number; rot: number; kind?: EmitLabel['kind'] }[],
431
696
  ): { x: number; y: number; nets: string[] }[] {
432
697
  const out = new Map<string, { x: number; y: number; nets: Set<string> }>();
433
- const boxes = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot));
698
+ const boxes = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
434
699
  for (let i = 0; i < labels.length; i++) {
435
700
  for (let j = i + 1; j < labels.length; j++) {
436
701
  const a = labels[i]!;
@@ -461,7 +726,182 @@ const segCrossesBody = (x1: number, y1: number, x2: number, y2: number, b: Bound
461
726
  return inX && inY; // conservative for diagonals (the engine never draws them)
462
727
  };
463
728
 
729
+ /**
730
+ * Draft, then check the drawn group boxes against one another: a box grows
731
+ * past its tiling estimate when the text it must hold (a flag on a hung
732
+ * part, a rail name at the edge) reaches further than the pin labels the
733
+ * estimate counted, and two boxes then intersect (usb-atmega-node's Status
734
+ * over IO Headers). Each overlap widens the right-hand group's left reserve
735
+ * by the overlap and the sheet is drafted again; placement is a pure
736
+ * function of the intent and these reserves, so the result stays
737
+ * deterministic. Three rounds bound the cost.
738
+ */
464
739
  export function draftSchematicPlacement(validated: ValidatedIntent, projectName: string, today: string): { model: PlacementModel; report: SchematicDraftReport } {
740
+ const reserves = new Map<string, { left: number; right: number }>();
741
+ let frameSlack = 0;
742
+ let wrapGap = 0;
743
+ // The squeeze. The first draft sizes every cell by rule (a margin a side, a
744
+ // power symbol's worth under every hung part, a label beside every pin) and
745
+ // draws into that room; most of the room stays empty. Each further round
746
+ // sizes the cells from what the previous round actually drew — body,
747
+ // wires, labels, power symbols, field text of the cell and of everything
748
+ // hung on it — plus a pad, and draws again. Rounds continue while the
749
+ // group boxes shrink and the engine's own gates hold (no merged nets, the
750
+ // label-overlap budget kept); the smallest passing sheet is the result.
751
+ // This is the step a drafter does by eye after placing: look, then pull
752
+ // things together.
753
+ let measured: Map<string, Overhang> | undefined;
754
+ let best: { model: PlacementModel; report: SchematicDraftReport; area: number; refusals: number; paperIdx: number } | null = null;
755
+ let squeezeRounds = 0;
756
+ let areaBefore = 0;
757
+ const boxArea = (rects: { x1: number; y1: number; x2: number; y2: number }[]): number => rects.reduce((a, r) => a + (r.x2 - r.x1) * (r.y2 - r.y1), 0);
758
+ let retries = 0;
759
+ // The look. Before any wrap is fitted the engine can only estimate how far
760
+ // a group's labels reach past its cells, and it estimates generously: a
761
+ // label's width beside every pin that might carry one. Those reserves
762
+ // decide how many groups share a row or column, so a wrapped sheet is
763
+ // drafted once more with the reach every box was actually measured to
764
+ // take, and that draft is kept when it fits the same or a smaller sheet
765
+ // with the gates still holding.
766
+ let reachMeasured: Map<string, Reach> | undefined;
767
+ let look: 'not-yet' | 'drafting' | 'done' = 'not-yet';
768
+ let lookVerdict: { kept: boolean; paperBefore: string; paperAfter: string } | undefined;
769
+ for (let round = 0; round < 16; round++) {
770
+ const { model, report, rects, measured: nextMeasured, reach, wrapped } = draftOnce(validated, projectName, today, reserves, frameSlack, measured, wrapGap, reachMeasured);
771
+ let widened = false;
772
+ const stillWrong: string[] = [];
773
+ // A row or column wrapped to the full usable width leaves no room for the
774
+ // text its boxes grow to hold afterwards, and a box then crosses the
775
+ // frame (usb-atmega-node's IO Headers on A3). Any box past the frame
776
+ // shrinks the usable frame by the overflow and the sheet is drafted again.
777
+ const paper = PAPERS.find((p) => p.name === report.sheetFit.paper);
778
+ if (paper) {
779
+ const over = Math.max(0, ...rects.flatMap((r) => [FRAME - r.x1, r.x2 - (paper.w - FRAME), FRAME - r.y1, r.y2 - (paper.h - FRAME)]));
780
+ if (over > 0.01) {
781
+ frameSlack += over + U;
782
+ widened = true;
783
+ stillWrong.push(`a box crosses the frame by ${over.toFixed(1)} mm`);
784
+ 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`);
785
+ }
786
+ // A sheet fitted into the title strip was judged on its group rects
787
+ // before the boxes grew to their text; a box that grew into the title
788
+ // block's own corner (as the checker reserves it, narrowed on small
789
+ // pages) overflows the usable height by how far it reaches in.
790
+ const cornerX = paper.w - FRAME - Math.min(TITLE_BLOCK_W, (paper.w - 2 * FRAME) / 2);
791
+ const cornerY = paper.h - FRAME - Math.min(TITLE_STRIP, (paper.h - 2 * FRAME) / 4);
792
+ const into = Math.max(0, ...rects.map((r) => (r.x2 > cornerX + 0.01 && r.y2 > cornerY + 0.01 ? r.y2 - cornerY : 0)));
793
+ if (over <= 0.01 && into > 0.01) {
794
+ frameSlack += into + U;
795
+ widened = true;
796
+ stillWrong.push(`a box enters the title block's corner by ${into.toFixed(1)} mm`);
797
+ 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`);
798
+ }
799
+ }
800
+ for (let i = 0; i < rects.length; i++) {
801
+ for (let j = i + 1; j < rects.length; j++) {
802
+ const a = rects[i]!;
803
+ const b = rects[j]!;
804
+ const ox = Math.min(a.x2, b.x2) - Math.max(a.x1, b.x1);
805
+ const oy = Math.min(a.y2, b.y2) - Math.max(a.y1, b.y1);
806
+ if (ox <= 0.01 || oy <= 0.01) continue;
807
+ widened = true;
808
+ stillWrong.push(`"${a.name}" and "${b.name}" intersect`);
809
+ if (oy < ox) {
810
+ // boxes in different rows (or columns) touching along the wrap
811
+ // gap: widen the gap between wrapped rows and columns
812
+ wrapGap += oy + U;
813
+ 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`);
814
+ continue;
815
+ }
816
+ const right = a.x1 < b.x1 ? b : a;
817
+ const r = reserves.get(right.name) ?? { left: 0, right: 0 };
818
+ r.left += ox + U;
819
+ reserves.set(right.name, r);
820
+ 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`);
821
+ }
822
+ }
823
+ if (widened && retries < 3) {
824
+ retries++;
825
+ continue; // the same cells, re-tiled: not a squeeze round
826
+ }
827
+ const area = boxArea(rects);
828
+ const refusals = report.notes.filter((n) => /refused/.test(n)).length;
829
+ const paperIdx = PAPERS.findIndex((p) => p.name === report.paper);
830
+ // a round whose boxes still overlap or cross the frame after three
831
+ // re-tilings is not a sheet to keep; nor one that lost a hung part, or
832
+ // needs a larger sheet, or shrank by less than three percent
833
+ const passes = !widened && report.mergedNets.length === 0 && !report.labelOverlapBudgetExceeded;
834
+ if (widened) report.notes.push(`group boxes still wrong after ${retries} re-tilings (${stillWrong.join('; ')}); the sheet is drawn as it stands`);
835
+ retries = 0;
836
+ reserves.clear();
837
+ frameSlack = 0;
838
+ wrapGap = 0;
839
+ if (!best) {
840
+ areaBefore = area;
841
+ best = { model, report, area, refusals, paperIdx };
842
+ if (look === 'not-yet' && wrapped && passes) {
843
+ look = 'drafting';
844
+ reachMeasured = reach;
845
+ 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`);
846
+ continue;
847
+ }
848
+ } else if (look === 'drafting') {
849
+ look = 'done';
850
+ if (passes && refusals <= best.refusals && (paperIdx < best.paperIdx || (paperIdx === best.paperIdx && area <= best.area))) {
851
+ trace(`measured-reach draft kept: ${report.paper}, boxes ${area.toFixed(0)} mm² (was ${best.report.paper}, ${best.area.toFixed(0)} mm²)`);
852
+ lookVerdict = { kept: true, paperBefore: best.report.paper, paperAfter: report.paper };
853
+ if (paperIdx < best.paperIdx) report.notes.push(`label reach measured: the wrap fitted again takes ${report.paper}, not ${best.report.paper}`);
854
+ areaBefore = area;
855
+ best = { model, report, area, refusals, paperIdx };
856
+ } else {
857
+ 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²`);
858
+ lookVerdict = { kept: false, paperBefore: best.report.paper, paperAfter: best.report.paper };
859
+ reachMeasured = undefined;
860
+ }
861
+ } else if (passes && refusals <= best.refusals && paperIdx <= best.paperIdx && area < best.area * 0.97) {
862
+ best = { model, report, area, refusals, paperIdx };
863
+ } else {
864
+ trace(`squeeze round ${squeezeRounds}: ${passes ? `boxes ${area.toFixed(0)} mm² did not shrink` : 'gates failed'}; keeping the previous round`);
865
+ break;
866
+ }
867
+ // Rounds beyond the first run only when asked for: on the boards drafted
868
+ // so far the rules were within a few percent of the drawing, and the
869
+ // extra drafts cost time and re-pin every fixture. The measurement and
870
+ // the report are always made.
871
+ if (squeezeRounds >= 3 || process.env['COPPERHEAD_DRAFT_SQUEEZE'] !== '1') break;
872
+ measured = nextMeasured;
873
+ squeezeRounds++;
874
+ trace(`squeeze round ${squeezeRounds}: cells measured, group boxes ${area.toFixed(0)} mm²; drafting again`);
875
+ }
876
+ const out = best!;
877
+ out.report.sheetFit.squeeze = { rounds: squeezeRounds, boxAreaBefore: Math.round(areaBefore), boxAreaAfter: Math.round(out.area) };
878
+ if (lookVerdict) out.report.sheetFit.look = lookVerdict;
879
+ if (squeezeRounds > 0) {
880
+ 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}`);
881
+ }
882
+ return { model: out.model, report: out.report };
883
+ }
884
+
885
+ function draftOnce(
886
+ validated: ValidatedIntent,
887
+ projectName: string,
888
+ today: string,
889
+ reserves: Map<string, { left: number; right: number }>,
890
+ frameSlack = 0,
891
+ measured?: Map<string, Overhang>,
892
+ wrapGap = 0,
893
+ reachMeasured?: Map<string, Reach>,
894
+ ): {
895
+ model: PlacementModel;
896
+ report: SchematicDraftReport;
897
+ rects: { name: string; x1: number; y1: number; x2: number; y2: number }[];
898
+ measured: Map<string, Overhang>;
899
+ reach: Map<string, Reach>;
900
+ wrapped: boolean;
901
+ } {
902
+ // a measured round keeps only a small margin: the measured overhang says
903
+ // where the drawing actually reaches
904
+ const MARGIN = measured ? MEASURED_MARGIN : BASE_MARGIN;
465
905
  const { intent, symbols, docGroups } = validated;
466
906
  const notes: string[] = [];
467
907
 
@@ -501,6 +941,17 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
501
941
  }));
502
942
  });
503
943
  const instByKey = new Map(instances.map((i) => [i.key, i]));
944
+ /** The pin that controls a transistor (base or gate), or null for anything else. */
945
+ const controlPinOf = (key: string): DraftPin | null => {
946
+ const inst = instByKey.get(key);
947
+ if (!inst || inst.sym.pins.length !== 3) return null;
948
+ if (!/^Transistor|^Device:Q_|^Q\d/.test(inst.part.libId) && !/^Q\d/.test(inst.ref)) return null;
949
+ return inst.sym.pins.find((p) => /^(B|G|Base|Gate)$/i.test(p.name)) ?? null;
950
+ };
951
+ const isTransistor = (key: string): boolean => controlPinOf(key) !== null;
952
+ /** Switches are the anchors of a button block: the pull-up, the debounce
953
+ * cap and the ESD diode all hang on the switch's signal pin. */
954
+ const isSwitch = (key: string): boolean => /^Switch[:_]|^Device:SW_|^Button/i.test(instByKey.get(key)?.part.libId ?? '');
504
955
  /** Placed instances per refdes, for expanding a common pin's endpoint. */
505
956
  const instancesOfRef = new Map<string, Instance[]>();
506
957
  for (const inst of instances) instancesOfRef.set(inst.ref, [...(instancesOfRef.get(inst.ref) ?? []), inst]);
@@ -595,12 +1046,41 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
595
1046
  let right = 0;
596
1047
  for (const key of keys) {
597
1048
  const inst = instByKey.get(key);
1049
+ if (!inst) continue;
1050
+ // a part with pins only on its top and bottom (a vertical R or C)
1051
+ // carries its reference and value beside it on the right
1052
+ if (inst.sym.pins.every((pin) => outward(pin).dx === 0)) {
1053
+ const fieldW = Math.max(inst.ref.length + 1, inst.part.value.length) * TEXT_RESERVE * LABEL_HEIGHT;
1054
+ right = Math.max(right, 1.27 + fieldW + 1.27);
1055
+ }
598
1056
  for (const pin of inst?.sym.pins ?? []) {
599
- const net = signalNetOfPin.get(`${inst!.ref}.${pin.number}`);
600
- if (!net) continue;
601
1057
  const o = outward(pin);
602
1058
  if (o.dx === 0) continue;
603
- const extent = STUB * U + Math.max(1, net.length) * LABEL_ADVANCE * LABEL_HEIGHT;
1059
+ const ep = `${inst!.ref}.${pin.number}`;
1060
+ const signal = signalNetOfPin.get(ep);
1061
+ const power = signal ? undefined : netByEndpoint.get(ep);
1062
+ if (!signal && !power) continue;
1063
+ // a small signal net whose every endpoint is in this group is wired,
1064
+ // not labelled: no flag reaches out from it (counting one for every
1065
+ // pin put 27 mm between groups whose facing sides carry no label). A
1066
+ // larger in-group net may still end in stub labels, so it keeps its
1067
+ // reserve.
1068
+ if (signal) {
1069
+ const net = netByEndpoint.get(ep);
1070
+ const others = (net?.pins ?? []).filter((other) => other !== ep).map((other) => /^([^.]+)\./.exec(other)?.[1] ?? '');
1071
+ const leaves = !net || net.pins.length > MAX_WIRED_ENDPOINTS || others.some((ref) => partByRef.get(ref)?.group !== inst!.part.group);
1072
+ // only when every other endpoint is a two-lead part (or a test
1073
+ // point): those hang on the pin and are wired by construction; two
1074
+ // ICs of one group may still sit past wire span and take labels
1075
+ const hangs = others.every((ref) => (symbols.get(ref)?.pins.length ?? 3) <= 2);
1076
+ if (!leaves && hangs) continue;
1077
+ }
1078
+ // a signal: stub plus the label's flag (text, margins and tip); a
1079
+ // rail or ground on a sideways pin: the longer power stub, the bar,
1080
+ // and the name drawn outward in line
1081
+ const extent = signal
1082
+ ? STUB * U + Math.max(1, signal.length) * TEXT_RESERVE * LABEL_HEIGHT + FLAG_PAD
1083
+ : (STUB + 2) * U + 1.905 + Math.max(1, power!.name.length) * TEXT_RESERVE * LABEL_HEIGHT;
604
1084
  if (o.dx === -1) left = Math.max(left, extent);
605
1085
  else right = Math.max(right, extent);
606
1086
  }
@@ -608,9 +1088,10 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
608
1088
  return { left, right };
609
1089
  };
610
1090
  /** Extra gap units so facing label text fits a boundary whose body-to-body
611
- * clearance is `baseUnits` (one unit of slack between the two texts). */
1091
+ * clearance is `baseUnits`: the two texts, the box padding each group draws
1092
+ * around its own text (`BOX_PAD` a side), and the gap between the boxes. */
612
1093
  const widenBy = (rightOfPrev: number, leftOfNext: number, baseUnits: number): number =>
613
- Math.max(0, ceilU(rightOfPrev + leftOfNext) + 1 - baseUnits);
1094
+ Math.max(0, ceilU(rightOfPrev + leftOfNext) + 2 * BOX_PAD + BOX_GAP - baseUnits);
614
1095
 
615
1096
  // ---------- group ordering: hints, then SUBSYSTEMS.md order, then name ----------
616
1097
  const groupNames = [...new Set(intent.parts.filter((p) => !symbols.get(p.ref)!.isPower).map((p) => p.group))];
@@ -627,12 +1108,20 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
627
1108
  const placed = new Map<string, Placed>();
628
1109
  const groupRects: { name: string; x1: number; y1: number; x2: number; y2: number }[] = [];
629
1110
  const groupOf = new Map<string, string>();
1111
+ /** Part key -> the anchor it was hung on or laid beside. A bound part is
1112
+ * wired to its anchor whatever the distance: the shelf placed it there
1113
+ * deliberately, and a lane past the wire span still belongs to its pin. */
1114
+ const boundTo = new Map<string, string>();
630
1115
  const groupExtents = new Map<string, { left: number; right: number }>();
631
1116
  for (const gname of groupNames) {
632
- groupExtents.set(
633
- gname,
634
- labelExtents(instances.filter((i) => i.part.group === gname && !i.sym.isPower).map((i) => i.key)),
635
- );
1117
+ // The estimate reserves a label's width beside every pin that might take
1118
+ // one; the drawing seldom uses a tenth of it. A round that has seen the
1119
+ // drawing reserves what its box actually grew past its cells, plus a
1120
+ // unit.
1121
+ const m = reachMeasured?.get(gname);
1122
+ 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));
1123
+ const r = reserves.get(gname);
1124
+ groupExtents.set(gname, { left: e.left + (r?.left ?? 0), right: e.right + (r?.right ?? 0) });
636
1125
  }
637
1126
 
638
1127
  /**
@@ -684,23 +1173,77 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
684
1173
  }
685
1174
  }
686
1175
  }
687
- for (let iter = 0; iter < members.length; iter++) {
688
- let changed = false;
1176
+ // The IC leads its group. Edges above are stored refdes-ordered, and
1177
+ // C, D, J, L, Q and R all sort before U, so plain propagation pushed
1178
+ // every IC to the deepest column on the right with its passives strung
1179
+ // out to its left — a reader looked for the part the group is about and
1180
+ // found it last. When a group has ICs (three or more pins, not a
1181
+ // connector), they anchor the first column after the connectors and
1182
+ // every other member sits at its signal-net hop distance from the
1183
+ // nearest IC; a member no signal path reaches stays beside the ICs.
1184
+ // A group with no IC keeps the edge propagation, where the connector
1185
+ // column is the only anchor there is.
1186
+ // A transistor is a three-pin part, but it is not what a group is
1187
+ // about: it belongs at the end of the run that drives its base, the way
1188
+ // a hand-drawn sheet puts a level shifter or a switch right on the pin.
1189
+ const groupIC = (key: string): boolean => instByKey.get(key)!.sym.pins.length >= 3 && !isConnector(instByKey.get(key)!.part) && !isTransistor(key);
1190
+ const anchors = members.filter((m) => groupIC(m.key)).map((m) => m.key);
1191
+ if (anchors.length) {
1192
+ // IC to IC, the signal flow still propagates (a chain of forty
1193
+ // stages is forty columns, not one), refdes-ordered as before
1194
+ const icSet = new Set(anchors);
1195
+ for (let iter = 0; iter < anchors.length; iter++) {
1196
+ let changed = false;
1197
+ for (const e of edges) {
1198
+ if (!icSet.has(e.from) || !icSet.has(e.to)) continue;
1199
+ const want = depth.get(e.from)! + 1;
1200
+ if (depth.get(e.to)! < want) {
1201
+ depth.set(e.to, want);
1202
+ changed = true;
1203
+ }
1204
+ }
1205
+ if (!changed) break;
1206
+ }
1207
+ // then every other member at its hop distance past the nearest IC,
1208
+ // never routing through a connector or another IC
1209
+ const adj = new Map<string, string[]>();
689
1210
  for (const e of edges) {
690
- const want = (depth.get(e.from) ?? 0) + 1;
691
- if ((depth.get(e.to) ?? 0) < want && want <= members.length) {
692
- depth.set(e.to, want);
693
- changed = true;
1211
+ adj.set(e.from, [...(adj.get(e.from) ?? []), e.to]);
1212
+ adj.set(e.to, [...(adj.get(e.to) ?? []), e.from]);
1213
+ }
1214
+ const dist = new Map<string, number>(anchors.map((k) => [k, depth.get(k)!]));
1215
+ const queue = [...anchors].sort((a, b) => dist.get(a)! - dist.get(b)!);
1216
+ while (queue.length) {
1217
+ const k = queue.shift()!;
1218
+ for (const o of adj.get(k) ?? []) {
1219
+ if (dist.has(o) || isConnector(instByKey.get(o)!.part)) continue;
1220
+ dist.set(o, dist.get(k)! + 1);
1221
+ queue.push(o);
694
1222
  }
695
1223
  }
696
- if (!changed) break;
1224
+ for (const [k, d] of dist) if (!icSet.has(k)) depth.set(k, d);
1225
+ } else {
1226
+ for (let iter = 0; iter < members.length; iter++) {
1227
+ let changed = false;
1228
+ for (const e of edges) {
1229
+ const want = (depth.get(e.from) ?? 0) + 1;
1230
+ if ((depth.get(e.to) ?? 0) < want && want <= members.length) {
1231
+ depth.set(e.to, want);
1232
+ changed = true;
1233
+ }
1234
+ }
1235
+ if (!changed) break;
1236
+ }
697
1237
  }
698
1238
  const depths = [...new Set([...depth.values()])].sort((a, b) => a - b);
699
1239
  const columns: string[][] = depths.map((d) => members.filter((m) => depth.get(m.key) === d).map((m) => m.key));
700
1240
 
701
- // barycenter row ordering (two sweeps), refdes as the deterministic tie
1241
+ // barycenter row ordering (two sweeps), refdes as the deterministic
1242
+ // tie; an IC stays at the top of its column so the group reads from
1243
+ // its top-left corner
702
1244
  const rowOf = new Map<string, number>();
703
- columns.forEach((col) => col.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i)));
1245
+ const lead = (ref: string): number => (groupIC(ref) ? 0 : 1);
1246
+ columns.forEach((col) => col.sort((a, b) => lead(a) - lead(b) || a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i)));
704
1247
  for (let sweep = 0; sweep < 2; sweep++) {
705
1248
  for (let ci = 1; ci < columns.length; ci++) {
706
1249
  const col = columns[ci]!;
@@ -712,59 +1255,857 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
712
1255
  if (!neigh.length) return rowOf.get(ref)!;
713
1256
  return neigh.reduce((s, o) => s + rowOf.get(o)!, 0) / neigh.length;
714
1257
  };
715
- col.sort((a, b) => bary(a) - bary(b) || a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i));
1258
+ 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));
716
1259
  }
717
1260
  }
718
1261
 
719
1262
  // cells: sized from body plus margins, positions snapped to the grid
720
- const cellDims = new Map<string, { w: number; h: number; body: Bounds }>();
1263
+ const cellDims = new Map<string, { w: number; h: number; body: Bounds; shelfL: number; shelfR: number; topPad: number; usedL?: number; usedR?: number; reachL?: number; reachR?: number }>();
721
1264
  for (const m of members) {
722
1265
  const b = bodyBoundsOf(m.sym);
1266
+ const mo = measured?.get(m.key);
1267
+ if (mo) {
1268
+ // the cell is what the previous round drew around this body, a pad
1269
+ // past it on every side: the body sits at shelfL + MARGIN from the
1270
+ // cell's left edge and topPad + MARGIN from its top
1271
+ const shelfL = Math.max(0, ceilU(mo.left) + MEASURE_PAD - MARGIN);
1272
+ const shelfR = Math.max(0, ceilU(mo.right) + MEASURE_PAD - MARGIN);
1273
+ const topPad = Math.max(0, ceilU(mo.top) + MEASURE_PAD - VMARGIN);
1274
+ const below = Math.max(VMARGIN, ceilU(mo.bottom) + MEASURE_PAD);
1275
+ cellDims.set(m.key, {
1276
+ w: shelfL + MARGIN + ceilU(b.maxX - b.minX) + MARGIN + shelfR,
1277
+ h: topPad + VMARGIN + ceilU(b.maxY - b.minY) + below,
1278
+ body: b,
1279
+ shelfL,
1280
+ shelfR,
1281
+ topPad,
1282
+ });
1283
+ continue;
1284
+ }
1285
+ // a rail or ground on a pin facing up or down draws a stub, a bar and
1286
+ // a name past the body: that pin's side reserves the symbol's reach
1287
+ const powered = (dy: number): boolean =>
1288
+ 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');
1289
+ const topReach = powered(-1) ? POWER_REACH : 0;
1290
+ const botReach = powered(1) ? POWER_REACH : 0;
723
1291
  cellDims.set(m.key, {
724
1292
  w: ceilU(b.maxX - b.minX) + 2 * MARGIN,
725
- h: ceilU(b.maxY - b.minY) + 2 * MARGIN,
1293
+ h: Math.max(0, topReach - VMARGIN) + VMARGIN + ceilU(b.maxY - b.minY) + VMARGIN + Math.max(0, botReach - VMARGIN),
726
1294
  body: b,
1295
+ shelfL: 0,
1296
+ shelfR: 0,
1297
+ topPad: Math.max(0, topReach - VMARGIN),
727
1298
  });
728
1299
  }
1300
+
1301
+ // ---------- pin-anchored hangs (#220 phase 3) ----------
1302
+ // A drafter puts a part on the pin it serves: the compensation network
1303
+ // hangs off COMP, the bootstrap cap sits on BOOT, a pull-down drops from
1304
+ // its pin. Column placement put every such part in a column of its own
1305
+ // and let the wire pass decide later, so most of them ended as labelled
1306
+ // islands. Here, before any column is packed, each IC's side pins are
1307
+ // read in order and the chain of vertical two-lead parts a pin's net
1308
+ // leads into is claimed as a HANG: it will be drawn below the pin's
1309
+ // row in a shelf beside the IC, at the first slot whose rows are free,
1310
+ // and the IC's cell grows by the shelf so the columns start past it.
1311
+ // Hung parts leave the columns now; the chain pass below places them
1312
+ // once the IC has coordinates, with the same clearance check every
1313
+ // idiom uses, and a hang that cannot be placed cleanly falls back to a
1314
+ // column at the group's right edge.
1315
+ type Hang = { ic: string; pin: DraftPin; dx: -1 | 1; dir: -1 | 1; chain: string[]; slot: number; straight: boolean; w: number; h: number; ends: 'power' | 'open' };
1316
+ /** A run of two-lead parts laid ALONG the pin's row, away from the IC. */
1317
+ type Inline = { ic: string; pin: DraftPin; dx: -1 | 1; chain: { key: string; nearPin: string; gap: number }[]; len: number; farNet: string | null; offset: number };
1318
+ const hangs: Hang[] = [];
1319
+ const inlines: Inline[] = [];
1320
+ const hungKeys = new Set<string>();
1321
+ for (const [k, v] of boundTo) if (groupOf.get(k) === gname) boundTo.delete(k), void v;
1322
+ /**
1323
+ * The rotation (0, 90, 180, 270) that turns two-lead part `key` so that
1324
+ * pin `nearPin` faces direction `want` (schematic dx/dy) and its other
1325
+ * lead faces the opposite way; null when the part is not a two-lead
1326
+ * part with opposed leads. This is what lets a diode, LED, fuse or
1327
+ * switch (horizontal leads in the library) hang like a resistor, and a
1328
+ * resistor lie along a row like a diode.
1329
+ */
1330
+ /** A test point: one pin, probing the net it sits on. It hangs beside that net's pin. */
1331
+ const isTestPoint = (key: string): boolean => {
1332
+ const inst = instByKey.get(key);
1333
+ return !!inst && inst.sym.pins.length === 1 && (/TestPoint/i.test(inst.part.libId) || /^TP\d/.test(inst.ref));
1334
+ };
1335
+
1336
+ const orientFor = (key: string, nearPin: string, want: { dx: number; dy: number }): number | null => {
1337
+ const pins = instByKey.get(key)?.sym.pins ?? [];
1338
+ if (pins.length !== 2 && !isTestPoint(key)) return null;
1339
+ for (const rot of [0, 90, 180, 270]) {
1340
+ const rs = rotatedSym(instByKey.get(key)!.sym, rot);
1341
+ const near = rs.pins.find((p) => p.number === nearPin);
1342
+ if (!near) return null;
1343
+ const on = outward(near);
1344
+ if (on.dx !== want.dx || on.dy !== want.dy) continue;
1345
+ if (pins.length === 1) return rot;
1346
+ const other = rs.pins.find((p) => p.number !== nearPin)!;
1347
+ const oo = outward(other);
1348
+ if (oo.dx === -want.dx && oo.dy === -want.dy) return rot;
1349
+ }
1350
+ return null;
1351
+ };
1352
+ /** Both leads of a two-lead part, un-rotated; a test point's one pin twice. */
1353
+ const leadsOf = (key: string): { a: DraftPin; b: DraftPin } | null => {
1354
+ const pins = instByKey.get(key)?.sym.pins ?? [];
1355
+ if (isTestPoint(key)) return { a: pins[0]!, b: pins[0]! };
1356
+ if (pins.length !== 2) return null;
1357
+ const oa = outward(pins[0]!);
1358
+ const ob = outward(pins[1]!);
1359
+ if (oa.dx !== -ob.dx || oa.dy !== -ob.dy) return null; // leads must oppose
1360
+ return { a: pins[0]!, b: pins[1]! };
1361
+ };
1362
+ const hangable = (key: string): boolean =>
1363
+ memberKeySet.has(key) &&
1364
+ !decapOwner.has(key) &&
1365
+ !hungKeys.has(key) &&
1366
+ !isConnector(instByKey.get(key)!.part) &&
1367
+ !/crystal|reson/i.test(instByKey.get(key)?.part.libId ?? '') &&
1368
+ leadsOf(key) !== null;
1369
+ /** Lead span along the hang or run, mm: connection point to connection
1370
+ * point, or for a test point the body's reach past its one pin. */
1371
+ const spanOf = (key: string, rot = 0): number => {
1372
+ const ctl = controlPinOf(key);
1373
+ if (ctl) {
1374
+ const b = bodyBoundsOf(instByKey.get(key)!.sym);
1375
+ return Math.max(b.maxX - ctl.x, ctl.x - b.minX);
1376
+ }
1377
+ const l = leadsOf(key)!;
1378
+ if (isTestPoint(key)) {
1379
+ const rs = rotatedSym(instByKey.get(key)!.sym, rot);
1380
+ const pin = rs.pins[0]!;
1381
+ const b = bodyBoundsOf(rs);
1382
+ const o = outward(pin);
1383
+ // the body lies opposite the pin's outward direction
1384
+ 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);
1385
+ }
1386
+ return Math.hypot(l.a.x - l.b.x, l.a.y - l.b.y);
1387
+ };
1388
+ const INLINE_GAP = 4 * U;
1389
+ /**
1390
+ * Claim a hang from IC pin `pin` starting at `first` (an endpoint of the
1391
+ * pin's net). `dir` 1 hangs down from the row, -1 rises above it; the
1392
+ * chain continues through two-endpoint links to a rail, a ground or a
1393
+ * tapped node. The part is turned so its near lead faces the row.
1394
+ */
1395
+ const claimHang = (icKey: string, pin: DraftPin, dx: -1 | 1, first: { key: string; pin: string }, dir: -1 | 1): void => {
1396
+ const chain = [first.key];
1397
+ const nearPins = [first.pin];
1398
+ hungKeys.add(first.key);
1399
+ boundTo.set(first.key, icKey);
1400
+ let ends: 'power' | 'open' = 'open';
1401
+ for (;;) {
1402
+ const cur = chain[chain.length - 1]!;
1403
+ if (isTestPoint(cur)) break;
1404
+ const l = leadsOf(cur)!;
1405
+ const near = nearPins[nearPins.length - 1]!;
1406
+ const far = l.a.number === near ? l.b : l.a;
1407
+ const beyond = netByEndpoint.get(`${instByKey.get(cur)!.ref}.${far.number}`);
1408
+ if (!beyond) break;
1409
+ if ((netClasses.get(beyond.name)?.cls ?? 'signal') !== 'signal') {
1410
+ ends = 'power';
1411
+ break;
1412
+ }
1413
+ if (beyond.pins.length !== 2) break;
1414
+ const next = beyond.pins.map(epKey).find((e) => e !== null && e.key !== cur);
1415
+ if (!next || !hangable(next.key) || chain.includes(next.key)) break;
1416
+ chain.push(next.key);
1417
+ nearPins.push(next.pin);
1418
+ hungKeys.add(next.key);
1419
+ boundTo.set(next.key, icKey);
1420
+ }
1421
+ let h = 0;
1422
+ let w = 0;
1423
+ for (const key of chain) {
1424
+ const inst = instByKey.get(key)!;
1425
+ // turned to hang: the body's extent across the axis and its span along it
1426
+ const rotH = orientFor(key, nearPins[chain.indexOf(key)]!, { dx: 0, dy: -dir }) ?? 0;
1427
+ const rs = rotatedSym(inst.sym, rotH);
1428
+ const b = bodyBoundsOf(rs);
1429
+ h += spanOf(key, rotH) + HANG_GAP;
1430
+ // a hung part is narrow: its body, the reference and value
1431
+ // beside it, and a unit of air — not the column cell's margins
1432
+ const fieldW = Math.max(inst.ref.length + 1, inst.part.value.length) * TEXT_RESERVE * LABEL_HEIGHT + 1.27;
1433
+ w = Math.max(w, b.maxX - b.minX + fieldW + U);
1434
+ }
1435
+ if (ends === 'power') h += HANG_POWER_END;
1436
+ hangs.push({ ic: icKey, pin, dx, dir, chain, slot: 0, straight: false, w, h, ends });
1437
+ hangNearPins.set(chain.join('+'), nearPins);
1438
+ };
1439
+ const hangNearPins = new Map<string, string[]>();
1440
+ const placedOffset = new Set<Inline>();
1441
+ /**
1442
+ * Claim a series run along the row from IC pin `pin`: `first` lies on
1443
+ * the row with its near lead toward the IC, and the run continues
1444
+ * through two-endpoint links while the next part is a two-lead part.
1445
+ * The far end gets whatever the wire pass gives it: a wire to a
1446
+ * connector on the row, or a label.
1447
+ */
1448
+ /** Gap before a part on a row: room for the wired net's name above the wire, at least one plain gap. */
1449
+ const gapFor = (netName: string): number => Math.max(INLINE_GAP, grid(ceilU(Math.max(1, netName.length) * TEXT_RESERVE * LABEL_HEIGHT + 2 * U) ));
1450
+ const claimInline = (icKey: string, pin: DraftPin, dx: -1 | 1, first: { key: string; pin: string }): void => {
1451
+ const pinNet = netByEndpoint.get(`${instByKey.get(icKey)!.ref}.${pin.number}`)?.name ?? '';
1452
+ const chain: { key: string; nearPin: string; gap: number }[] = [{ key: first.key, nearPin: first.pin, gap: gapFor(pinNet) }];
1453
+ hungKeys.add(first.key);
1454
+ boundTo.set(first.key, icKey);
1455
+ let farNet: string | null = null;
1456
+ for (let n = 0; n < 3; n++) {
1457
+ const cur = chain[chain.length - 1]!;
1458
+ if (isTransistor(cur.key)) {
1459
+ farNet = null; // the run ends in the transistor; its other pins take what the wire pass gives
1460
+ break;
1461
+ }
1462
+ const l = leadsOf(cur.key)!;
1463
+ const far = l.a.number === cur.nearPin ? l.b : l.a;
1464
+ const beyond = netByEndpoint.get(`${instByKey.get(cur.key)!.ref}.${far.number}`);
1465
+ farNet = beyond?.name ?? null;
1466
+ if (!beyond || beyond.pins.length !== 2) break;
1467
+ const next = beyond.pins.map(epKey).find((e) => e !== null && e.key !== cur.key);
1468
+ if (!next || chain.some((c) => c.key === next.key)) break;
1469
+ // a series resistor into a base or gate: the transistor ends the run,
1470
+ // turned so that pin faces the part that drives it
1471
+ if (isTransistor(next.key)) {
1472
+ if (!memberKeySet.has(next.key) || hungKeys.has(next.key) || controlPinOf(next.key)!.number !== next.pin) break;
1473
+ chain.push({ key: next.key, nearPin: next.pin, gap: gapFor(beyond.name) });
1474
+ hungKeys.add(next.key);
1475
+ boundTo.set(next.key, icKey);
1476
+ farNet = null;
1477
+ break;
1478
+ }
1479
+ if (!hangable(next.key)) break;
1480
+ chain.push({ key: next.key, nearPin: next.pin, gap: gapFor(beyond.name) });
1481
+ hungKeys.add(next.key);
1482
+ boundTo.set(next.key, icKey);
1483
+ }
1484
+ let len = 0;
1485
+ for (const c of chain) len += c.gap + spanOf(c.key);
1486
+ // room for the label the far end may carry
1487
+ if (farNet) len += STUB * U + Math.max(1, farNet.length) * TEXT_RESERVE * LABEL_HEIGHT;
1488
+ inlines.push({ ic: icKey, pin, dx, chain, len, farNet, offset: 0 });
1489
+ };
1490
+ const epKey = (ep: string): { key: string; pin: string } | null => {
1491
+ const m = /^([^.]+)\.(.+)$/.exec(ep);
1492
+ return m ? { key: instKeyOf(m[1]!, m[2]!), pin: m[2]! } : null;
1493
+ };
1494
+ // Three units, not four: a hung part's near stub ends one unit past
1495
+ // the gap, and pins sit two units apart, so an even gap parks that
1496
+ // stub end exactly on the neighbouring pin's row, where any run along
1497
+ // that row would join it (every single pull-up beside a pull-down on
1498
+ // the next pin was refused for this). An odd gap keeps it between rows.
1499
+ const HANG_GAP = 3 * U;
1500
+ /**
1501
+ * How far the labels on one side of an anchor actually reach, given
1502
+ * what has been claimed on it: a pin whose parts hang carries no stub
1503
+ * label any more, only its run's name (and only when the net has
1504
+ * endpoints elsewhere), so the lanes may start that much closer to the
1505
+ * IC. `labelExtents` assumes a stub label on every pin and put the
1506
+ * lanes 15 mm further out than the drawing needed.
1507
+ */
1508
+ const sideReach = (icKey: string, dx: -1 | 1): number => {
1509
+ const ic = instByKey.get(icKey)!;
1510
+ let reach = 0;
1511
+ for (const pin of ic.sym.pins) {
1512
+ if (outward(pin).dx !== dx) continue;
1513
+ const net = netByEndpoint.get(`${ic.ref}.${pin.number}`);
1514
+ if (!net) continue;
1515
+ const nameW = Math.max(1, net.name.length) * TEXT_RESERVE * LABEL_HEIGHT;
1516
+ if ((netClasses.get(net.name)?.cls ?? 'signal') !== 'signal') {
1517
+ reach = Math.max(reach, (STUB + 2) * U + 1.905 + nameW);
1518
+ continue;
1519
+ }
1520
+ const eps = net.pins.map(epKey);
1521
+ const claimed = eps.some((e) => e !== null && boundTo.get(e.key) === icKey);
1522
+ const elsewhere = eps.some((e) => e === null || (e.key !== icKey && !memberKeySet.has(e.key) && !decapOwner.has(e.key)));
1523
+ if (claimed) reach = Math.max(reach, elsewhere ? nameW + FLAG_PAD + U : STUB * U);
1524
+ else reach = Math.max(reach, STUB * U + nameW + FLAG_PAD);
1525
+ }
1526
+ return reach;
1527
+ };
1528
+ const HANG_POWER_END = 5 * U;
1529
+ /** Rows a power symbol and its name need past a pin: stub, bar, text. */
1530
+ const POWER_CLEAR_ROWS = 6;
1531
+ // Parts hang on ICs first, then on connectors and switches: a fuse
1532
+ // rises from the jack's pin, a button's pull-up, debounce cap and ESD
1533
+ // diode hang on the switch's signal pin. A switch already hung on an
1534
+ // IC pin (a reset button dropping from EN) anchors nothing itself.
1535
+ const claimAnchors = [
1536
+ ...anchors,
1537
+ ...members.filter((m) => !anchors.includes(m.key) && (isConnector(m.part) || isSwitch(m.key))).map((m) => m.key),
1538
+ ];
1539
+ for (const icKey of claimAnchors) {
1540
+ if (hungKeys.has(icKey)) continue;
1541
+ const ic = instByKey.get(icKey)!;
1542
+ const sidePins = ic.sym.pins
1543
+ .filter((p) => outward(p).dx !== 0)
1544
+ .sort((a, b) => b.y - a.y || a.x - b.x); // top row first (symbol y is up)
1545
+ for (const pin of sidePins) {
1546
+ const dx = outward(pin).dx as -1 | 1;
1547
+ const net = netByEndpoint.get(`${ic.ref}.${pin.number}`);
1548
+ if (!net || (netClasses.get(net.name)?.cls ?? 'signal') !== 'signal') continue;
1549
+ // the wire pass draws this net only if it stays in the group with
1550
+ // few endpoints; a net that will be labelled anyway gives no hang
1551
+ const eps = net.pins.map(epKey).filter((e): e is { key: string; pin: string } => e !== null);
1552
+ // A net that leaves the group or carries more endpoints than the
1553
+ // wire pass joins still hangs its local parts: the pull-up on a
1554
+ // fault line that also goes to the MCU, the RC on a button the
1555
+ // MCU reads. The wire pass joins the pin and its hung parts as
1556
+ // one local run and labels it once for the rest of the net
1557
+ // (esp32-amp shipped fourteen such parts as labelled islands).
1558
+ if (eps.length > MAX_WIRED_ENDPOINTS) trace(`${ic.ref}.${pin.number} ${net.name}: ${eps.length} endpoints, local parts still hang`);
1559
+ // a crystal's load caps belong to the flanking idiom below
1560
+ if (eps.some((e) => /crystal|reson/i.test(instByKey.get(e.key)?.part.libId ?? ''))) continue;
1561
+ // the chain enters the first part through whichever lead is on the
1562
+ // pin's net: through the top lead it hangs DOWN from the row,
1563
+ // through the bottom lead it rises above it (the pull-up shape)
1564
+ // every hangable endpoint of the pin's net hangs, each as its own
1565
+ // chain: a compensation network is a series RC AND a shunt C on
1566
+ // one pin, and leaving the shunt in a far column keeps the whole
1567
+ // net past wire span, labelled
1568
+ /** The net on the lead of `key` that is NOT `nearPin`. */
1569
+ const farNetOf = (key: string, nearPin: string): IntentNet | undefined => {
1570
+ const l = leadsOf(key)!;
1571
+ const far = l.a.number === nearPin ? l.b : l.a;
1572
+ return netByEndpoint.get(`${instByKey.get(key)!.ref}.${far.number}`);
1573
+ };
1574
+ /** Bridges from this pin to another pin of the IC, resolved after the pin's other parts. */
1575
+ const pinBridges: { first: { key: string; pin: string }; otherPin: DraftPin; othersOnPin: boolean }[] = [];
1576
+ for (const first of eps) {
1577
+ if (first.key === icKey) continue;
1578
+ // a transistor driven straight from the pin lies on the row, base to the pin
1579
+ if (isTransistor(first.key) && memberKeySet.has(first.key) && !hungKeys.has(first.key) && controlPinOf(first.key)!.number === first.pin) {
1580
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key)!.ref} lies on the row (transistor)`);
1581
+ claimInline(icKey, pin, dx, first);
1582
+ continue;
1583
+ }
1584
+ if (!hangable(first.key)) {
1585
+ 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';
1586
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key)!.ref} not hangable (${why})`);
1587
+ continue;
1588
+ }
1589
+ // a test point rises above the row of the pin it probes
1590
+ if (isTestPoint(first.key)) {
1591
+ claimHang(icKey, pin, dx, first, -1);
1592
+ continue;
1593
+ }
1594
+ // Look to the END of the run this part starts, through two-endpoint
1595
+ // links: a run that ends on a rail or ground is a SHUNT and hangs
1596
+ // from the row — rising to a rail (a pull-up), dropping to ground
1597
+ // (a pull-down, a filter cap, a series RC to ground). A run whose
1598
+ // far net returns to a pin of this IC BRIDGES the two pins and
1599
+ // hangs toward the other pin's row so that net stays within wire
1600
+ // span (a bootstrap cap between BST and OUT). Everything else is
1601
+ // a SERIES element — a 100 Ω in an I2S line, an output inductor —
1602
+ // and lies along the row, away from the IC, the way the reference
1603
+ // sheet draws it.
1604
+ let cur = first;
1605
+ let farNet = farNetOf(cur.key, cur.pin);
1606
+ const seen = new Set([first.key]);
1607
+ for (let n = 0; n < 4 && farNet && (netClasses.get(farNet.name)?.cls ?? 'signal') === 'signal' && farNet.pins.length === 2; n++) {
1608
+ const next = farNet.pins.map(epKey).find((e) => e !== null && e.key !== cur.key);
1609
+ if (!next || !hangable(next.key) || seen.has(next.key)) break;
1610
+ seen.add(next.key);
1611
+ cur = next;
1612
+ farNet = farNetOf(cur.key, cur.pin);
1613
+ }
1614
+ const farCls = farNet ? (netClasses.get(farNet.name)?.cls ?? 'signal') : null;
1615
+ // A bridge (the far net returns to another pin of this IC) lies
1616
+ // on the row of the pin that has NOTHING ELSE on it: claimed
1617
+ // from the busier pin it blocked that pin's row, and the series
1618
+ // part behind it could not be wired (esp32-amp's C28 on OUT_LN
1619
+ // left L3 a labelled island while C27 on BST_AP let L2 wire).
1620
+ const bridgeEp = cur.key === first.key && farNet && farCls === 'signal' && farNet.pins.length === 2
1621
+ ? farNet.pins.find((ep) => ep.startsWith(`${ic.ref}.`) && ep !== `${ic.ref}.${pin.number}`)
1622
+ : undefined;
1623
+ const otherPin = bridgeEp ? ic.sym.pins.find((p) => `${ic.ref}.${p.number}` === bridgeEp) : undefined;
1624
+ if (otherPin) {
1625
+ const others = eps.filter((e) => e.key !== icKey && e.key !== first.key && (hangable(e.key) || boundTo.get(e.key) === icKey));
1626
+ pinBridges.push({ first, otherPin, othersOnPin: others.length > 0 });
1627
+ continue;
1628
+ }
1629
+ // A part between two pins of this IC (a bootstrap cap between
1630
+ // BST and OUT) lies along its row too: hung, its far lead
1631
+ // landed rows away from the other pin and that pin's branch ran
1632
+ // along a third row under a label. On the row, the far net
1633
+ // reaches the other pin's row through the router's trunk.
1634
+ // A bridge to another pin of this IC (a bootstrap cap between BST
1635
+ // and SW) hangs in a lane toward that pin's row, its far lead
1636
+ // joined to that row by one short jog: laid along the row it sat
1637
+ // past every lane and every series part, and the bank under the
1638
+ // group followed the row out (rails 112 mm wide, 91 by hand).
1639
+ if (farCls === 'ground') {
1640
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key)!.ref} hangs down (to ${farNet!.name})`);
1641
+ claimHang(icKey, pin, dx, first, 1);
1642
+ } else if (farCls === 'rail') {
1643
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key)!.ref} rises (to ${farNet!.name})`);
1644
+ claimHang(icKey, pin, dx, first, -1);
1645
+ } else {
1646
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key)!.ref} lies on the row (far net ${farNet?.name ?? 'none'})`);
1647
+ claimInline(icKey, pin, dx, first);
1648
+ }
1649
+ }
1650
+ // A bridge cap (BST to SW) hangs in the lane this pin already has,
1651
+ // toward the other pin's row, when the pin has a hang to share the
1652
+ // lane with (the buck's L1 rising from SW): one axis, one T, and the
1653
+ // other row reaches the cap's far lead with a single jog. With no
1654
+ // lane of its own to join it stays on the row (hung alone it cost
1655
+ // four attachments on the amplifier's four bootstrap pairs); and it
1656
+ // is left to the other pin when this pin carries series parts and
1657
+ // that pin carries nothing else.
1658
+ const pinHasHang = hangs.some((hg) => hg.ic === icKey && hg.pin.number === pin.number);
1659
+ for (const b of pinBridges) {
1660
+ if (hungKeys.has(b.first.key)) continue;
1661
+ if (pinHasHang) {
1662
+ const dir: -1 | 1 = b.otherPin.y < pin.y ? 1 : -1; // symbol y is up: a lower pin means dropping
1663
+ 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'}`);
1664
+ claimHang(icKey, pin, dx, b.first, dir);
1665
+ } else if (b.othersOnPin) {
1666
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(b.first.key)!.ref} bridges to ${ic.ref}.${b.otherPin.number}; left for that pin`);
1667
+ } else {
1668
+ trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(b.first.key)!.ref} bridges to ${ic.ref}.${b.otherPin.number}; lies on the row`);
1669
+ claimInline(icKey, pin, dx, b.first);
1670
+ }
1671
+ }
1672
+ }
1673
+ // Slots per side. A hang whose rows are free of every other pin on
1674
+ // that side stays dead straight on its stub axis (AC-16.31: the wire
1675
+ // from the pin continues into the chain with no bend); the rest take
1676
+ // the first shelf slot whose rows are free. Rows are relative to the
1677
+ // IC origin, y down; symbol pin y is up.
1678
+ for (const dx of [-1, 1] as const) {
1679
+ const side = hangs.filter((hg) => hg.ic === icKey && hg.dx === dx);
1680
+ const inlineSide = inlines.filter((il) => il.ic === icKey && il.dx === dx);
1681
+ // Two horizontal parts on rows one pitch apart cannot share an x
1682
+ // range: a run on a row next to an earlier run starts past that
1683
+ // run's end (the reference sheet's output filter alternates the
1684
+ // inductor and the cap across its rows for the same reason).
1685
+ // a part lying on a row carries its reference above and its value
1686
+ // below, so its texts reach the neighbouring rows: runs within
1687
+ // three rows of each other stagger (two left C27's value under L3's
1688
+ // reference)
1689
+ const ROWS_NEAR = 3 * 2.54 + 0.01;
1690
+ for (const il of [...inlineSide].sort((a, b) => b.pin.y - a.pin.y)) {
1691
+ let offset = 0;
1692
+ for (;;) {
1693
+ const clash = inlineSide.find(
1694
+ (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,
1695
+ );
1696
+ if (!clash) break;
1697
+ offset = clash.offset + clash.len + INLINE_GAP;
1698
+ }
1699
+ il.offset = offset;
1700
+ placedOffset.add(il);
1701
+ }
1702
+ if (!side.length && !inlineSide.length) continue;
1703
+ // any connected neighbour blocks the straight axis: a signal pin's
1704
+ // stub end sits on it, and a rail or ground pin's longer stub
1705
+ // crosses it — legal, but a wire through the IC's supply stubs is
1706
+ // exactly the crossing a drafter moves a part to avoid (the Tier C
1707
+ // divider drew two of them). A no-connect neighbour blocks nothing.
1708
+ const sidePinYs = ic.sym.pins
1709
+ .filter((p) => outward(p).dx === dx && netByEndpoint.has(`${ic.ref}.${p.number}`))
1710
+ .map((p) => -p.y);
1711
+ const pitch = Math.max(0, ...side.map((hg) => hg.w)) + U;
1712
+ const slots: { top: number; bot: number }[][] = [];
1713
+ let anyShelved = false;
1714
+ // Slot order matters: a lower pin's branch runs along its row from
1715
+ // the stub to its chain's axis, and a chain hanging down from a
1716
+ // higher pin must not cross that row inside the branch's span. So
1717
+ // down-hangs take slots from the lowest pin upward and up-hangs from
1718
+ // the highest pin downward: the chain that passes other pins' rows
1719
+ // is always the outer one, and every branch stays clear of every
1720
+ // sibling's stub end (esp32-amp's RT branch ran into the COMP
1721
+ // hang's stub end the first time).
1722
+ // Slots go to PINS, not hangs: a part rising and a part dropping
1723
+ // from one pin share one axis — the divider on its pin — and meet
1724
+ // the row in a single T. A slot's occupied range runs from the pin
1725
+ // ROW to the chain end, because the branch along the row belongs
1726
+ // to it: measured from the first part instead, a chain rising from
1727
+ // a lower pin shared its slot with the higher pin's drop and the
1728
+ // two axes met on that pin's row (esp32-amp's USB_OV divider was
1729
+ // refused for exactly that, four hangs on two adjacent pins).
1730
+ // One axis carries at most one chain up and one chain down; a
1731
+ // third chain on the same pin (the NTC divider's cap beside its
1732
+ // thermistor) takes the next lane out, and each lane is slotted as
1733
+ // its own group.
1734
+ const byPin = new Map<string, Hang[]>();
1735
+ for (const hg of side) byPin.set(hg.pin.number, [...(byPin.get(hg.pin.number) ?? []), hg]);
1736
+ const pinRow = (g: Hang[]): number => -g[0]!.pin.y;
1737
+ const pinGroups: Hang[][] = [];
1738
+ for (const hgs of byPin.values()) {
1739
+ const ups = hgs.filter((hg) => hg.dir === -1);
1740
+ const downs = hgs.filter((hg) => hg.dir === 1);
1741
+ for (let i = 0; i < Math.max(ups.length, downs.length); i++) {
1742
+ const lane = [ups[i], downs[i]].filter((hg): hg is Hang => hg !== undefined);
1743
+ pinGroups.push(lane);
1744
+ }
1745
+ }
1746
+ const ordered = [
1747
+ ...pinGroups.filter((g) => g.some((hg) => hg.dir === 1)).sort((a, b) => pinRow(b) - pinRow(a)),
1748
+ ...pinGroups.filter((g) => g.every((hg) => hg.dir === -1)).sort((a, b) => pinRow(a) - pinRow(b)),
1749
+ ];
1750
+ const lowRowAll = Math.max(...side.map((hg) => -hg.pin.y));
1751
+ const highRowAll = Math.min(...side.map((hg) => -hg.pin.y));
1752
+ for (const g of ordered) {
1753
+ const rowY = pinRow(g);
1754
+ const top = Math.min(rowY, ...g.map((hg) => (hg.dir === -1 ? highRowAll - HANG_GAP - hg.h : rowY)));
1755
+ const bot = Math.max(rowY, ...g.map((hg) => (hg.dir === 1 ? lowRowAll + HANG_GAP + hg.h : rowY)));
1756
+ // the clearance check pads the axis range by four units past the
1757
+ // pin row and the chain end, so a neighbour that close blocks it
1758
+ // the same five units (four of pad plus one) the clearance check
1759
+ // itself applies, else a neighbour exactly at the edge passes here
1760
+ // and refuses there (J1's fuse against the CC1 pin two rows down)
1761
+ const straight = !sidePinYs.some((y) => y !== rowY && y > top - 5 * U && y < bot + 5 * U);
1762
+ for (const hg of g) hg.straight = straight;
1763
+ if (straight) continue;
1764
+ anyShelved = true;
1765
+ let k = 0;
1766
+ while ((slots[k] ?? []).some((iv) => top < iv.bot + 2 * U && bot > iv.top - 2 * U)) k++;
1767
+ (slots[k] ??= []).push({ top, bot });
1768
+ for (const hg of g) hg.slot = k;
1769
+ }
1770
+ const dims = cellDims.get(icKey)!;
1771
+ const ext = labelExtents([icKey]);
1772
+ // Lanes come first, past the side's labels; the inline runs start
1773
+ // beyond the lanes. A part lying on the row between the stub and
1774
+ // the lanes blocked the row for the pin's own hung parts (Q1 on
1775
+ // VBUS_FET_EN left R17 unreachable), and a run on another row
1776
+ // starting inside the lanes crossed their bodies.
1777
+ const laneReach = sideReach(icKey, dx);
1778
+ void ext;
1779
+ const lanesW = anyShelved ? slots.length * pitch : 0;
1780
+ for (const il of inlineSide) il.offset += anyShelved ? ceilU(laneReach + 2 * U) * U + lanesW : 0;
1781
+ const inlineW = Math.max(0, ...inlineSide.map((il) => il.offset + il.len));
1782
+ // shelf: the IC's own labels, the stub, the lanes, then the runs. A
1783
+ // straight hang sits within the stub and the cell's own margin and
1784
+ // channel, so a side whose hangs are all straight and carries no
1785
+ // inline run reserves nothing (a five-part board went from A5 to A4
1786
+ // when it did).
1787
+ 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;
1788
+ if (!measured?.has(icKey)) {
1789
+ if (dx === 1) dims.shelfR = shelf;
1790
+ else dims.shelfL = shelf;
1791
+ dims.w += shelf;
1792
+ }
1793
+ // y-down relative to the IC origin: the body spans -maxY..-minY
1794
+ const lowRow = Math.max(...side.map((hg) => -hg.pin.y));
1795
+ const highRow = Math.min(...side.map((hg) => -hg.pin.y));
1796
+ 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));
1797
+ // what this side uses below the body's top, and how far its labels
1798
+ // reach: the shelf below that is free for the infill pass
1799
+ 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));
1800
+ const usedBelow = ceilU(laneDepth - -dims.body.maxY) + 2;
1801
+ if (dx === 1) {
1802
+ dims.usedR = usedBelow;
1803
+ dims.reachR = ceilU(laneReach + 2 * U);
1804
+ } else {
1805
+ dims.usedL = usedBelow;
1806
+ dims.reachL = ceilU(laneReach + 2 * U);
1807
+ }
1808
+ 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)));
1809
+ // an inline run lies on its row; a transistor at its end reaches two
1810
+ // rows above and below it (collector and emitter stubs)
1811
+ for (const il of inlineSide) {
1812
+ for (const c of il.chain) {
1813
+ if (!controlPinOf(c.key)) continue;
1814
+ const b = bodyBoundsOf(instByKey.get(c.key)!.sym);
1815
+ const reach = Math.max(b.maxY, -b.minY) + STUB * U + POWER_CLEAR_ROWS * U;
1816
+ if (measured?.has(icKey)) continue;
1817
+ dims.topPad = Math.max(dims.topPad, ceilU(-dims.body.maxY - (-il.pin.y - reach)));
1818
+ dims.h = Math.max(dims.h, ceilU(Math.max(deepest, -il.pin.y + reach) - Math.min(highest, -il.pin.y - reach)) + VMARGIN + 2);
1819
+ }
1820
+ }
1821
+ if (!measured?.has(icKey)) {
1822
+ dims.topPad = Math.max(dims.topPad, ceilU(-dims.body.maxY - highest));
1823
+ // the chain's end already carries the symbol's own room; two
1824
+ // units of air below it, not a full margin (a button block's cell
1825
+ // was 39 mm tall where a drafter draws it on a 25 mm pitch)
1826
+ dims.h = Math.max(dims.h, ceilU(deepest - highest) + VMARGIN + 2);
1827
+ }
1828
+ }
1829
+ }
1830
+ // Node hangs: the far end of a series run is a node too. An RC filter's
1831
+ // cap, a button's debounce cap and ESD diode sit on the net PAST the
1832
+ // series resistor, so they hang from the run's last part, dropping or
1833
+ // rising from the row just beyond its far lead, the first straight down
1834
+ // from the row's end and the rest on lanes past it.
1835
+ const nodeAnchors = new Set<string>();
1836
+ for (const il of [...inlines]) {
1837
+ const last = il.chain[il.chain.length - 1]!;
1838
+ if (isTransistor(last.key)) continue;
1839
+ const l = leadsOf(last.key)!;
1840
+ const farPin = l.a.number === last.nearPin ? l.b : l.a;
1841
+ const farNet = netByEndpoint.get(`${instByKey.get(last.key)!.ref}.${farPin.number}`);
1842
+ if (!farNet || (netClasses.get(farNet.name)?.cls ?? 'signal') !== 'signal') continue;
1843
+ const eps = farNet.pins.map(epKey).filter((e): e is { key: string; pin: string } => e !== null);
1844
+ let lanes = 0;
1845
+ for (const first of eps) {
1846
+ if (first.key === last.key || !hangable(first.key)) continue;
1847
+ const fl = leadsOf(first.key)!;
1848
+ const far = fl.a.number === first.pin ? fl.b : fl.a;
1849
+ const beyond = netByEndpoint.get(`${instByKey.get(first.key)!.ref}.${far.number}`);
1850
+ const cls = beyond ? (netClasses.get(beyond.name)?.cls ?? 'signal') : null;
1851
+ if (cls !== 'ground' && cls !== 'rail') continue; // only shunts; a second series part is a network
1852
+ trace(`${instByKey.get(last.key)!.ref}.${farPin.number} ${farNet.name} (node): ${instByKey.get(first.key)!.ref} ${cls === 'ground' ? 'hangs down' : 'rises'}`);
1853
+ claimHang(last.key, farPin, il.dx, first, cls === 'ground' ? 1 : -1);
1854
+ const hg = hangs[hangs.length - 1]!;
1855
+ hg.slot = lanes;
1856
+ hg.straight = lanes === 0;
1857
+ lanes++;
1858
+ nodeAnchors.add(last.key);
1859
+ }
1860
+ if (!lanes) continue;
1861
+ // the run's owner reserves the lanes and the drop below them
1862
+ const nodeHangs = hangs.filter((hg) => hg.ic === last.key);
1863
+ const pitch = Math.max(0, ...nodeHangs.map((hg) => hg.w)) + U;
1864
+ const add = (lanes - 1) * pitch + 2 * U + Math.max(0, ...nodeHangs.map((hg) => hg.w)) / 2;
1865
+ il.len += add;
1866
+ const dims = cellDims.get(il.ic)!;
1867
+ if (measured?.has(il.ic)) continue;
1868
+ if (il.dx === 1) dims.shelfR += ceilU(add);
1869
+ else dims.shelfL += ceilU(add);
1870
+ dims.w += ceilU(add);
1871
+ const rowRel = -il.pin.y;
1872
+ const hDown = Math.max(0, ...nodeHangs.filter((hg) => hg.dir === 1).map((hg) => hg.h));
1873
+ const hUp = Math.max(0, ...nodeHangs.filter((hg) => hg.dir === -1).map((hg) => hg.h));
1874
+ const bottom = Math.max(-dims.body.minY, rowRel + HANG_GAP + hDown);
1875
+ const top = Math.min(-dims.body.maxY, rowRel - HANG_GAP - hUp);
1876
+ dims.topPad = Math.max(dims.topPad, ceilU(-dims.body.maxY - top));
1877
+ dims.h = Math.max(dims.h, ceilU(bottom - top) + VMARGIN + 2);
1878
+ }
1879
+ void nodeAnchors;
1880
+ for (const col of columns) {
1881
+ for (let i = col.length - 1; i >= 0; i--) if (hungKeys.has(col[i]!)) col.splice(i, 1);
1882
+ }
1883
+ for (let i = columns.length - 1; i >= 0; i--) if (!columns[i]!.length) columns.splice(i, 1);
1884
+ // The budget must leave room for the label TEXT facing the sheet edges:
1885
+ // a band filled to the full usable width puts the leftmost column's
1886
+ // left-facing labels outside the frame (#220 phase 1), and no later
1887
+ // shift can fix both edges at once.
1888
+ const ext = groupExtents.get(gname)!;
1889
+ const bandW = Math.max(1, bandBudgetW - ceilU(ext.left) - ceilU(ext.right));
1890
+
1891
+ // Height the decoupling bank adds UNDER the columns once they span
1892
+ // `blockW` units: the same row-wrap the bank placement below performs.
1893
+ const bankHeight = (blockW: number): number => {
1894
+ if (!caps.length) return 0;
1895
+ const capBudget = Math.min(bandW, Math.max(64, blockW));
1896
+ let rows = 1;
1897
+ let x = 0;
1898
+ for (const c of caps) {
1899
+ const b = bodyBoundsOf(c.sym);
1900
+ const w = ceilU(b.maxX - b.minX) + 2 * MARGIN;
1901
+ if (x > 0 && x + w > capBudget) {
1902
+ rows++;
1903
+ x = 0;
1904
+ }
1905
+ x += w;
1906
+ }
1907
+ return MARGIN + 4 + rows * (2 * MARGIN + 6);
1908
+ };
729
1909
  // Column height budget (#220 phase 4): a depth whose parts stack taller
730
1910
  // than the budget splits into several side-by-side columns, in row
731
1911
  // order, so a 24-part board stops drafting as one full-height strip on
732
1912
  // a sheet two sizes too large. Cells never shrink; only the arrangement
733
1913
  // changes, so readability is untouched.
734
- const columnsToPlace: string[][] =
735
- colBudgetH === Infinity
736
- ? columns
737
- : columns.flatMap((col) => {
738
- const chunks: string[][] = [];
739
- let cur: string[] = [];
740
- let h = 0;
741
- for (const ref of col) {
742
- const add = cellDims.get(ref)!.h + (cur.length ? ROW_GAP : 0);
743
- if (cur.length && h + add > colBudgetH) {
744
- chunks.push(cur);
745
- cur = [ref];
746
- h = cellDims.get(ref)!.h;
747
- } else {
748
- cur.push(ref);
749
- h += add;
750
- }
1914
+ //
1915
+ // The bank sits under the columns and counts against the SAME budget:
1916
+ // a group whose columns already fit the budget got no shorter under the
1917
+ // compaction retries while its bulk-cap rows pushed the group past the
1918
+ // sheet (a 30-part power-input group missed A1 by 8 mm that way). The
1919
+ // bank's rows are estimated at the columns' pre-split width — a lower
1920
+ // bound on the width they end up with, so an upper bound on rows.
1921
+ const colsW =
1922
+ columns.reduce((s, col) => s + Math.max(...col.map((r) => cellDims.get(r)!.w)), 0) +
1923
+ CHANNEL * Math.max(0, columns.length - 1);
1924
+ const sheetBudget = colBudgetH === Infinity ? Infinity : Math.max(1, colBudgetH - bankHeight(colsW));
1925
+
1926
+ // ---------- in-group column balance ----------
1927
+ // A depth whose parts stack into one strip draws the group as a tall
1928
+ // thin column beside a short IC with the rest of the box empty: an
1929
+ // amplifier group put 20 filter caps in one 500 mm column next to a
1930
+ // 120 mm IC, and the box was two-thirds air. A drafter runs such parts
1931
+ // in several columns no taller than the IC they serve. So every column
1932
+ // is budgeted at the taller of the group's tallest IC cell and the side
1933
+ // of the square the group's cells would fill, and one taller than that
1934
+ // splits into equal-height side-by-side columns. The IC's CELL, not its
1935
+ // column: a transistor or an eFuse sharing a column with eight
1936
+ // resistors must not make that strip the reference height. Row order
1937
+ // is preserved chunk by chunk, exactly as the sheet budget splits, so
1938
+ // barycenter ordering and the trunk idioms are untouched.
1939
+ const colH = (col: string[]): number => col.reduce((s, r, i) => s + cellDims.get(r)!.h + (i ? ROW_GAP : 0), 0);
1940
+ const isIC = (key: string): boolean => instByKey.get(key)!.sym.pins.length >= 3;
1941
+ const anchorH = Math.max(0, ...members.filter((m) => isIC(m.key)).map((m) => cellDims.get(m.key)!.h));
1942
+ const cellArea = columns.reduce((s, c) => s + colH(c) * Math.max(...c.map((r) => cellDims.get(r)!.w)), 0);
1943
+ const squareSide = Math.ceil(Math.sqrt(cellArea));
1944
+ // Under a band budget the group is shaped to FILL the band's width, not
1945
+ // to be square: a column of eight test points re-rows into two of four
1946
+ // and the button blocks stack three deep, the grid a person draws when
1947
+ // the group must sit in a 250 mm column (esp32-amp's UI group went
1948
+ // from 364 × 150 to the width of its neighbours).
1949
+ // A group with no IC to lead it (buttons, test points, LEDs) has no
1950
+ // natural shape, and its columns line up as a strip five blocks wide;
1951
+ // shape it no wider than two and a half times its square side, the grid
1952
+ // a person draws. Groups led by an IC keep the IC's own proportions.
1953
+ const shapeToAspect = anchors.length === 0;
1954
+ const aspectBand = Math.ceil(2.5 * squareSide);
1955
+ const searchBand = shapeToAspect ? Math.min(aspectBand, bandW) : bandW;
1956
+ const targetH = shapeToAspect
1957
+ ? Math.max(Math.ceil(cellArea / searchBand), Math.ceil(squareSide / 2))
1958
+ : bandBudgetW === Infinity
1959
+ ? squareSide
1960
+ : Math.max(Math.ceil(cellArea / bandBudgetW), Math.ceil(squareSide / 2));
1961
+ const balanceH = Math.max(anchorH, targetH);
1962
+
1963
+ /** Split `col` into the fewest equal-height chunks that each fit
1964
+ * `budget`; a single cell never splits, and a column shorter than
1965
+ * `minCells` is left alone (a drafter does not re-row three parts). */
1966
+ const splitColumn = (col: string[], budget: number, minCells = 1): string[][] => {
1967
+ const total = colH(col);
1968
+ if (total <= budget || col.length < minCells) return [col];
1969
+ const k = Math.ceil(total / budget);
1970
+ const fill = (cap: number): string[][] => {
1971
+ const chunks: string[][] = [];
1972
+ let cur: string[] = [];
1973
+ let h = 0;
1974
+ for (const ref of col) {
1975
+ const add = cellDims.get(ref)!.h + (cur.length ? ROW_GAP : 0);
1976
+ if (cur.length && h + add > cap) {
1977
+ chunks.push(cur);
1978
+ cur = [ref];
1979
+ h = cellDims.get(ref)!.h;
1980
+ } else {
1981
+ cur.push(ref);
1982
+ h += add;
1983
+ }
1984
+ }
1985
+ if (cur.length) chunks.push(cur);
1986
+ return chunks;
1987
+ };
1988
+ // Balanced chunks first, for the look of level columns: the split of
1989
+ // the column into k consecutive chunks that minimises the tallest
1990
+ // chunk (a greedy fill to an equal target fell 7-1 on eight test
1991
+ // points of two heights, and 2-2-1 on five equal cells). If even
1992
+ // that tallest chunk is over the budget, fill to the budget instead.
1993
+ const hs = col.map((ref) => cellDims.get(ref)!.h);
1994
+ const n = hs.length;
1995
+ const runH = (i: number, j: number): number => hs.slice(i, j + 1).reduce((a, b) => a + b, 0) + ROW_GAP * (j - i);
1996
+ const bestMax: number[][] = Array.from({ length: k + 1 }, () => new Array(n + 1).fill(Infinity));
1997
+ const cutAt: number[][] = Array.from({ length: k + 1 }, () => new Array(n + 1).fill(0));
1998
+ bestMax[0]![0] = 0;
1999
+ for (let parts = 1; parts <= k; parts++) {
2000
+ for (let j = 1; j <= n; j++) {
2001
+ for (let i = parts - 1; i < j; i++) {
2002
+ const v = Math.max(bestMax[parts - 1]![i]!, runH(i, j - 1));
2003
+ if (v < bestMax[parts]![j]!) {
2004
+ bestMax[parts]![j] = v;
2005
+ cutAt[parts]![j] = i;
751
2006
  }
752
- if (cur.length) chunks.push(cur);
753
- return chunks;
754
- });
2007
+ }
2008
+ }
2009
+ }
2010
+ if (bestMax[k]![n]! > budget) return fill(budget);
2011
+ const chunks: string[][] = [];
2012
+ for (let parts = k, j = n; parts >= 1; parts--) {
2013
+ const i = cutAt[parts]![j]!;
2014
+ chunks.unshift(col.slice(i, j));
2015
+ j = i;
2016
+ }
2017
+ return chunks;
2018
+ };
2019
+ const BALANCE_MIN_CELLS = 4;
2020
+ // Under a band budget, the lowest column height at which the group's
2021
+ // columns fit the band side by side in ONE band wins: shorter columns
2022
+ // mean more of them and a group that wraps onto bands anyway, taller
2023
+ // ones a group that fills the band's width in one pass (five button
2024
+ // blocks as two columns of three beside two columns of test points).
2025
+ let chosenH = balanceH;
2026
+ if (bandBudgetW !== Infinity || shapeToAspect) {
2027
+ const widthAt = (h: number): number => {
2028
+ const cols = columns.flatMap((col) => splitColumn(col, h, BALANCE_MIN_CELLS));
2029
+ // the same channel and facing-label widening the placement below applies
2030
+ return cols.reduce((sum, c, i) => {
2031
+ const next = cols[i + 1];
2032
+ return sum + Math.max(...c.map((r) => cellDims.get(r)!.w)) + (next ? CHANNEL + widenBy(labelExtents(c).right, labelExtents(next).left, 2 * MARGIN + CHANNEL) : 0);
2033
+ }, 0);
2034
+ };
2035
+ // against the band the columns actually get (the budget less the
2036
+ // group's own label reserves), not the raw budget
2037
+ const ceiling = Math.max(balanceH, ...columns.map(colH));
2038
+ for (let h = balanceH; h <= ceiling; h += 2) {
2039
+ if (widthAt(h) <= searchBand) {
2040
+ chosenH = h;
2041
+ break;
2042
+ }
2043
+ }
2044
+ if (widthAt(chosenH) > searchBand) chosenH = shapeToAspect ? balanceH : ceiling;
2045
+ }
2046
+ let columnsToPlace: string[][] = columns.flatMap((col) =>
2047
+ splitColumn(col, chosenH, BALANCE_MIN_CELLS).flatMap((c) => (sheetBudget === Infinity ? [c] : splitColumn(c, sheetBudget))),
2048
+ );
2049
+ // Shelf infill. An IC's side shelf is as deep as its tallest lane, but
2050
+ // the lanes hang from the top pins and the shelf below them is empty;
2051
+ // small cells from later columns move into that room (the USB ESD
2052
+ // array under the UART bridge's transistor runs, where the hand-laid
2053
+ // sheet keeps it), and the column they leave narrows or disappears.
2054
+ const infill = new Map<string, { key: string; side: -1 | 1; relY: number }[]>();
2055
+ for (let ci = 0; ci < columnsToPlace.length; ci++) {
2056
+ for (const host of columnsToPlace[ci]!) {
2057
+ const d = cellDims.get(host)!;
2058
+ for (const side of [1, -1] as const) {
2059
+ const shelf = side === 1 ? d.shelfR : d.shelfL;
2060
+ const used = side === 1 ? d.usedR : d.usedL;
2061
+ const reach = side === 1 ? d.reachR ?? 0 : d.reachL ?? 0;
2062
+ if (!shelf || used === undefined) continue;
2063
+ const freeW = shelf - reach - 1;
2064
+ let top = d.topPad + VMARGIN + used;
2065
+ let freeH = d.h - top - 1;
2066
+ 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`);
2067
+ if (freeW < 8 || freeH < 8) continue;
2068
+ // candidates from every other column, later ones first; a cell
2069
+ // leaving the host's own column shortens it
2070
+ const order = [...columnsToPlace.keys()].sort((a, b) => (a > ci ? 0 : a === ci ? 1 : 2) - (b > ci ? 0 : b === ci ? 1 : 2) || a - b);
2071
+ for (const cj of order) {
2072
+ if (freeH < 8) break;
2073
+ const col = columnsToPlace[cj]!;
2074
+ for (let k = col.length - 1; k >= 0 && freeH >= 8; k--) {
2075
+ const cand = col[k]!;
2076
+ if (cand === host) continue;
2077
+ // connectors keep the left column (the reader looks for them
2078
+ // there); anchors and anything with lanes of its own stay put
2079
+ // only a substantial part (four pins or more: an ESD array, a
2080
+ // level shifter) is worth tucking away; scattering test points
2081
+ // and LEDs into shelves reads as clutter, not economy
2082
+ const cinst = instByKey.get(cand)!;
2083
+ if (isConnector(cinst.part) || cinst.sym.pins.length < 4) continue;
2084
+ const cd = cellDims.get(cand)!;
2085
+ if (cd.shelfL || cd.shelfR || cd.w > freeW || cd.h > freeH) continue;
2086
+ infill.set(host, [...(infill.get(host) ?? []), { key: cand, side, relY: top }]);
2087
+ col.splice(k, 1);
2088
+ trace(`infill: ${instByKey.get(cand)!.ref} moves under the ${side === 1 ? 'right' : 'left'} shelf of ${instByKey.get(host)!.ref}`);
2089
+ top += cd.h + ROW_GAP;
2090
+ freeH -= cd.h + ROW_GAP;
2091
+ }
2092
+ }
2093
+ }
2094
+ }
2095
+ }
2096
+ columnsToPlace = columnsToPlace.filter((c) => c.length);
2097
+ const hostGeom = new Map<string, { colX: number; colW: number; colShelfR: number; rowY: number }>();
2098
+ 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(' ')}`);
755
2099
  let colX = groupX;
756
2100
  let groupMaxY = 0;
757
2101
  let bandTop = 0; // y origin (units) of the current band of columns
758
2102
  let bandCount = 1;
759
- // The budget must leave room for the label TEXT facing the sheet edges:
760
- // a band filled to the full usable width puts the leftmost column's
761
- // left-facing labels outside the frame (#220 phase 1), and no later
762
- // shift can fix both edges at once.
763
- const ext = groupExtents.get(gname)!;
764
- const bandW = Math.max(1, bandBudgetW - ceilU(ext.left) - ceilU(ext.right));
765
2103
  for (let ci = 0; ci < columnsToPlace.length; ci++) {
766
2104
  const col = columnsToPlace[ci]!;
767
2105
  const colW = Math.max(...col.map((r) => cellDims.get(r)!.w));
2106
+ const colShelfL = Math.max(...col.map((r) => cellDims.get(r)!.shelfL));
2107
+ const colShelfR = Math.max(...col.map((r) => cellDims.get(r)!.shelfR));
2108
+ const colCoreW = Math.max(...col.map((r) => cellDims.get(r)!.w - cellDims.get(r)!.shelfL - cellDims.get(r)!.shelfR));
768
2109
  // Banding (#219): a column that would tile past the width budget starts
769
2110
  // a new band of columns below everything placed so far, the way the
770
2111
  // shelf-wrap below re-rows whole groups. Never before the first column
@@ -778,9 +2119,14 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
778
2119
  let rowY = bandTop;
779
2120
  for (const ref of col) {
780
2121
  const dims = cellDims.get(ref)!;
781
- const cx = colX + Math.floor(colW / 2); // shared column axis (units)
782
- const cy = rowY + Math.floor(dims.h / 2);
2122
+ // shared column axis (units): the core span between the shelves a
2123
+ // hung IC reserves beside itself
2124
+ const cx = colX + colShelfL + Math.floor((colW - colShelfL - colShelfR - Math.max(0, colW - colShelfL - colShelfR - colCoreW)) / 2);
783
2125
  const b = dims.body;
2126
+ // the body sits at the top of its cell, under the room any up-hang
2127
+ // needs; a cell with nothing hung is body plus margins, so this is
2128
+ // its centre as before
2129
+ const cy = rowY + dims.topPad + VMARGIN + Math.floor(ceilU(b.maxY - b.minY) / 2);
784
2130
  // origin so the body centers on the cell center, snapped to grid
785
2131
  const ox = grid(cx - Math.round((b.minX + b.maxX) / 2 / U));
786
2132
  const oy = grid(cy + Math.round((b.minY + b.maxY) / 2 / U));
@@ -795,7 +2141,9 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
795
2141
  body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
796
2142
  cellW: dims.w,
797
2143
  cellH: dims.h,
2144
+ rot: 0,
798
2145
  });
2146
+ if (infill.has(ref)) hostGeom.set(ref, { colX, colW, colShelfR, rowY });
799
2147
  rowY += dims.h + ROW_GAP;
800
2148
  }
801
2149
  groupMaxY = Math.max(groupMaxY, rowY - ROW_GAP);
@@ -804,7 +2152,19 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
804
2152
  }
805
2153
 
806
2154
  // decoupling rows: caps in a uniform row under their owner (or the group)
807
- const capRefs = caps.map((c) => c.key).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
2155
+ // by rail first, then by reference: caps of one rail sit side by side
2156
+ // and share one trunk and one pair of symbols (sorted by reference
2157
+ // alone, a +5V cap numbered after the +3V3 caps stood apart with its
2158
+ // own two symbols)
2159
+ const railOf = (key: string): string => {
2160
+ const inst = instByKey.get(key)!;
2161
+ for (const p of inst.sym.pins) {
2162
+ const n = netByEndpoint.get(`${inst.ref}.${p.number}`);
2163
+ if (n && (netClasses.get(n.name)?.cls ?? 'signal') === 'rail') return n.name;
2164
+ }
2165
+ return '';
2166
+ };
2167
+ const capRefs = caps.map((c) => c.key).sort((a, b) => railOf(a).localeCompare(railOf(b)) || a.localeCompare(b, undefined, { numeric: true }));
808
2168
  if (capRefs.length) {
809
2169
  // The bank stacks under the circuit at the circuit's own width, the
810
2170
  // way a hand-drawn sheet does — a 45-cap ribbon run out to the band
@@ -812,34 +2172,97 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
812
2172
  // it deserved (#233). The floor keeps a short bank (four typical cap
813
2173
  // cells) on one row even when the circuit above it is narrower.
814
2174
  const blockW = Math.max(64, colX - groupX);
815
- const capBudget = Math.min(bandW, blockW);
2175
+ let capBudget = Math.min(bandW, blockW);
2176
+ // Under a height budget the bank is part of the group's height too:
2177
+ // when its rows at the circuit's width would run past the budget,
2178
+ // widen the bank — only as far as the rows need, never past the
2179
+ // band — instead of letting it decide the sheet on its own.
2180
+ if (colBudgetH !== Infinity) {
2181
+ const capWidths = capRefs.map((ref) => {
2182
+ const b = bodyBoundsOf(instByKey.get(ref)!.sym);
2183
+ return ceilU(b.maxX - b.minX) + 2 * MARGIN;
2184
+ });
2185
+ const rowsAt = (budget: number): number => {
2186
+ let rows = 1;
2187
+ let x = 0;
2188
+ for (const w of capWidths) {
2189
+ if (x > 0 && x + w > budget) {
2190
+ rows++;
2191
+ x = 0;
2192
+ }
2193
+ x += w;
2194
+ }
2195
+ return rows;
2196
+ };
2197
+ const rowsAllowed = Math.max(1, Math.floor((colBudgetH - groupMaxY - (MARGIN + 4)) / (2 * MARGIN + 6)));
2198
+ if (rowsAt(capBudget) > rowsAllowed) {
2199
+ const total = capWidths.reduce((a, b) => a + b, 0);
2200
+ capBudget = Math.min(bandW, Math.max(capBudget, Math.ceil(total / rowsAllowed) + Math.max(...capWidths)));
2201
+ }
2202
+ }
816
2203
  let capX = groupX;
817
2204
  let capY = groupMaxY + MARGIN + 4;
2205
+ let bankRowH = 2 * MARGIN + 6;
2206
+ // A bank member with horizontal leads (a TVS diode drawn lying down)
2207
+ // stands up like the caps beside it, its rail lead on top; lying in
2208
+ // the row it carried its ground symbol sideways under the next cap's
2209
+ // name. Each member is turned, and measured, by its own leads.
2210
+ const bankRotOf = (ref: string): number => {
2211
+ const inst = instByKey.get(ref)!;
2212
+ if (inst.sym.pins.length !== 2 || !inst.sym.pins.every((p) => outward(p).dx !== 0)) return 0;
2213
+ const railPin = inst.sym.pins.find((p) => (netClasses.get(netByEndpoint.get(`${inst.ref}.${p.number}`)?.name ?? '')?.cls ?? 'signal') === 'rail') ?? inst.sym.pins[0]!;
2214
+ return orientFor(ref, railPin.number, { dx: 0, dy: -1 }) ?? 90;
2215
+ };
818
2216
  for (const ref of capRefs) {
819
2217
  const inst = instByKey.get(ref)!;
820
- const b = bodyBoundsOf(inst.sym);
2218
+ const rot = bankRotOf(ref);
2219
+ const sym = rotatedSym(inst.sym, rot);
2220
+ const b = bodyBoundsOf(sym);
2221
+ // a measured cap takes the room its drawing needed (its own fields,
2222
+ // the rail bar and name above, the ground below), plus the pad
2223
+ const mo = measured?.get(ref);
2224
+ const capW = mo ? ceilU(mo.left) + ceilU(b.maxX - b.minX) + ceilU(mo.right) + 2 * MEASURE_PAD : ceilU(b.maxX - b.minX) + 2 * MARGIN;
2225
+ const capLeft = mo ? ceilU(mo.left) + MEASURE_PAD : MARGIN;
2226
+ const capTop = mo ? ceilU(mo.top) + MEASURE_PAD : MARGIN;
2227
+ const capRowH = mo ? capTop + ceilU(b.maxY - b.minY) + ceilU(mo.bottom) + MEASURE_PAD : 2 * MARGIN + 6;
2228
+ bankRowH = Math.max(bankRowH, capRowH);
821
2229
  // Banding (#219): a decoupling bank wider than the budget wraps onto
822
- // another uniform row rather than running past the frame.
823
- if (capX > groupX && capX + ceilU(b.maxX - b.minX) + 2 * MARGIN - groupX > capBudget) {
2230
+ // another uniform row rather than running past the frame; and it
2231
+ // wraps BEFORE a rail whose caps would not all fit the row, so one
2232
+ // rail's caps stay on one trunk (PVDD's pair split across two rows
2233
+ // left the second cap with its own two symbols).
2234
+ const railStart = capRefs.indexOf(ref) === 0 || railOf(capRefs[capRefs.indexOf(ref) - 1]!) !== railOf(ref);
2235
+ let railRunW = 0;
2236
+ if (railStart) {
2237
+ for (let k = capRefs.indexOf(ref); k < capRefs.length && railOf(capRefs[k]!) === railOf(ref); k++) {
2238
+ const kb = bodyBoundsOf(rotatedSym(instByKey.get(capRefs[k]!)!.sym, bankRotOf(capRefs[k]!)));
2239
+ const km = measured?.get(capRefs[k]!);
2240
+ railRunW += km ? ceilU(km.left) + ceilU(kb.maxX - kb.minX) + ceilU(km.right) + 2 * MEASURE_PAD : ceilU(kb.maxX - kb.minX) + 2 * MARGIN;
2241
+ }
2242
+ }
2243
+ const needW = railStart && railRunW <= capBudget ? railRunW : capW;
2244
+ if (capX > groupX && capX + needW - groupX > capBudget) {
824
2245
  capX = groupX;
825
- capY += 2 * MARGIN + 6;
2246
+ capY += bankRowH;
2247
+ bankRowH = capRowH;
826
2248
  }
827
- const ox = grid(capX + MARGIN);
828
- const oy = grid(capY + MARGIN);
2249
+ const ox = mo ? grid(capX + capLeft - Math.floor(b.minX / U)) : grid(capX + MARGIN);
2250
+ const oy = mo ? grid(capY + capTop + Math.ceil(b.maxY / U)) : grid(capY + MARGIN);
829
2251
  placed.set(ref, {
830
2252
  part: inst.part,
831
2253
  refDes: inst.ref,
832
2254
  unit: inst.unit,
833
- sym: inst.sym,
2255
+ sym,
834
2256
  x: ox,
835
2257
  y: oy,
836
2258
  body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
837
- cellW: ceilU(b.maxX - b.minX) + 2 * MARGIN,
838
- cellH: ceilU(b.maxY - b.minY) + 2 * MARGIN,
2259
+ cellW: capW,
2260
+ cellH: capRowH,
2261
+ rot,
839
2262
  });
840
- capX += ceilU(b.maxX - b.minX) + 2 * MARGIN;
2263
+ capX += capW;
841
2264
  }
842
- groupMaxY = capY + 2 * MARGIN + 6;
2265
+ groupMaxY = capY + bankRowH;
843
2266
  }
844
2267
 
845
2268
  // ---------- idiom micro-templates and the alignment pass (7.5/7.5a) ----------
@@ -892,6 +2315,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
892
2315
  body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
893
2316
  cellW: prev.cellW,
894
2317
  cellH: prev.cellH,
2318
+ rot: prev.rot,
895
2319
  };
896
2320
  };
897
2321
  /** Apply candidate moves unless any moved body lands within a grid unit of
@@ -946,7 +2370,10 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
946
2370
  const end = { x: p.x + o.dx * len * U, y: p.y + o.dy * len * U };
947
2371
  if (!ownEps.has(ep)) {
948
2372
  for (const q of [p, end]) {
949
- if (sameCoord(q.x, axisX) && q.y > yMin - U && q.y < yMax + U) return false;
2373
+ if (sameCoord(q.x, axisX) && q.y > yMin - U && q.y < yMax + U) {
2374
+ trace(`axis x=${axisX} blocked by ${ep} (${net.name}) at y=${q.y}`);
2375
+ return false;
2376
+ }
950
2377
  }
951
2378
  }
952
2379
  // A horizontal stub SEGMENT crossing the axis exactly at a chain
@@ -964,7 +2391,10 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
964
2391
  if (row && row.net !== net.name) {
965
2392
  const lo = Math.min(p.x, end.x) - 0.001;
966
2393
  const hi = Math.max(p.x, end.x) + 0.001;
967
- if (axisX >= lo && axisX <= hi) return false;
2394
+ if (axisX >= lo && axisX <= hi) {
2395
+ trace(`axis x=${axisX} crossed by the stub of ${ep} (${net.name}) on a ${row.net} row`);
2396
+ return false;
2397
+ }
968
2398
  }
969
2399
  }
970
2400
  }
@@ -972,7 +2402,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
972
2402
  return true;
973
2403
  };
974
2404
  /** The room a rail/ground end grows past its pin: stub, bar, value text. */
975
- const POWER_CLEAR = 8 * U;
2405
+ const POWER_CLEAR = 5 * U;
976
2406
  const powerEndBox = (axisX: number, pinY: number, dir: -1 | 1): Bounds => ({
977
2407
  minX: axisX - 3 * U,
978
2408
  maxX: axisX + 3 * U,
@@ -986,8 +2416,9 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
986
2416
  segments: { axisX: number; ys: number[]; conn?: { y: number; net: string }[] }[],
987
2417
  moves: Map<string, Placed>,
988
2418
  clearBoxes: Bounds[] = [],
2419
+ ownOverride?: Set<string>,
989
2420
  ): boolean => {
990
- const own = ownEndpoints(moves.keys());
2421
+ const own = ownOverride ?? ownEndpoints(moves.keys());
991
2422
  const movedRefs = new Set(moves.keys());
992
2423
  for (const seg of segments) {
993
2424
  // the pad covers the power stub and symbol a rail/ground end grows
@@ -999,7 +2430,10 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
999
2430
  for (const box of clearBoxes) {
1000
2431
  for (const [oref, op] of placed) {
1001
2432
  if (moves.has(oref)) continue;
1002
- if (padOverlap(box, op.body, 0)) return false;
2433
+ if (padOverlap(box, op.body, 0)) {
2434
+ trace(`power end at x=${(box.minX + box.maxX) / 2} would sit on ${oref}`);
2435
+ return false;
2436
+ }
1003
2437
  }
1004
2438
  }
1005
2439
  if (!applyMoves(moves)) return false;
@@ -1046,6 +2480,206 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1046
2480
  if (moves.size) finalizeMoves(segments, moves, clearBoxes);
1047
2481
  }
1048
2482
 
2483
+ // Pin-anchored hangs claimed above: each chain drops from its pin's row
2484
+ // into its shelf slot. Slot 0 sits past the IC's labels on that side,
2485
+ // so the wire pass draws pin, stub, a short branch and the chain's
2486
+ // stub as one local route; deeper slots stagger outward. A hang the
2487
+ // clearance check refuses goes to the leftover column at the group's
2488
+ // right edge, never on top of something.
2489
+ const leftovers: string[] = [];
2490
+ const placeInfill = (): void => {
2491
+ for (const [host, cells] of infill) {
2492
+ const g = hostGeom.get(host);
2493
+ const d = cellDims.get(host)!;
2494
+ if (!g) continue;
2495
+ for (const f of cells) {
2496
+ const cd = cellDims.get(f.key)!;
2497
+ const fb = cd.body;
2498
+ const x0 = f.side === 1 ? g.colX + g.colW - g.colShelfR + (d.reachR ?? 0) : g.colX + 1;
2499
+ const fcx = x0 + Math.floor(cd.w / 2);
2500
+ const fcy = g.rowY + f.relY + Math.floor(cd.h / 2);
2501
+ const fox = grid(fcx - Math.round((fb.minX + fb.maxX) / 2 / U));
2502
+ const foy = grid(fcy + Math.round((fb.minY + fb.maxY) / 2 / U));
2503
+ placed.set(f.key, placeCell(f.key, fox, foy));
2504
+ }
2505
+ }
2506
+ };
2507
+ const placeCell = (key: string, ox: number, oy: number, rot = 0, mirror?: 'y'): Placed => {
2508
+ const inst = instByKey.get(key)!;
2509
+ const dims = cellDims.get(key)!;
2510
+ const sym = transformSym(inst.sym, rot, mirror);
2511
+ const b = bodyBoundsOf(sym);
2512
+ return {
2513
+ part: inst.part,
2514
+ refDes: inst.ref,
2515
+ unit: inst.unit,
2516
+ sym,
2517
+ x: ox,
2518
+ y: oy,
2519
+ body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
2520
+ cellW: dims.w,
2521
+ cellH: dims.h,
2522
+ rot,
2523
+ ...(mirror ? { mirror } : {}),
2524
+ };
2525
+ };
2526
+ placeInfill();
2527
+ // Inline runs: each part lies on its pin's row with its near lead
2528
+ // toward the IC, one gap past the stub, the next part one gap on. The
2529
+ // wire pass then draws pin, stub, gap and lead as one straight line.
2530
+ for (const il of inlines) {
2531
+ const icPl = placed.get(il.ic);
2532
+ if (!icPl) {
2533
+ leftovers.push(...il.chain.map((c) => c.key));
2534
+ continue;
2535
+ }
2536
+ const at = pinAt(icPl, il.pin);
2537
+ const s = { x: at.x + il.dx * STUB * U, y: at.y };
2538
+ const moves = new Map<string, Placed>();
2539
+ let cursor = s.x + il.dx * il.offset; // x of the last connection point on the row
2540
+ let ok = true;
2541
+ for (const c of il.chain) {
2542
+ const ctl = controlPinOf(c.key);
2543
+ // a transistor keeps collector up and emitter down; it is mirrored,
2544
+ // never turned, so its base faces the part that drives it
2545
+ const mirror: 'y' | undefined = ctl ? (outward(ctl).dx === -il.dx ? undefined : 'y') : undefined;
2546
+ const rot = ctl ? 0 : orientFor(c.key, c.nearPin, { dx: -il.dx, dy: 0 });
2547
+ if (rot === null) {
2548
+ ok = false;
2549
+ break;
2550
+ }
2551
+ cursor = cursor + il.dx * c.gap; // the next near lead
2552
+ const rs = transformSym(instByKey.get(c.key)!.sym, rot, mirror);
2553
+ const near = rs.pins.find((pn) => pn.number === c.nearPin)!;
2554
+ const ox = grid(Math.round((cursor - near.x) / U));
2555
+ const oy = grid(Math.round((s.y + near.y) / U));
2556
+ moves.set(c.key, placeCell(c.key, ox, oy, rot, mirror));
2557
+ cursor = cursor + il.dx * spanOf(c.key);
2558
+ }
2559
+ if (ok) {
2560
+ for (const [key, cand] of moves) placed.set(key, cand);
2561
+ // no axis to clear: the run's wires are the row itself, which the
2562
+ // wire pass checks segment by segment; bodies must still fit
2563
+ if (!finalizeMoves([], moves, [], new Set())) {
2564
+ for (const key of moves.keys()) placed.delete(key);
2565
+ ok = false;
2566
+ }
2567
+ }
2568
+ if (!ok) {
2569
+ leftovers.push(...il.chain.map((c) => c.key));
2570
+ for (const c of il.chain) boundTo.delete(c.key);
2571
+ const refs = il.chain.map((c) => instByKey.get(c.key)!.ref).join(', ');
2572
+ 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`;
2573
+ trace(note);
2574
+ if (!notes.includes(note)) notes.push(note);
2575
+ }
2576
+ }
2577
+ for (const hg of hangs) {
2578
+ const icPl = placed.get(hg.ic);
2579
+ if (!icPl) {
2580
+ leftovers.push(...hg.chain);
2581
+ continue;
2582
+ }
2583
+ // a node anchor is a hung part, placed turned: read its pin as drawn
2584
+ const pinNow = icPl.sym.pins.find((p) => p.number === hg.pin.number) ?? hg.pin;
2585
+ const at = pinAt(icPl, pinNow);
2586
+ const s = { x: at.x + hg.dx * STUB * U, y: at.y };
2587
+ const side = hangs.filter((o) => o.ic === hg.ic && o.dx === hg.dx);
2588
+ const pitch = Math.max(0, ...side.map((o) => o.w)) + U;
2589
+ const ext = labelExtents([hg.ic]);
2590
+ const reach = sideReach(hg.ic, hg.dx);
2591
+ void ext;
2592
+ const firstSlot = grid(Math.round((s.x + hg.dx * (reach + 2 * U + pitch / 2)) / U));
2593
+ const slotX = grid(Math.round((firstSlot + hg.dx * hg.slot * pitch) / U));
2594
+ 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)' : ''}`);
2595
+ const netOfHang = (key: string, pinN: string): string => netByEndpoint.get(`${instByKey.get(key)!.ref}.${pinN}`)?.name ?? '';
2596
+ let placedOk = false;
2597
+ // dead straight on the stub axis when the pin-neighbour test found
2598
+ // the rows free (AC-16.31), else the reserved shelf slot. Trying the
2599
+ // axis regardless passed the clearance check — a wire may legally
2600
+ // cross a neighbouring rail stub — and drew exactly that crossing
2601
+ // on the Tier C divider, twice.
2602
+ const axes = hg.straight ? [s.x] : [slotX];
2603
+ // Chains on one side start level: drops below the side's lowest hung
2604
+ // pin row, rises above its highest. Started at its own row, a chain
2605
+ // from one pin put its body across the next pin's row, and that
2606
+ // pin's run out to its lane hit the body (U1's FLT pull-up against
2607
+ // the OVLO divider). Level bases turn that into a wire crossing at
2608
+ // worst, and the resistors of a comb line up the way a drafter
2609
+ // lines them up. A straight hang keeps its own row.
2610
+ const sidePinYsAbs = side.map((o) => pinAt(icPl, icPl.sym.pins.find((p) => p.number === o.pin.number) ?? o.pin).y);
2611
+ const baseY = hg.straight ? s.y : hg.dir === 1 ? Math.max(...sidePinYsAbs) : Math.min(...sidePinYsAbs);
2612
+ for (const axisX of axes) {
2613
+ for (let lift = 0; lift < 3 && !placedOk; lift++) {
2614
+ // the near lead of the first part sits one gap from the base row,
2615
+ // and each further part one gap on, away from the IC
2616
+ let cursor = grid(Math.round((baseY + hg.dir * HANG_GAP) / U)) + hg.dir * lift * 2 * U;
2617
+ const moves = new Map<string, Placed>();
2618
+ const conn: { y: number; net: string }[] = [{ y: s.y, net: netOfHang(hg.ic, hg.pin.number) }];
2619
+ const startY = cursor;
2620
+ const nearPins = hangNearPins.get(hg.chain.join('+'))!;
2621
+ for (const [i, key] of hg.chain.entries()) {
2622
+ // turned so the near lead faces the row (up for a hang that
2623
+ // drops, down for one that rises) and the far lead points away
2624
+ const rot = orientFor(key, nearPins[i]!, { dx: 0, dy: -hg.dir }) ?? 0;
2625
+ const rs = rotatedSym(instByKey.get(key)!.sym, rot);
2626
+ const near = rs.pins.find((pn) => pn.number === nearPins[i])!;
2627
+ const farLead = rs.pins.find((pn) => pn.number !== nearPins[i]) ?? near;
2628
+ const span = spanOf(key, rot);
2629
+ moves.set(key, placeCell(key, axisX - near.x, cursor + near.y, rot));
2630
+ conn.push({ y: cursor, net: netOfHang(key, near.number) }, { y: cursor + hg.dir * span, net: netOfHang(key, farLead.number) });
2631
+ cursor = cursor + hg.dir * (span + HANG_GAP);
2632
+ }
2633
+ const endY = cursor - hg.dir * HANG_GAP;
2634
+ const clearBoxes: Bounds[] = hg.ends === 'power' ? [powerEndBox(axisX, endY, hg.dir)] : [];
2635
+ const ys = [Math.min(startY, endY, s.y), Math.max(startY, endY, s.y)];
2636
+ // Only the nets that run ALONG the axis are the chain's own: the
2637
+ // anchor net and each link between chain parts. The rail or ground
2638
+ // at the far end is not — a pull-up's rail is also the IC's supply
2639
+ // pin, and counting it as own let an axis run straight through
2640
+ // that pin (esp32-amp's BUCK_EN divider on U4's PVDD pin; the
2641
+ // router refused the wire, so the net shipped labelled).
2642
+ const axisNets = new Set(conn.map((c) => c.net));
2643
+ const own = new Set<string>();
2644
+ for (const net of intent.nets) if (axisNets.has(net.name)) for (const ep of net.pins) own.add(ep);
2645
+ // the clearance check reads a chain's OWN connection points from the
2646
+ // placed map; hung parts have no column position, so they go in as
2647
+ // their candidates first and come out again if the check refuses
2648
+ for (const [key, cand] of moves) placed.set(key, cand);
2649
+ if (finalizeMoves([{ axisX, ys, conn }], moves, clearBoxes, own)) placedOk = true;
2650
+ else for (const key of moves.keys()) placed.delete(key);
2651
+ }
2652
+ if (placedOk) break;
2653
+ }
2654
+ if (!placedOk) {
2655
+ leftovers.push(...hg.chain);
2656
+ for (const k of hg.chain) boundTo.delete(k);
2657
+ const refs = hg.chain.map((k) => instByKey.get(k)!.ref).join(', ');
2658
+ 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`;
2659
+ trace(note);
2660
+ if (!notes.includes(note)) notes.push(note);
2661
+ }
2662
+ }
2663
+ if (leftovers.length) {
2664
+ // a column of their own past everything the group placed so far
2665
+ 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;
2666
+ const colW = Math.max(...leftovers.map((k) => cellDims.get(k)!.w));
2667
+ let rowY = bandTop;
2668
+ for (const key of leftovers) {
2669
+ const dims = cellDims.get(key)!;
2670
+ const b = dims.body;
2671
+ const cx = x + Math.floor(colW / 2);
2672
+ const cy = rowY + Math.floor(dims.h / 2);
2673
+ const ox = grid(cx - Math.round((b.minX + b.maxX) / 2 / U));
2674
+ const oy = grid(cy + Math.round((b.minY + b.maxY) / 2 / U));
2675
+ placed.set(key, placeCell(key, ox, oy));
2676
+ rowY += dims.h + ROW_GAP;
2677
+ }
2678
+ groupMaxY = Math.max(groupMaxY, rowY - ROW_GAP);
2679
+ colX = x + colW + CHANNEL;
2680
+ void colX;
2681
+ }
2682
+
1049
2683
  // Drop chains: a maximal run of two-lead vertical parts linked pin-to-pin
1050
2684
  // by two-endpoint signal nets, ended on each side by an anchor pin (any
1051
2685
  // other part) or a power-class net. The run is restacked as one straight
@@ -1064,11 +2698,18 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1064
2698
  const net = netByEndpoint.get(epOf(current, pinN));
1065
2699
  if (!net) return { kind: 'open' }; // declared no-connect or unused
1066
2700
  if (classOf(net.name) !== 'signal') return { kind: 'power' };
1067
- if (net.pins.length !== 2) return { kind: 'invalid' }; // a tapped node is not a series chain
2701
+ // a tapped node is not a series link, but it is a fine END: the chain
2702
+ // stops there and the label that names the node sits on it (R18 over
2703
+ // Q1's drain, its other end on the four-way USB_EN)
2704
+ if (net.pins.length !== 2) return { kind: 'open' };
1068
2705
  const otherEp = net.pins.map(parseEp).find((e) => e !== null && e.key !== current);
1069
2706
  if (!otherEp) return { kind: 'invalid' };
1070
2707
  const opl = placed.get(otherEp.key);
1071
- if (!opl || groupOf.get(otherEp.key) !== gname) return { kind: 'invalid' };
2708
+ if (!opl) return { kind: 'invalid' };
2709
+ // the other end lives in another group: this end is open, and the
2710
+ // label that names the net across the sheet sits on it (an LED's
2711
+ // resistor driven from the MCU group stacks over its LED here)
2712
+ if (groupOf.get(otherEp.key) !== gname) return { kind: 'open' };
1072
2713
  if (chainable(otherEp.key) && !chain.includes(otherEp.key)) {
1073
2714
  const ov = vertPins(otherEp.key)!;
1074
2715
  // the link must enter through the lead facing the chain, or the
@@ -1088,14 +2729,36 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1088
2729
  for (const m of members) {
1089
2730
  if (!chainable(m.key) || chained.has(m.key)) continue;
1090
2731
  const chain = [m.key];
1091
- const topEnd = walk(m.key, 'up', chain);
1092
- const bottomEnd = walk(chain[chain.length - 1]!, 'down', chain);
2732
+ let topEnd = walk(m.key, 'up', chain);
2733
+ let bottomEnd = walk(chain[chain.length - 1]!, 'down', chain);
1093
2734
  for (const ref of chain) chained.add(ref);
2735
+ trace(`chain ${chain.map((k) => instByKey.get(k)!.ref).join('+')}: top ${topEnd.kind} bottom ${bottomEnd.kind}`);
1094
2736
  if (topEnd.kind === 'invalid' || bottomEnd.kind === 'invalid') continue;
2737
+ // The anchor sits BELOW the chain but meets its top lead (R18's pin 1
2738
+ // on Q1's drain, which faces up): turn every part half a turn so the
2739
+ // lead that meets the anchor is the bottom one, and stack upward.
2740
+ // The reverse case (an anchor above, met by a bottom lead) mirrors it.
2741
+ // A turned chain that is then abandoned below gets its column's
2742
+ // orientation back: `abandon` restores every part the turn replaced.
2743
+ const unturned = new Map<string, Placed>();
2744
+ const flip = (): void => {
2745
+ for (const key of chain) {
2746
+ const pl = placed.get(key)!;
2747
+ unturned.set(key, pl);
2748
+ placed.set(key, placeCell(key, pl.x, pl.y, (pl.rot + 180) % 360, pl.mirror));
2749
+ }
2750
+ [topEnd, bottomEnd] = [bottomEnd, topEnd];
2751
+ chain.reverse();
2752
+ };
2753
+ const abandon = (): void => {
2754
+ for (const [key, pl] of unturned) placed.set(key, pl);
2755
+ };
1095
2756
  if (chain.length > 4) continue; // beyond four parts this is a network, not an idiom
1096
2757
  const anchors = [topEnd, bottomEnd].filter((e): e is Extract<ChainEnd, { kind: 'anchor' }> => e.kind === 'anchor');
1097
2758
  if (anchors.length === 0 && chain.length < 2) continue; // a lone floating part has nothing to align to
1098
2759
  if (topEnd.kind === 'open' && bottomEnd.kind === 'open') continue;
2760
+ if (topEnd.kind === 'anchor' && outward(topEnd.pin).dy === -1 && bottomEnd.kind !== 'anchor') flip();
2761
+ else if (bottomEnd.kind === 'anchor' && outward(bottomEnd.pin).dy === 1 && topEnd.kind !== 'anchor') flip();
1099
2762
 
1100
2763
  const stubEndOf = (a: Extract<ChainEnd, { kind: 'anchor' }>): { x: number; y: number; o: { dx: number; dy: number } } => {
1101
2764
  const at = pinAt(placed.get(a.ref)!, a.pin);
@@ -1107,25 +2770,35 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1107
2770
  let order = chain;
1108
2771
  if (topEnd.kind === 'anchor') {
1109
2772
  const s = stubEndOf(topEnd);
1110
- if (s.o.dy === -1) continue; // an up-facing pin cannot feed a downward run
2773
+ if (s.o.dy === -1) {
2774
+ abandon(); // an up-facing pin cannot feed a downward run
2775
+ continue;
2776
+ }
1111
2777
  if (bottomEnd.kind === 'anchor') {
1112
2778
  const b = stubEndOf(bottomEnd);
1113
2779
  // both ends must sit on one axis with the second anchor below and
1114
2780
  // able to receive from above, else leave the columns alone
1115
- if (!sameCoord(s.x, b.x) || b.y <= s.y || b.o.dy === 1) continue;
2781
+ if (!sameCoord(s.x, b.x) || b.y <= s.y || b.o.dy === 1) {
2782
+ abandon();
2783
+ continue;
2784
+ }
1116
2785
  }
1117
2786
  axisX = s.x;
1118
2787
  cursor = s.y + CHAIN_GAP;
1119
2788
  } else if (bottomEnd.kind === 'anchor') {
1120
2789
  // rail above, anchor below (a pull-up): stack upward from the anchor
1121
2790
  const s = stubEndOf(bottomEnd);
1122
- if (s.o.dy === 1) continue; // a down-facing pin cannot feed an upward run
2791
+ if (s.o.dy === 1) {
2792
+ abandon(); // a down-facing pin cannot feed an upward run
2793
+ continue;
2794
+ }
1123
2795
  axisX = s.x;
1124
2796
  order = [...chain].reverse();
1125
2797
  // Bounded lift: when a connection row would sit on a foreign stub's
1126
2798
  // crossing (axisClear's segment check), raise the whole stack a grid
1127
2799
  // row at a time rather than shipping the contact or losing the idiom.
1128
2800
  const netOf = (key: string, pinN: string): string => netByEndpoint.get(epOf(key, pinN))?.name ?? '';
2801
+ let lifted = false;
1129
2802
  for (let lift = 0; lift < 3; lift++) {
1130
2803
  let up = s.y - CHAIN_GAP - lift * 2 * U;
1131
2804
  const moves = new Map<string, Placed>();
@@ -1145,8 +2818,12 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1145
2818
  moves,
1146
2819
  topEnd.kind === 'power' ? [powerEndBox(axisX, topY, -1)] : [],
1147
2820
  );
1148
- if (done) break;
2821
+ if (done) {
2822
+ lifted = true;
2823
+ break;
2824
+ }
1149
2825
  }
2826
+ if (!lifted) abandon();
1150
2827
  continue;
1151
2828
  } else {
1152
2829
  // both ends are rails: a divider — straighten in place on its own axis
@@ -1158,6 +2835,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1158
2835
  }
1159
2836
  const cursor0 = cursor;
1160
2837
  const netOf2 = (key: string, pinN: string): string => netByEndpoint.get(epOf(key, pinN))?.name ?? '';
2838
+ let stacked = false;
1161
2839
  for (let lift = 0; lift < 3; lift++) {
1162
2840
  cursor = cursor0 + lift * 2 * U;
1163
2841
  const moves = new Map<string, Placed>();
@@ -1185,19 +2863,39 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1185
2863
  const endNet = conn.length ? conn[0]!.net : '';
1186
2864
  const startConn = { y: cursor0 - CHAIN_GAP, net: topEnd.kind === 'anchor' ? netOf2(topEnd.ref, topEnd.pin.number) : endNet };
1187
2865
  const lastConn = { y: axisEndY, net: bottomEnd.kind === 'anchor' ? netOf2(bottomEnd.ref, bottomEnd.pin.number) : (conn.length ? conn[conn.length - 1]!.net : '') };
1188
- if (fits && finalizeMoves([{ axisX, ys: [cursor0 - CHAIN_GAP, axisEndY], conn: [startConn, ...conn, lastConn] }], moves, clearBoxes)) break;
2866
+ if (fits && finalizeMoves([{ axisX, ys: [cursor0 - CHAIN_GAP, axisEndY], conn: [startConn, ...conn, lastConn] }], moves, clearBoxes)) {
2867
+ stacked = true;
2868
+ break;
2869
+ }
1189
2870
  if (!fits) break; // lifting only shrinks the room below; no retry can help
1190
2871
  }
2872
+ if (!stacked) abandon();
1191
2873
  }
1192
2874
 
1193
2875
  const memberRefs = [...members.map((m) => m.key), ...capRefs];
1194
2876
  const cells = memberRefs.map((r) => placed.get(r)!);
1195
2877
  if (cells.length) {
1196
2878
  const minX = Math.min(...cells.map((c) => c.body.minX)) - MARGIN * U;
1197
- const maxX = Math.max(...cells.map((c) => c.body.maxX)) + MARGIN * U;
1198
- const minY = Math.min(...cells.map((c) => c.body.minY)) - (MARGIN + 4) * U;
2879
+ // a two-lead part carries its reference and value beside its body;
2880
+ // at the group's right edge that text is what the next group must
2881
+ // clear (the rails bank's "47uF/10V" ran under the amplifier's box)
2882
+ const fieldReach = (c: Placed): number =>
2883
+ c.sym.pins.length <= 2 ? Math.max(c.refDes.length, c.part.value.length) * TEXT_RESERVE * LABEL_HEIGHT + 1.27 : 0;
2884
+ const maxX = Math.max(...cells.map((c) => c.body.maxX + fieldReach(c))) + MARGIN * U;
2885
+ // the top inset holds the caption band (a bold caption at
2886
+ // CAPTION_SIZE, inset 2 mm) above the tallest thing a cell can carry
2887
+ // over its body: a rail symbol on an upward stub with its value text
2888
+ // over it, which with an IC at the top-left corner sat 4.3 mm under
2889
+ // the box edge at six units; CAPTION_BAND keeps the caption's
2890
+ // bottom edge clear of it
2891
+ const minY = Math.min(...cells.map((c) => c.body.minY)) - (MARGIN + CAPTION_BAND) * U;
1199
2892
  const maxY = Math.max(...cells.map((c) => c.body.maxY)) + (MARGIN + 2) * U;
1200
- groupRects.push({ name: gname, x1: minX, y1: minY, x2: maxX, y2: maxY });
2893
+ // a measured round already knows how far the box will grow above
2894
+ // and below its cells (caption band, text, power symbols): the rect
2895
+ // carries it so every fit stacks boxes, not cells
2896
+ const reach = reachMeasured?.get(gname);
2897
+ groupRects.push({ name: gname, x1: minX, y1: minY - (reach?.top ?? 0), x2: maxX, y2: maxY + (reach?.bottom ?? 0) });
2898
+ trace(`group "${gname}": rect x ${minX.toFixed(1)}..${maxX.toFixed(1)} y ${minY.toFixed(1)}..${maxY.toFixed(1)} (${cells.length} cells)`);
1201
2899
  groupX = Math.round(maxX / U) + GROUP_GAP;
1202
2900
  prevGroup = gname;
1203
2901
  bandsOf.set(gname, bandCount);
@@ -1223,9 +2921,9 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1223
2921
  const hinted = paperHint ? PAPERS.find((p) => p.name === paperHint) : undefined;
1224
2922
  // A hint pins the width budget; otherwise try every sheet, smallest first.
1225
2923
  const candidates = hinted ? [hinted] : PAPERS;
1226
- const gap = GROUP_GAP * U;
1227
- const usableW = (p: { w: number }): number => p.w - 2 * FRAME;
1228
- const usableH = (p: { h: number }): number => p.h - 2 * FRAME - TITLE_STRIP;
2924
+ const gap = GROUP_GAP * U + wrapGap;
2925
+ const usableW = (p: { w: number }): number => p.w - 2 * FRAME - frameSlack;
2926
+ const usableH = (p: { h: number }): number => p.h - 2 * FRAME - TITLE_STRIP - frameSlack;
1229
2927
 
1230
2928
  /**
1231
2929
  * Shelf-wrap the group rects to a width budget; returns per-group offsets.
@@ -1249,24 +2947,280 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1249
2947
  let rowLeftExt = leftExtOf(groupRects[0]!.name);
1250
2948
  let dyUnits = 0;
1251
2949
  let rowH = 0;
1252
- for (const r of groupRects) {
1253
- // A group wider than the whole budget still starts its own row; it will
1254
- // overflow, and the caller rejects this paper for it.
1255
- if (r.x1 > rowOriginX && r.x2 - rowOriginX + rowLeftExt + rightExtOf(r.name) > budgetW) {
2950
+ // Every box in a row shares the row's top. The single-row pass gives the
2951
+ // boxes different tops when one reserved room above its IC for a
2952
+ // pull-up (esp32-amp's rails box started 16 mm below its neighbours),
2953
+ // and a row height measured box by box then misses that offset, so the
2954
+ // next row landed on the tall box's bottom. Each box moves to the common
2955
+ // top; its height is then its own.
2956
+ const topY = Math.min(...groupRects.map((r) => r.y1));
2957
+ // Rows are consecutive runs of groups (reading order is kept), but WHICH
2958
+ // consecutive runs is chosen to minimise the stack's height, not by
2959
+ // filling each row until the next group no longer fits: first-fit paired
2960
+ // esp32-amp's two tallest groups with two short ones and stacked five
2961
+ // rows where four would do. A group wider than the whole budget still
2962
+ // starts its own row; it will overflow, and the caller rejects this paper
2963
+ // for it.
2964
+ const n = groupRects.length;
2965
+ const heightOf = (r: (typeof groupRects)[number]): number => r.y2 - r.y1;
2966
+ const runFits = (i: number, j: number): boolean =>
2967
+ i === j || groupRects[j]!.x2 - groupRects[i]!.x1 + leftExtOf(groupRects[i]!.name) + rightExtOf(groupRects[j]!.name) <= budgetW;
2968
+ const best: number[] = new Array(n + 1).fill(Infinity);
2969
+ const cut: number[] = new Array(n + 1).fill(0);
2970
+ best[0] = 0;
2971
+ for (let j = 1; j <= n; j++) {
2972
+ let rowMax = 0;
2973
+ for (let i = j; i >= 1; i--) {
2974
+ rowMax = Math.max(rowMax, heightOf(groupRects[i - 1]!));
2975
+ if (!runFits(i - 1, j - 1)) break;
2976
+ const h = best[i - 1]! + rowMax + (i > 1 ? gap : 0);
2977
+ if (h < best[j]!) {
2978
+ best[j] = h;
2979
+ cut[j] = i - 1;
2980
+ }
2981
+ }
2982
+ if (best[j] === Infinity) {
2983
+ // no run ending here fits: the group is its own (overflowing) row
2984
+ best[j] = best[j - 1]! + heightOf(groupRects[j - 1]!) + (j > 1 ? gap : 0);
2985
+ cut[j] = j - 1;
2986
+ }
2987
+ }
2988
+ const starts: number[] = [];
2989
+ for (let j = n; j > 0; j = cut[j]!) starts.unshift(cut[j]!);
2990
+ const rowStart = new Set(starts);
2991
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') {
2992
+ const rows: string[] = [];
2993
+ for (let k = 0; k < starts.length; k++) {
2994
+ const a = starts[k]!;
2995
+ const b = k + 1 < starts.length ? starts[k + 1]! : n;
2996
+ const members = groupRects.slice(a, b);
2997
+ rows.push(`[${members.map((r) => r.name.slice(0, 2).trim()).join('+')} h${Math.max(...members.map(heightOf)).toFixed(0)}]`);
2998
+ }
2999
+ trace(`wrap at ${budgetW.toFixed(0)}: rows ${rows.join(' ')} -> ${best[n]!.toFixed(0)} tall`);
3000
+ }
3001
+ for (const [idx, r] of groupRects.entries()) {
3002
+ if (idx > 0 && rowStart.has(idx)) {
1256
3003
  dyUnits += Math.ceil((rowH + gap) / U);
1257
3004
  rowOriginX = r.x1;
1258
3005
  rowLeftExt = leftExtOf(r.name);
1259
3006
  rowH = 0;
1260
3007
  }
1261
- deltas.push({ dx: grid(Math.round((originX - rowOriginX) / U)), dy: dyUnits * U });
1262
- rowH = Math.max(rowH, r.y2 - r.y1);
3008
+ const lift = grid(Math.round((topY - r.y1) / U));
3009
+ deltas.push({ dx: grid(Math.round((originX - rowOriginX) / U)), dy: dyUnits * U + lift });
3010
+ rowH = Math.max(rowH, r.y2 + lift - topY);
1263
3011
  }
3012
+ void rowLeftExt;
1264
3013
  const xs = groupRects.flatMap((r, i) => [r.x1 + deltas[i]!.dx, r.x2 + deltas[i]!.dx]);
1265
3014
  const ys = groupRects.flatMap((r, i) => [r.y1 + deltas[i]!.dy, r.y2 + deltas[i]!.dy]);
1266
3015
  return { deltas, w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
1267
3016
  };
1268
3017
 
1269
- type SheetFit = { paper: (typeof PAPERS)[number]; wrap: { deltas: { dx: number; dy: number }[]; w: number; h: number } | null };
3018
+ /**
3019
+ * The column-major counterpart of `wrapTo`: groups keep their declared
3020
+ * order but fill top-to-bottom, then left-to-right, the way a person lays
3021
+ * a newspaper page. Consecutive runs form columns no taller than `budgetH`;
3022
+ * a column is as wide as its widest group (label extents included) and the
3023
+ * breaks are chosen to minimise the total width. Groups of like width stack
3024
+ * well this way (the engagement sheet's three 250 mm groups in one column,
3025
+ * its three 205 mm groups in the next), which rows of mismatched heights
3026
+ * never achieve.
3027
+ */
3028
+ const wrapColumns = (budgetH: number): { deltas: { dx: number; dy: number }[]; w: number; h: number; kind: 'columns' } => {
3029
+ const originX = groupRects[0]!.x1;
3030
+ const topY = Math.min(...groupRects.map((r) => r.y1));
3031
+ const leftExtOf = (name: string): number => groupExtents.get(name)?.left ?? 0;
3032
+ const rightExtOf = (name: string): number => groupExtents.get(name)?.right ?? 0;
3033
+ const gap = GROUP_GAP * U + wrapGap;
3034
+ const n = groupRects.length;
3035
+ const hOf = (r: (typeof groupRects)[number]): number => r.y2 - r.y1;
3036
+ const wOf = (r: (typeof groupRects)[number]): number => r.x2 - r.x1 + leftExtOf(r.name) + rightExtOf(r.name);
3037
+ const runH = (i: number, j: number): number => {
3038
+ let h = 0;
3039
+ for (let k = i; k <= j; k++) h += hOf(groupRects[k]!) + (k > i ? gap : 0);
3040
+ return h;
3041
+ };
3042
+ const best: number[] = new Array(n + 1).fill(Infinity);
3043
+ const cut: number[] = new Array(n + 1).fill(0);
3044
+ best[0] = 0;
3045
+ for (let j = 1; j <= n; j++) {
3046
+ let colW = 0;
3047
+ for (let i = j; i >= 1; i--) {
3048
+ colW = Math.max(colW, wOf(groupRects[i - 1]!));
3049
+ if (i < j && runH(i - 1, j - 1) > budgetH) break;
3050
+ const w = best[i - 1]! + colW + (i > 1 ? gap : 0);
3051
+ if (w < best[j]!) {
3052
+ best[j] = w;
3053
+ cut[j] = i - 1;
3054
+ }
3055
+ }
3056
+ }
3057
+ const starts: number[] = [];
3058
+ for (let j = n; j > 0; j = cut[j]!) starts.unshift(cut[j]!);
3059
+ const deltas: { dx: number; dy: number }[] = [];
3060
+ let colOriginUnits = Math.round(originX / U);
3061
+ let maxH = 0;
3062
+ for (let k = 0; k < starts.length; k++) {
3063
+ const a = starts[k]!;
3064
+ const b = k + 1 < starts.length ? starts[k + 1]! : n;
3065
+ const members = groupRects.slice(a, b);
3066
+ const colLeft = Math.max(...members.map((r) => leftExtOf(r.name)));
3067
+ let yUnits = Math.round(topY / U);
3068
+ for (const r of members) {
3069
+ deltas.push({ dx: (colOriginUnits + Math.ceil(colLeft / U)) * U - grid(Math.round(r.x1 / U)), dy: yUnits * U - grid(Math.round(r.y1 / U)) });
3070
+ yUnits += Math.ceil(hOf(r) / U) + GROUP_GAP + Math.ceil(wrapGap / U);
3071
+ }
3072
+ maxH = Math.max(maxH, (yUnits - GROUP_GAP) * U - topY);
3073
+ colOriginUnits += Math.ceil(Math.max(...members.map(wOf)) / U) + GROUP_GAP + Math.ceil(wrapGap / U);
3074
+ }
3075
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') {
3076
+ 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`);
3077
+ }
3078
+ return { deltas, w: best[n]!, h: maxH, kind: 'columns' };
3079
+ };
3080
+
3081
+ /**
3082
+ * The grid a person lays when neither rows nor columns fit: k columns,
3083
+ * groups dealt to them in declared order (group i in column i mod k), each
3084
+ * column stacking its own groups from the top. Reading order runs along
3085
+ * the rows as before, but a short group no longer holds a whole row's
3086
+ * height open beside a tall one (the hand-laid A2 sheet: power, amplifier
3087
+ * and UI in one column, PD, MCU and UART in the next, rails in the third).
3088
+ */
3089
+ /** Masonry deals already searched in this draft, by budget and group sizes. */
3090
+ const masonryDeals = new Map<string, number[][] | null>();
3091
+ const wrapMasonry = (k: number, budgetH: number): { deltas: { dx: number; dy: number }[]; w: number; h: number; kind: 'masonry' } | null => {
3092
+ const originX = groupRects[0]!.x1;
3093
+ const topY = Math.min(...groupRects.map((r) => r.y1));
3094
+ const leftExtOf = (name: string): number => groupExtents.get(name)?.left ?? 0;
3095
+ const rightExtOf = (name: string): number => groupExtents.get(name)?.right ?? 0;
3096
+ const n = groupRects.length;
3097
+ const wOf = (i: number): number => groupRects[i]!.x2 - groupRects[i]!.x1 + leftExtOf(groupRects[i]!.name) + rightExtOf(groupRects[i]!.name);
3098
+ const hOf = (i: number): number => groupRects[i]!.y2 - groupRects[i]!.y1;
3099
+ const gap = GROUP_GAP * U + wrapGap;
3100
+ // Every way of dealing n groups to k columns (order kept within a
3101
+ // column, columns in the order their first groups are declared) is
3102
+ // judged and the narrowest that fits the height wins; ties go to the
3103
+ // shorter. Seven groups in three columns is 301 deals, but twelve in four
3104
+ // is 611 501, and the fit asks for this at every budget of every sheet:
3105
+ // enumerating them all made a twelve-group board draft in tens of
3106
+ // seconds. So the deals are walked as a tree, a group at a time, and a
3107
+ // branch is cut as soon as a column is already too tall, or the columns
3108
+ // opened so far are already wider than the best deal found (or as wide
3109
+ // and as tall): no deal below it could replace the best. The walk visits
3110
+ // deals in the enumeration's order and cuts only branches that could not
3111
+ // win, so the deal it keeps is the one the full enumeration keeps. Past
3112
+ // a node budget the walk stops with the best deal so far, and deals
3113
+ // round-robin when it has found none. Shapes recur across the budgets
3114
+ // the fit tries, so a search is remembered by the groups' sizes.
3115
+ const sizesKey = `${k}|${budgetH}|${gap}|${groupRects.map((_, i) => `${wOf(i)}x${hOf(i)}`).join(',')}`;
3116
+ let cols: number[][] | null | undefined = masonryDeals.get(sizesKey);
3117
+ if (cols === undefined) {
3118
+ let found: number[][] | null = null;
3119
+ let bestW = Infinity;
3120
+ let bestH = Infinity;
3121
+ const judge = (assign: number[]): void => {
3122
+ const cs: number[][] = Array.from({ length: k }, () => []);
3123
+ assign.forEach((c, i) => cs[c]!.push(i));
3124
+ if (cs.some((c) => !c.length)) return;
3125
+ const hs = cs.map((c) => c.reduce((h, i, m) => h + hOf(i) + (m ? gap : 0), 0));
3126
+ const maxH = Math.max(...hs);
3127
+ if (maxH > budgetH) return;
3128
+ const w = cs.reduce((acc, c) => acc + Math.max(...c.map(wOf)), 0) + (k - 1) * gap;
3129
+ if (w < bestW - 1e-6 || (Math.abs(w - bestW) <= 1e-6 && maxH < bestH)) {
3130
+ bestW = w;
3131
+ bestH = maxH;
3132
+ found = cs;
3133
+ }
3134
+ };
3135
+ // columns are opened in reading order: the first group starts the
3136
+ // first column, and a group may start a new column only when every
3137
+ // earlier one is open (a restricted-growth string), so the same deal
3138
+ // is never judged under k! column orders
3139
+ const assign: number[] = new Array(n).fill(0);
3140
+ const colH: number[] = new Array(k).fill(0);
3141
+ const colW: number[] = new Array(k).fill(0);
3142
+ const colN: number[] = new Array(k).fill(0);
3143
+ let nodes = 0;
3144
+ const grow = (i: number, open: number, widthSoFar: number, tallest: number): void => {
3145
+ if (nodes > MASONRY_NODE_BUDGET) return;
3146
+ nodes++;
3147
+ if (n - i < k - open) return; // too few groups left to open every column
3148
+ if (i === n) {
3149
+ judge(assign);
3150
+ return;
3151
+ }
3152
+ for (let c = 0; c < Math.min(open + 1, k); c++) {
3153
+ const h = colH[c]! + hOf(i) + (colN[c] ? gap : 0);
3154
+ if (h > budgetH) continue;
3155
+ const w = Math.max(colW[c]!, wOf(i));
3156
+ const lowerW = widthSoFar - colW[c]! + w + (k - 1) * gap;
3157
+ const tall = Math.max(tallest, h);
3158
+ if (lowerW > bestW + 1e-6 || (lowerW >= bestW - 1e-6 && tall >= bestH)) continue;
3159
+ const [h0, w0] = [colH[c]!, colW[c]!];
3160
+ assign[i] = c;
3161
+ colH[c] = h;
3162
+ colW[c] = w;
3163
+ colN[c]!++;
3164
+ grow(i + 1, Math.max(open, c + 1), lowerW - (k - 1) * gap, tall);
3165
+ colN[c]!--;
3166
+ colH[c] = h0;
3167
+ colW[c] = w0;
3168
+ }
3169
+ };
3170
+ grow(0, 0, 0, 0);
3171
+ if (nodes > MASONRY_NODE_BUDGET) {
3172
+ 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'}`);
3173
+ if (!found) judge(groupRects.map((_, i) => i % k));
3174
+ }
3175
+ cols = found;
3176
+ masonryDeals.set(sizesKey, cols);
3177
+ }
3178
+ if (!cols) return null;
3179
+ const colW = (cols as number[][]).map((c) => Math.max(0, ...c.map(wOf)));
3180
+ const colH = (cols as number[][]).map((c) => c.reduce((h, i, m) => h + hOf(i) + (m ? gap : 0), 0));
3181
+ const deltas: { dx: number; dy: number }[] = new Array(groupRects.length);
3182
+ let colOriginUnits = Math.round(originX / U);
3183
+ for (const [ci, c] of (cols as number[][]).entries()) {
3184
+ const colLeft = Math.max(0, ...c.map((i) => leftExtOf(groupRects[i]!.name)));
3185
+ let yUnits = Math.round(topY / U);
3186
+ for (const i of c) {
3187
+ const r = groupRects[i]!;
3188
+ deltas[i] = { dx: (colOriginUnits + Math.ceil(colLeft / U)) * U - grid(Math.round(r.x1 / U)), dy: yUnits * U - grid(Math.round(r.y1 / U)) };
3189
+ yUnits += Math.ceil((r.y2 - r.y1) / U) + GROUP_GAP + Math.ceil(wrapGap / U);
3190
+ }
3191
+ colOriginUnits += Math.ceil(colW[ci]! / U) + (ci + 1 < k ? GROUP_GAP + Math.ceil(wrapGap / U) : 0);
3192
+ }
3193
+ const w = (colOriginUnits - Math.round(originX / U)) * U;
3194
+ trace(`masonry wrap k=${k} at ${budgetH.toFixed(0)}: ${(cols as number[][]).map((c) => `[${c.map((i) => groupRects[i]!.name.slice(0, 2).trim()).join('+')}]`).join(' ')} -> ${w.toFixed(0)} wide, ${Math.max(...colH).toFixed(0)} tall`);
3195
+ return { deltas, w, h: Math.max(...colH), kind: 'masonry' };
3196
+ };
3197
+
3198
+ type SheetFit = {
3199
+ paper: (typeof PAPERS)[number];
3200
+ wrap: { deltas: { dx: number; dy: number }[]; w: number; h: number; kind?: 'rows' | 'columns' | 'masonry' } | null;
3201
+ /** The content runs into the title strip beside the title block (the corner itself stays clear). */
3202
+ intoStrip?: boolean;
3203
+ };
3204
+ /**
3205
+ * Does wrapped content of `w` × `h` fit sheet `p`? The title strip is
3206
+ * reserved across the whole width by default; content taller than that
3207
+ * may still fit when the group boxes that reach into the strip stay clear
3208
+ * of the title block's own corner (a person lays the left-hand column down
3209
+ * to the frame and keeps the bottom-right for the block).
3210
+ */
3211
+ const fitsFrame = (p: (typeof PAPERS)[number], w: number, h: number, rects: { x1: number; y1: number; x2: number; y2: number }[]): { ok: boolean; intoStrip: boolean } => {
3212
+ if (w > usableW(p)) return { ok: false, intoStrip: false };
3213
+ if (h <= usableH(p)) return { ok: true, intoStrip: false };
3214
+ if (h > usableH(p) + TITLE_STRIP - 4 * U) return { ok: false, intoStrip: false };
3215
+ // final placement: centred in width, top-aligned (the frame plus four units) in height
3216
+ const minX = Math.min(...rects.map((r) => r.x1));
3217
+ const minY = Math.min(...rects.map((r) => r.y1));
3218
+ const x0 = FRAME + Math.max(0, (usableW(p) - w) / 2) - minX;
3219
+ const y0 = FRAME + 4 * U - minY;
3220
+ const corner = { minX: p.w - FRAME - TITLE_BLOCK_W, minY: p.h - FRAME - TITLE_STRIP, maxX: p.w - FRAME, maxY: p.h - FRAME };
3221
+ 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));
3222
+ return { ok: clear, intoStrip: clear };
3223
+ };
1270
3224
  /**
1271
3225
  * Whether the current placement fits sheet `p`, with the group shelf-wrap
1272
3226
  * deltas that make it fit. `wrap` is null when there is nothing to reflow:
@@ -1275,8 +3229,26 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1275
3229
  */
1276
3230
  const fitsOn = (p: (typeof PAPERS)[number]): SheetFit | null => {
1277
3231
  if (groupRects.length > 1) {
3232
+ const wrappedRects = (d: { dx: number; dy: number }[]): { x1: number; y1: number; x2: number; y2: number }[] =>
3233
+ 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 }));
1278
3234
  const w = wrapTo(usableW(p));
1279
- return w.w <= usableW(p) && w.h <= usableH(p) ? { paper: p, wrap: w } : null;
3235
+ const fw = fitsFrame(p, w.w, w.h, wrappedRects(w.deltas));
3236
+ if (fw.ok) return { paper: p, wrap: { ...w, kind: 'rows' }, intoStrip: fw.intoStrip };
3237
+ // rows of mismatched heights may miss where columns of like widths fit
3238
+ for (const budgetH of [usableH(p), usableH(p) + TITLE_STRIP - 4 * U]) {
3239
+ const c = wrapColumns(budgetH);
3240
+ const fc = fitsFrame(p, c.w, c.h, wrappedRects(c.deltas));
3241
+ if (fc.ok) return { paper: p, wrap: c, intoStrip: fc.intoStrip };
3242
+ }
3243
+ for (const budgetH of [usableH(p), usableH(p) + TITLE_STRIP - 4 * U]) {
3244
+ for (let k = 2; k <= Math.min(4, groupRects.length - 1); k++) {
3245
+ const m = wrapMasonry(k, budgetH);
3246
+ if (!m) continue;
3247
+ const fm = fitsFrame(p, m.w, m.h, wrappedRects(m.deltas));
3248
+ if (fm.ok) return { paper: p, wrap: m, intoStrip: fm.intoStrip };
3249
+ }
3250
+ }
3251
+ return null;
1280
3252
  }
1281
3253
  const r = groupRects[0];
1282
3254
  return !r || (r.x2 - r.x1 <= usableW(p) && r.y2 - r.y1 <= usableH(p)) ? { paper: p, wrap: null } : null;
@@ -1298,15 +3270,34 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1298
3270
  * spread the same cells into more side-by-side columns, so walk the height
1299
3271
  * fractions until the content matches the sheet's aspect or nothing fits.
1300
3272
  */
3273
+ /** Closest miss per sheet a budgeted attempt could not fit, for the notes. */
3274
+ const budgetMisses: string[] = [];
1301
3275
  const tryPaperBudgeted = (p: (typeof PAPERS)[number]): { fit: SheetFit; bands: Map<string, number> } | null => {
1302
- for (const frac of [1, 0.7, 0.5]) {
1303
- const b = placeAllGroups(Math.floor(usableW(p) / U), Math.floor((usableH(p) * frac) / U));
3276
+ let best: { w: number; h: number } | null = null;
3277
+ // finer fractions re-row a tall group's columns until the groups are
3278
+ // short enough for two or three rows of them to stack on the sheet
3279
+ 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] as const))) {
3280
+ // band budgets of a half and a third of the sheet fold a wide flat
3281
+ // group (five button blocks in a row) toward the width of a column
3282
+ const b = placeAllGroups(Math.floor(usableW(p) / div / U), Math.floor((usableH(p) * frac) / U));
1304
3283
  const f = fitsOn(p);
3284
+ if (groupRects.length > 1) {
3285
+ const w = wrapTo(usableW(p));
3286
+ 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(' ')}`);
3287
+ }
1305
3288
  if (f) return { fit: f, bands: b };
3289
+ // remember the closest miss so a failed pass can say how far off it was
3290
+ if (groupRects.length) {
3291
+ const w = groupRects.length > 1 ? wrapTo(usableW(p)) : { w: groupRects[0]!.x2 - groupRects[0]!.x1, h: groupRects[0]!.y2 - groupRects[0]!.y1 };
3292
+ const over = Math.max(w.w - usableW(p), w.h - usableH(p));
3293
+ if (!best || over < Math.max(best.w - usableW(p), best.h - usableH(p))) best = { w: w.w, h: w.h };
3294
+ }
1306
3295
  }
3296
+ if (best) budgetMisses.push(`${p.name} ${Math.round(best.w)}×${Math.round(best.h)} mm vs ${usableW(p)}×${usableH(p)} usable`);
1307
3297
  return null;
1308
3298
  };
1309
3299
 
3300
+ let compaction: SchematicDraftReport['sheetFit']['compaction'] = hinted ? 'pinned' : 'not-needed';
1310
3301
  let bands = placeAllGroups(Infinity);
1311
3302
  let fit = bestFit();
1312
3303
  if (!fit) {
@@ -1323,10 +3314,12 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1323
3314
  if (t) {
1324
3315
  bands = t.bands;
1325
3316
  fit = t.fit;
3317
+ compaction = 'banded';
1326
3318
  break;
1327
3319
  }
1328
3320
  }
1329
3321
  if (!fit) {
3322
+ compaction = 'overflow';
1330
3323
  const largest = candidates[candidates.length - 1]!;
1331
3324
  bands = placeAllGroups(Math.floor(usableW(largest) / U), Math.floor(usableH(largest) / U));
1332
3325
  fit = { paper: largest, wrap: groupRects.length > 1 ? wrapTo(usableW(largest)) : null };
@@ -1366,6 +3359,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1366
3359
  }
1367
3360
  }
1368
3361
  if (compacted) {
3362
+ compaction = 'compacted';
1369
3363
  bands = compactedBands;
1370
3364
  fit = compacted;
1371
3365
  notes.push(
@@ -1375,8 +3369,12 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1375
3369
  if (n > 1) notes.push(`group "${g}" was wider than the sheet; its columns wrapped onto ${n} bands`);
1376
3370
  }
1377
3371
  } else {
1378
- // nothing smaller holds the reflowed content: restore the natural
1379
- // placement byte for byte
3372
+ // nothing smaller holds the reflowed content: say so, with how far
3373
+ // each sheet missed, then restore the natural placement byte for byte
3374
+ compaction = 'failed';
3375
+ notes.push(
3376
+ `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('; ')})`,
3377
+ );
1380
3378
  bands = placeAllGroups(Infinity);
1381
3379
  fit = bestFit()!;
1382
3380
  }
@@ -1384,8 +3382,18 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1384
3382
  }
1385
3383
  if (fit.wrap) {
1386
3384
  const wrap = fit.wrap;
1387
- const rows = new Set(wrap.deltas.map((d) => d.dy)).size;
1388
- if (rows > 1) notes.push(`groups wrapped onto ${rows} rows to fit the sheet`);
3385
+ if (wrap.kind === 'columns') {
3386
+ const cols = new Set(wrap.deltas.map((d, i) => Math.round((groupRects[i]!.x1 + d.dx) / U))).size;
3387
+ notes.push(`groups wrapped onto ${cols} columns to fit the sheet (declared order runs down each column)`);
3388
+ } else if (wrap.kind === 'masonry') {
3389
+ const cols = new Set(wrap.deltas.map((d, i) => Math.round((groupRects[i]!.x1 + d.dx) / U))).size;
3390
+ notes.push(`groups laid in ${cols} columns to fit the sheet (declared order runs along the rows; each column stacks its own groups)`);
3391
+ } else {
3392
+ // rows, like columns above, are counted by where their boxes land, not
3393
+ // by the shift each took to get there (every group starts elsewhere)
3394
+ const rows = new Set(wrap.deltas.map((d, i) => Math.round((groupRects[i]!.y1 + d.dy) / U))).size;
3395
+ if (rows > 1) notes.push(`groups wrapped onto ${rows} rows to fit the sheet`);
3396
+ }
1389
3397
  groupRects.forEach((r, i) => {
1390
3398
  const d = wrap.deltas[i]!;
1391
3399
  if (!d.dx && !d.dy) return;
@@ -1645,6 +3653,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1645
3653
  const y = c.ep.at.y + c.o.dy * STUB * U;
1646
3654
  const seg = { x1: prev.ep.at.x, y1: y, x2: c.ep.at.x, y2: y };
1647
3655
  return (
3656
+ groupOf.get(prev.ep.ref) === groupOf.get(c.ep.ref) &&
1648
3657
  c.ep.at.x - prev.ep.at.x <= BANK_PITCH_MAX * U &&
1649
3658
  !powerBodies.some((b) => segCrossesBody(seg.x1, seg.y1, seg.x2, seg.y2, b)) &&
1650
3659
  !touchesForeign([seg], net.name, ownEps, { predictStubs: true }) &&
@@ -1681,10 +3690,16 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1681
3690
  }
1682
3691
  if (len > maxLen) len = maxLen;
1683
3692
  const at = (l: number): { x: number; y: number } => ({ x: ep.at.x + o.dx * l * U, y: ep.at.y + o.dy * l * U });
1684
- const valueAtOf = (end: { x: number; y: number }): { x: number; y: number } => ({
1685
- x: end.x,
1686
- y: end.y + (o.dy !== 0 ? o.dy * 3.556 : cls === 'ground' ? 3.556 : -3.556),
1687
- });
3693
+ // A vertical stub carries its name beyond the bar, in line. A HORIZONTAL
3694
+ // stub used to put the name 3.556 mm above (rails) or below (grounds)
3695
+ // the bar: 1.4 pin rows off, so on any IC with pins one row apart the
3696
+ // rail name of pin 5 sat on the label of pin 4 (esp32-amp's eFuse,
3697
+ // VBUS_FUSED over EFUSE_USB_FLT). The name now continues outward along
3698
+ // the row, past the bar, where the row is its own.
3699
+ const valueAtOf = (end: { x: number; y: number }): { x: number; y: number } =>
3700
+ o.dy !== 0
3701
+ ? { x: end.x, y: end.y + o.dy * 3.556 }
3702
+ : { x: end.x + o.dx * (Math.max(1, net.name.length) * LABEL_ADVANCE * LABEL_HEIGHT / 2 + 1.905), y: end.y };
1688
3703
  // Adjacent power pins collide their value texts two ways, resolved two
1689
3704
  // ways. The SAME net repeated (a TQFP's VCC pins one row apart) hides
1690
3705
  // the duplicates: one visible name per cluster carries the same
@@ -1835,28 +3850,35 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1835
3850
  const o = outward(ep.pin);
1836
3851
  return { ep, end: { x: ep.at.x + o.dx * STUB * U, y: ep.at.y + o.dy * STUB * U }, o };
1837
3852
  });
1838
- const groupsTouched = new Set(eps.map((e) => groupOf.get(e.ref)));
1839
- const spanX = Math.max(...stubs.map((s) => s.end.x)) - Math.min(...stubs.map((s) => s.end.x));
1840
- const spanY = Math.max(...stubs.map((s) => s.end.y)) - Math.min(...stubs.map((s) => s.end.y));
1841
- let asWire = groupsTouched.size === 1 && eps.length <= MAX_WIRED_ENDPOINTS && Math.max(spanX, spanY) <= MAX_WIRE_SPAN;
1842
-
1843
- if (asWire) {
1844
- // trunk-and-branch: a vertical trunk with horizontal branches. Several
1845
- // deterministic trunk positions are tried in order (median stub x, right
1846
- // of everything, left of everything); the first collision-free routing
1847
- // wins, and if none exists the net falls back to labels — the engine may
1848
- // never trip its own wire-through-symbol gate.
1849
- const xs = stubs.map((s) => s.end.x).sort((a, b) => a - b);
1850
- const ys = stubs.map((s) => s.end.y);
3853
+ type Stub = { ep: (typeof eps)[number]; end: { x: number; y: number }; o: { dx: number; dy: number } };
3854
+ /**
3855
+ * Trunk-and-branch route for `subset`: a vertical trunk with horizontal
3856
+ * branches, at the first of three deterministic trunk positions (median
3857
+ * stub x, right of everything, left of everything) whose every segment
3858
+ * clears every body and every foreign connection point (I22, #204). Null
3859
+ * when none does the engine may never trip its own wire-through-symbol
3860
+ * gate, and a routed contact would merge nets.
3861
+ */
3862
+ type RouteSeg = { x1: number; y1: number; x2: number; y2: number };
3863
+ type Route = { segs: RouteSeg[]; trunkX: number; trunkY?: number };
3864
+ const routeStubs = (subset: Stub[]): Route | null => {
3865
+ const xs = subset.map((s) => s.end.x).sort((a, b) => a - b);
3866
+ const ys = subset.map((s) => s.end.y);
3867
+ const admit = (candidate: RouteSeg[]): boolean => {
3868
+ if (candidate.some((c) => bodies.some((b) => segCrossesBody(c.x1, c.y1, c.x2, c.y2, b)))) return false;
3869
+ return !touchesForeign(candidate, net.name, new Set(net.pins));
3870
+ };
3871
+ const found: Route[] = [];
3872
+ // A vertical trunk with horizontal branches, at the median stub x,
3873
+ // right of everything, left of everything.
1851
3874
  const trunkCandidates = [
1852
3875
  grid(Math.round(xs[Math.floor(xs.length / 2)]! / U)),
1853
3876
  grid(Math.round(xs[xs.length - 1]! / U) + STUB),
1854
3877
  grid(Math.round(xs[0]! / U) - STUB),
1855
3878
  ];
1856
- let routed = false;
1857
3879
  for (const trunkX of trunkCandidates) {
1858
- const candidate: { x1: number; y1: number; x2: number; y2: number }[] = [];
1859
- for (const s of stubs) {
3880
+ const candidate: RouteSeg[] = [];
3881
+ for (const s of subset) {
1860
3882
  candidate.push({ x1: s.ep.at.x, y1: s.ep.at.y, x2: s.end.x, y2: s.end.y });
1861
3883
  if (!sameCoord(s.end.x, trunkX)) candidate.push({ x1: s.end.x, y1: s.end.y, x2: trunkX, y2: s.end.y });
1862
3884
  }
@@ -1866,69 +3888,250 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1866
3888
  for (let i = 1; i < meetYs.length; i++) {
1867
3889
  candidate.push({ x1: trunkX, y1: meetYs[i - 1]!, x2: trunkX, y2: meetYs[i]! });
1868
3890
  }
1869
- if (candidate.some((c) => bodies.some((b) => segCrossesBody(c.x1, c.y1, c.x2, c.y2, b)))) continue;
1870
- // No candidate segment may touch a foreign connection point (I22,
1871
- // #204): a trunk routed down a column of neighbouring stub ends
1872
- // silently merges nets. Mirrors the chain pass's axisClear,
1873
- // generalized to every candidate segment.
1874
- if (touchesForeign(candidate, net.name, new Set(net.pins))) continue;
1875
- for (const c of candidate) addWire(net.name, c.x1, c.y1, c.x2, c.y2);
1876
- // one label names the wired net (topmost-leftmost wire point): the net
1877
- // stays identifiable to PINOUT/drift and to a reviewer without a
1878
- // label-per-pin, matching hand-drafting practice
1879
- const pts = candidate.flatMap((c) => [
1880
- { x: c.x1, y: c.y1 },
1881
- { x: c.x2, y: c.y2 },
1882
- ]);
1883
- pts.sort((a, b) => a.y - b.y || a.x - b.x);
1884
- labels.push({ name: net.name, x: pts[0]!.x, y: pts[0]!.y, rot: 0 });
1885
- wiredLabels.push({ label: labels.length - 1, pts, segs: candidate.map((c) => ({ ...c })) });
1886
- wired++;
1887
- if (eps.length > 2) {
1888
- for (const s of stubs) {
1889
- const meet = sameCoord(s.end.x, trunkX) ? s.end : { x: trunkX, y: s.end.y };
1890
- if (meet.y > Math.min(...ys) && meet.y < Math.max(...ys)) junctions.push(meet);
3891
+ if (admit(candidate)) found.push({ segs: candidate, trunkX });
3892
+ }
3893
+ // A comb: the trunk lies ALONG one endpoint's row the IC pin's row
3894
+ // when the net has one and every other stub rises or drops to it.
3895
+ // This is how a pin with parts hung on it is drawn: the row runs out to
3896
+ // the axis, the chain above and the chain below meet it in one T. The
3897
+ // vertical trunk alone could not route a pull-up and a pull-down on one
3898
+ // pin (its branch to the second part ran along a foreign row), so one
3899
+ // of them shipped labelled.
3900
+ const rows = [...new Map(subset.map((s) => [knum(s.end.y), s])).values()].sort((a, b) => {
3901
+ const ica = (placed.get(a.ep.ref)?.sym.pins.length ?? 0) >= 3 ? 0 : 1;
3902
+ const icb = (placed.get(b.ep.ref)?.sym.pins.length ?? 0) >= 3 ? 0 : 1;
3903
+ return ica - icb || a.end.y - b.end.y;
3904
+ });
3905
+ for (const rowStub of rows) {
3906
+ const trunkY = rowStub.end.y;
3907
+ const candidate: RouteSeg[] = [];
3908
+ for (const s of subset) {
3909
+ candidate.push({ x1: s.ep.at.x, y1: s.ep.at.y, x2: s.end.x, y2: s.end.y });
3910
+ if (!sameCoord(s.end.y, trunkY)) candidate.push({ x1: s.end.x, y1: s.end.y, x2: s.end.x, y2: trunkY });
3911
+ }
3912
+ const meetXs = [...new Map(xs.map((x) => [knum(x), x])).values()].sort((a, b) => a - b);
3913
+ for (let i = 1; i < meetXs.length; i++) {
3914
+ candidate.push({ x1: meetXs[i - 1]!, y1: trunkY, x2: meetXs[i]!, y2: trunkY });
3915
+ }
3916
+ if (admit(candidate)) found.push({ segs: candidate, trunkX: meetXs[0]!, trunkY });
3917
+ }
3918
+ // A hook, for two stubs where one lies on a row and the other is a
3919
+ // lead below or above it on an axis the row would have to cross: run
3920
+ // the row past the lead by two units, turn to the lead's level, and
3921
+ // come back to its stub end. This is how a bootstrap cap's far lead is
3922
+ // reached from the row it bridges to (the other lead sits on the same
3923
+ // axis, in the way of a straight drop).
3924
+ if (!found.length && subset.length === 2) {
3925
+ for (const [a, b] of [[subset[0]!, subset[1]!], [subset[1]!, subset[0]!]] as const) {
3926
+ if (a.o.dx === 0 || b.o.dy === 0) continue; // a: a sideways pin (the row); b: a vertical lead
3927
+ for (const past of [2 * U, -2 * U]) {
3928
+ const turnX = grid(Math.round((b.end.x + past) / U));
3929
+ const candidate: RouteSeg[] = [
3930
+ { x1: a.ep.at.x, y1: a.ep.at.y, x2: a.end.x, y2: a.end.y },
3931
+ { x1: a.end.x, y1: a.end.y, x2: turnX, y2: a.end.y },
3932
+ { x1: turnX, y1: a.end.y, x2: turnX, y2: b.end.y },
3933
+ { x1: turnX, y1: b.end.y, x2: b.end.x, y2: b.end.y },
3934
+ { x1: b.ep.at.x, y1: b.ep.at.y, x2: b.end.x, y2: b.end.y },
3935
+ ].filter((c) => !(sameCoord(c.x1, c.x2) && sameCoord(c.y1, c.y2)));
3936
+ if (admit(candidate)) {
3937
+ found.push({ segs: candidate, trunkX: turnX });
3938
+ break;
3939
+ }
1891
3940
  }
3941
+ if (found.length) break;
1892
3942
  }
1893
- routed = true;
1894
- break;
1895
3943
  }
1896
- if (!routed) asWire = false;
1897
- }
1898
- if (!asWire) {
1899
- for (const s of stubs) {
1900
- // A stub is a wire too: its endpoint resting on a foreign net's wire
1901
- // or connection point merges nets exactly like a trunk would (the
1902
- // cap-to-ground drop's 2-unit stub ended on the neighbouring power
1903
- // pin's stub interior I22's third face). Grow the stub a grid unit
1904
- // at a time until the endpoint is clear; the interior then CROSSES
1905
- // the foreign wire mid-segment, which does not connect. If no length
1906
- // clears, emit the plain stub and let the merged-net gate refuse
1907
- // loudly rather than ship the contact.
1908
- let end = s.end;
1909
- const own = new Set(net.pins);
1910
- // Rungs in preference order: the classic 0..2 extensions first so
1911
- // clear cases stay byte-identical, then deeper extensions, then a
1912
- // one-unit retreat. A stub that ships with NO clear rung still ends
1913
- // touching a foreign wire and the merge gate refuses the draft, so
1914
- // every extra rung here is a board that drafts instead of refusing
1915
- // (jetson's twelve wire-contact refusals were exactly this fallback).
1916
- for (const len of [STUB, STUB + 1, STUB + 2, STUB + 3, STUB + 4, STUB + 5, STUB + 6, 1]) {
1917
- const cand = {
1918
- x: s.ep.at.x + s.o.dx * len * U,
1919
- y: s.ep.at.y + s.o.dy * len * U,
1920
- };
1921
- if (!touchesForeign([{ x1: s.ep.at.x, y1: s.ep.at.y, x2: cand.x, y2: cand.y }], net.name, own)) {
1922
- end = cand;
1923
- break;
3944
+ if (!found.length) {
3945
+ trace(`route ${net.name}: no clean route for ${subset.map((s) => `${s.ep.ref}.${s.ep.pin.number}`).join(', ')}`);
3946
+ return null;
3947
+ }
3948
+ // the fewest segments is the fewest bends; ties keep the classic order
3949
+ return found.reduce((best, r) => (r.segs.length < best.segs.length ? r : best), found[0]!);
3950
+ };
3951
+ /** Labels naming this net's wired runs, promoted to flags below if the net also leaves through a stub. */
3952
+ const netWired: number[] = [];
3953
+ /** Draw a routed subset: its wires, one label naming the net, and junction dots. */
3954
+ const commitRoute = (subset: Stub[], r: Route): void => {
3955
+ for (const c of r.segs) addWire(net.name, c.x1, c.y1, c.x2, c.y2);
3956
+ // one label names the wired run (topmost-leftmost wire point): the net
3957
+ // stays identifiable to PINOUT/drift and to a reviewer without a
3958
+ // label-per-pin, matching hand-drafting practice
3959
+ const pts = r.segs.flatMap((c) => [
3960
+ { x: c.x1, y: c.y1 },
3961
+ { x: c.x2, y: c.y2 },
3962
+ ]);
3963
+ pts.sort((a, b) => a.y - b.y || a.x - b.x);
3964
+ // The name stands above the wire from its anchor rightward. A trunk's
3965
+ // top is the classic place; a run that is one horizontal row (a part
3966
+ // on its pin's row) has no trunk, and its left end is the pin, so the
3967
+ // name sits over the stub and the gap the row left for it. Whichever
3968
+ // candidate clears every body wins; the run's top-left point is the
3969
+ // last resort.
3970
+ const vertTops = r.segs.filter((c) => sameCoord(c.x1, c.x2)).map((c) => ({ x: c.x1, y: Math.min(c.y1, c.y2) }));
3971
+ const candidates = [...vertTops.sort((a, b) => a.y - b.y || a.x - b.x), pts[0]!];
3972
+ const clearOfBodies = (pt: { x: number; y: number }): boolean => {
3973
+ const b = labelTextBox(net.name, pt.x, pt.y, 0);
3974
+ if ([...placed.values()].some((pl) => boundsOverlap(b, pl.body))) return false;
3975
+ return !labels.some((o) => o.name !== net.name && boundsOverlap(padBox(b), labelTextBox(o.name, o.x, o.y, o.rot)));
3976
+ };
3977
+ const at = candidates.find(clearOfBodies) ?? pts[0]!;
3978
+ labels.push({ name: net.name, x: at.x, y: at.y, rot: 0, kind: 'local' });
3979
+ netWired.push(labels.length - 1);
3980
+ wiredLabels.push({ label: labels.length - 1, pts, segs: r.segs.map((c) => ({ ...c })) });
3981
+ wired++;
3982
+ if (subset.length > 2) {
3983
+ if (r.trunkY !== undefined) {
3984
+ // comb: a branch meeting the row trunk strictly inside its span is a T
3985
+ const xs = subset.map((s) => s.end.x);
3986
+ for (const s of subset) {
3987
+ const meet = sameCoord(s.end.y, r.trunkY) ? s.end : { x: s.end.x, y: r.trunkY };
3988
+ if (meet.x > Math.min(...xs) + 0.01 && meet.x < Math.max(...xs) - 0.01) junctions.push(meet);
1924
3989
  }
3990
+ } else {
3991
+ const ys = subset.map((s) => s.end.y);
3992
+ for (const s of subset) {
3993
+ const meet = sameCoord(s.end.x, r.trunkX) ? s.end : { x: r.trunkX, y: s.end.y };
3994
+ if (meet.y > Math.min(...ys) && meet.y < Math.max(...ys)) junctions.push(meet);
3995
+ }
3996
+ }
3997
+ }
3998
+ };
3999
+
4000
+ // Clusters: endpoints in one group whose stub ends sit within wire span of
4001
+ // one another, in x order. A net that stays in one group and fits the
4002
+ // span is one cluster — the classic case, drawn as before. A net that
4003
+ // also leaves the group, or runs longer, still gets each local run wired
4004
+ // and labelled once: adjacency before labelling (#220 phase 3), so a part
4005
+ // hung on a pin or lying on its row is wired to it whatever the rest of
4006
+ // the net does. Lone endpoints keep a stub and a label.
4007
+ const sorted: Stub[] = [...stubs].sort((a, b) => a.end.x - b.end.x || a.end.y - b.end.y);
4008
+ const clusters: Stub[][] = [];
4009
+ const anchorKey = (ref: string): string => {
4010
+ let k = ref;
4011
+ for (let i = 0; i < 4 && boundTo.has(k); i++) k = boundTo.get(k)!;
4012
+ return k;
4013
+ };
4014
+ const near = (a: Stub, b: Stub): boolean =>
4015
+ (Math.abs(a.end.x - b.end.x) <= MAX_WIRE_SPAN && Math.abs(a.end.y - b.end.y) <= MAX_WIRE_SPAN) ||
4016
+ // a part hung on a pin or lying on its row joins that pin's cluster
4017
+ // however far its lane sits: the fourth lane of a comb is past the
4018
+ // wire span, and it is still that pin's part
4019
+ anchorKey(a.ep.ref) === anchorKey(b.ep.ref);
4020
+ for (const s of sorted) {
4021
+ const home = clusters.find((cl) => groupOf.get(cl[0]!.ep.ref) === groupOf.get(s.ep.ref) && cl.every((o) => near(o, s)));
4022
+ if (home) home.push(s);
4023
+ else clusters.push([s]);
4024
+ }
4025
+ const wiredStubs = new Set<Stub>();
4026
+ for (const found of clusters) {
4027
+ // A cluster past the wired-net size is a pin with its hung parts plus
4028
+ // whatever else sits within span: it is narrowed to the largest set of
4029
+ // endpoints bound to one anchor, so the parts hung on a pin are wired
4030
+ // to it however many there are, and the rest keep stubs and labels.
4031
+ let cl = found;
4032
+ if (cl.length > MAX_WIRED_ENDPOINTS) {
4033
+ const byAnchor = new Map<string, Stub[]>();
4034
+ for (const s of cl) byAnchor.set(anchorKey(s.ep.ref), [...(byAnchor.get(anchorKey(s.ep.ref)) ?? []), s]);
4035
+ cl = [...byAnchor.values()].reduce((a, b) => (b.length > a.length ? b : a));
4036
+ trace(`route ${net.name}: cluster of ${found.length} narrowed to ${cl.length} bound to ${anchorKey(cl[0]!.ep.ref)}`);
4037
+ }
4038
+ if (cl.length < 2) continue;
4039
+ // the whole cluster, else the largest subset that routes: a run on the
4040
+ // IC pin's row still draws as a wire when a third endpoint's branch
4041
+ // cannot be cleared, and only that endpoint keeps a stub label. Subsets
4042
+ // are tried largest first in index order, at most ROUTE_ATTEMPTS.
4043
+ let attempts = 0;
4044
+ let done = false;
4045
+ const cur: Stub[] = [];
4046
+ const pick = (start: number, size: number): void => {
4047
+ if (done || attempts >= ROUTE_ATTEMPTS) return;
4048
+ if (cur.length === size) {
4049
+ attempts++;
4050
+ const r = routeStubs(cur);
4051
+ if (!r) return;
4052
+ const sub = [...cur];
4053
+ commitRoute(sub, r);
4054
+ for (const s of sub) wiredStubs.add(s);
4055
+ done = true;
4056
+ return;
4057
+ }
4058
+ for (let i = start; i <= cl.length - (size - cur.length) && !done; i++) {
4059
+ cur.push(cl[i]!);
4060
+ pick(i + 1, size);
4061
+ cur.pop();
4062
+ }
4063
+ };
4064
+ for (let size = cl.length; size >= 2 && !done && attempts < ROUTE_ATTEMPTS; size--) pick(0, size);
4065
+ if (!done && attempts >= ROUTE_ATTEMPTS) trace(`route ${net.name}: ${ROUTE_ATTEMPTS} subsets of ${cl.length} tried, none routed`);
4066
+ }
4067
+ for (const s of stubs) {
4068
+ if (wiredStubs.has(s)) continue;
4069
+ // A stub is a wire too: its endpoint resting on a foreign net's wire
4070
+ // or connection point merges nets exactly like a trunk would (the
4071
+ // cap-to-ground drop's 2-unit stub ended on the neighbouring power
4072
+ // pin's stub interior — I22's third face). Grow the stub a grid unit
4073
+ // at a time until the endpoint is clear; the interior then CROSSES
4074
+ // the foreign wire mid-segment, which does not connect. If no length
4075
+ // clears, emit the plain stub and let the merged-net gate refuse
4076
+ // loudly rather than ship the contact.
4077
+ let end = s.end;
4078
+ const own = new Set(net.pins);
4079
+ // Rungs in preference order: the classic 0..2 extensions first so
4080
+ // clear cases stay byte-identical, then deeper extensions, then a
4081
+ // one-unit retreat. A stub that ships with NO clear rung still ends
4082
+ // touching a foreign wire and the merge gate refuses the draft, so
4083
+ // every extra rung here is a board that drafts instead of refusing
4084
+ // (jetson's twelve wire-contact refusals were exactly this fallback).
4085
+ for (const len of [STUB, STUB + 1, STUB + 2, STUB + 3, STUB + 4, STUB + 5, STUB + 6, 1]) {
4086
+ const cand = {
4087
+ x: s.ep.at.x + s.o.dx * len * U,
4088
+ y: s.ep.at.y + s.o.dy * len * U,
4089
+ };
4090
+ if (!touchesForeign([{ x1: s.ep.at.x, y1: s.ep.at.y, x2: cand.x, y2: cand.y }], net.name, own)) {
4091
+ end = cand;
4092
+ break;
1925
4093
  }
1926
- addWire(net.name, s.ep.at.x, s.ep.at.y, end.x, end.y);
1927
- // labels are always horizontal (drafting standard): leftward pins read
1928
- // outward to the left, everything else extends to the right
1929
- labels.push({ name: net.name, x: end.x, y: end.y, rot: s.o.dx === -1 ? 180 : 0 });
1930
- stubbedLabels.push({ label: labels.length - 1, wire: wires.length - 1, o: s.o, pins: net.pins });
1931
- labelled++;
4094
+ }
4095
+ addWire(net.name, s.ep.at.x, s.ep.at.y, end.x, end.y);
4096
+ // a stub ends in a global flag that continues the stub's direction,
4097
+ // the way a person hangs a flag on a wire end: a leftward stub reads
4098
+ // leftward, an upward one reads upward; the shape says what the pin is
4099
+ labels.push({ name: net.name, x: end.x, y: end.y, rot: rotOutward(s.o), kind: 'global', shape: flagShape(s.ep.pin.etype) });
4100
+ stubbedLabels.push({ label: labels.length - 1, wire: wires.length - 1, o: s.o, pins: net.pins });
4101
+ labelled++;
4102
+ }
4103
+ // A net that also leaves its run through a stub is named by global flags
4104
+ // there, and a local label of the same name would not connect to them in
4105
+ // KiCad: the run's own name becomes a flag too, re-anchored where a flag
4106
+ // fits (a local name stands over its wire; a flag has a body of its own).
4107
+ // A net wired whole keeps its plain local name on the run.
4108
+ if (netWired.length && stubs.some((s) => !wiredStubs.has(s))) {
4109
+ for (const idx of netWired) {
4110
+ const lb = labels[idx]!;
4111
+ const rec = wiredLabels.find((w) => w.label === idx)!;
4112
+ lb.kind = 'global';
4113
+ lb.shape = 'passive';
4114
+ const clear = (c: { x: number; y: number; rot: number }): boolean =>
4115
+ ![...placed.values()].some((pl) => boundsOverlap(labelTextBox(lb.name, c.x, c.y, c.rot, 'global'), pl.body));
4116
+ const at = wiredFlagCandidates(rec.segs).find(clear) ?? { x: lb.x, y: lb.y, rot: 90 };
4117
+ trace(`label ${lb.name}: run name becomes a flag at (${at.x}, ${at.y}, ${at.rot})`);
4118
+ lb.x = at.x;
4119
+ lb.y = at.y;
4120
+ lb.rot = at.rot;
4121
+ }
4122
+ }
4123
+ }
4124
+
4125
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') {
4126
+ // every crossing of two nets' wires, for the trace: a comb whose lanes
4127
+ // cross another pin's row is legal, but a drafter wants to know where
4128
+ const H = wires.filter((w) => sameCoord(w.y1, w.y2));
4129
+ const V = wires.filter((w) => sameCoord(w.x1, w.x2));
4130
+ for (const h of H) {
4131
+ for (const v of V) {
4132
+ if (h.net === v.net) continue;
4133
+ 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);
4134
+ if (v.x1 > hx1 + 0.01 && v.x1 < hx2 - 0.01 && h.y1 > vy1 + 0.01 && h.y1 < vy2 - 0.01) trace(`crossing ${h.net} (row y=${h.y1}) x ${v.net} (x=${v.x1})`);
1932
4135
  }
1933
4136
  }
1934
4137
  }
@@ -1946,11 +4149,38 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1946
4149
  * unit letter (U1A, U1B), so width metrics must measure the rendered text. */
1947
4150
  const displayRefOf = (pl: Placed): string =>
1948
4151
  pl.unit !== null ? `${pl.refDes}${String.fromCharCode(64 + Math.min(pl.unit, 26))}` : pl.refDes;
4152
+ /**
4153
+ * Width of the body interior left free by the pin NAMES drawn inside it on
4154
+ * the rows a ref/value pair at `cy` would occupy. A large module's names
4155
+ * (ESP32-WROVER's "MTMS/GPIO14/ADC2_CH6") run most of the way across, and a
4156
+ * value dropped on the centre line read as one word with them.
4157
+ */
4158
+ const interiorSpan = (pl: Placed, cy: number): { left: number; right: number } => {
4159
+ let leftW = 0;
4160
+ let rightW = 0;
4161
+ for (const pin of pl.sym.pins) {
4162
+ const o = outward(pin);
4163
+ if (o.dx === 0) continue;
4164
+ const py = pinAt(pl, pin).y;
4165
+ if (Math.abs(py - cy) > 2.54 + 0.7) continue;
4166
+ const w = Math.max(0, pin.name.length) * NAME_ADVANCE * LABEL_HEIGHT;
4167
+ if (o.dx === -1) leftW = Math.max(leftW, w);
4168
+ else rightW = Math.max(rightW, w);
4169
+ }
4170
+ return { left: pl.body.minX + leftW, right: pl.body.maxX - rightW };
4171
+ };
4172
+ /** A library symbol that draws text of its own inside the body (ESP32-WROVER
4173
+ * prints its family name across the middle) has no free interior. */
4174
+ const hasGraphicText = (pl: Placed): boolean => /\(text\s/.test(pl.sym.sourceText ?? '');
4175
+ const pinSidesOf = (pl: Placed): Set<string> =>
4176
+ new Set(
4177
+ pl.sym.pins.map((p) => {
4178
+ const o = outward(p);
4179
+ return o.dx === -1 ? 'left' : o.dx === 1 ? 'right' : o.dy === -1 ? 'top' : 'bottom';
4180
+ }),
4181
+ );
1949
4182
  for (const [, pl] of [...placed.entries()].sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }))) {
1950
- const pinSides = new Set(pl.sym.pins.map((p) => {
1951
- const o = outward(p);
1952
- return o.dx === -1 ? 'left' : o.dx === 1 ? 'right' : o.dy === -1 ? 'top' : 'bottom';
1953
- }));
4183
+ const pinSides = pinSidesOf(pl);
1954
4184
  const cy = (pl.body.minY + pl.body.maxY) / 2;
1955
4185
  const cx = (pl.body.minX + pl.body.maxX) / 2;
1956
4186
  const textW = Math.max(displayRefOf(pl).length, pl.part.value.length) * 0.8 * 1.27;
@@ -1962,15 +4192,22 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1962
4192
  // below the body otherwise
1963
4193
  valueAt = pinSides.has('bottom') ? { x: cx, y: pl.body.minY - 5.08 } : { x: cx, y: pl.body.maxY + 2.54 };
1964
4194
  } else if (
1965
- pl.body.maxX - pl.body.minX >= textW + 2.54 &&
1966
- pl.body.maxY - pl.body.minY >= 7.62
4195
+ pl.body.maxY - pl.body.minY >= 7.62 &&
4196
+ !hasGraphicText(pl) &&
4197
+ // the name at its real advance, centred in the interval the pin names
4198
+ // leave free, with two text heights of air each side: measured from
4199
+ // the body centre instead, ESP32-WROVER's value printed across the
4200
+ // ends of its long left-hand pin names
4201
+ interiorSpan(pl, cy).right - interiorSpan(pl, cy).left >= (textW * NAME_ADVANCE) / LABEL_ADVANCE + 4 * 2.54
1967
4202
  ) {
1968
4203
  // pins on top AND a body big enough to hold its own name: a TQFP-class
1969
4204
  // part carries pins on all four sides, so every outside slot lands on
1970
4205
  // some pin's stub or label; the body interior is the one guaranteed-free
1971
4206
  // area, and it is where KiCad's own large symbols put their text
1972
- refAt = { x: cx, y: cy - 1.27 };
1973
- valueAt = { x: cx, y: cy + 1.27 };
4207
+ const span = interiorSpan(pl, cy);
4208
+ const mid = grid(Math.round((span.left + span.right) / 2 / U));
4209
+ refAt = { x: mid, y: cy - 1.27 };
4210
+ valueAt = { x: mid, y: cy + 1.27 };
1974
4211
  } else {
1975
4212
  refAt = { x: pl.body.maxX + textW / 2 + 1.27, y: cy - 1.27 };
1976
4213
  valueAt = { x: pl.body.maxX + textW / 2 + 1.27, y: cy + 1.27 };
@@ -1980,7 +4217,8 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
1980
4217
  libId: pl.sym.libId,
1981
4218
  value: pl.part.value,
1982
4219
  footprint: pl.part.footprint ?? '',
1983
- at: { x: pl.x, y: pl.y, rot: 0 },
4220
+ at: { x: pl.x, y: pl.y, rot: pl.rot },
4221
+ ...(pl.mirror ? { mirror: pl.mirror } : {}),
1984
4222
  refAt,
1985
4223
  valueAt,
1986
4224
  pinNumbers: pl.sym.pins.map((p) => p.number),
@@ -2017,6 +4255,8 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2017
4255
  const fieldBoxes: Bounds[] = extraSymbols
2018
4256
  .filter((s) => !s.hideValue)
2019
4257
  .map((s) => centeredTextBox(s.value, s.valueAt.x, s.valueAt.y));
4258
+ const powerBodies: Bounds[] = 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 }));
4259
+ const labelBoxes: Bounds[] = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
2020
4260
  for (const { sym, pl } of emitPairs) {
2021
4261
  const dref = displayRefOf(pl);
2022
4262
  const cx = (pl.body.minX + pl.body.maxX) / 2;
@@ -2028,16 +4268,62 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2028
4268
  for (const op of placed.values()) {
2029
4269
  if (op !== pl && boundsOverlap(b, op.body)) return false;
2030
4270
  }
2031
- if (fieldBoxes.some((t) => boundsOverlap(t, b))) return false;
4271
+ // a rail bar or ground symbol is a drawn body too, not just its
4272
+ // value text (R23's reference printed through PVDD's bar)
4273
+ if (powerBodies.some((pb) => boundsOverlap(b, pb))) return false;
4274
+ // the part's own pins: the pin line and its number, from the pin
4275
+ // end to the body edge (U8's name printed over its no-connect pins,
4276
+ // which draw no wire for the wire check to see)
4277
+ for (const pin of pl.sym.pins) {
4278
+ const p = pinAt(pl, pin);
4279
+ const o = outward(pin);
4280
+ const band: Bounds = o.dx !== 0
4281
+ ? { 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 }
4282
+ : { 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) };
4283
+ if (boundsOverlap(b, band)) return false;
4284
+ }
4285
+ if (fieldBoxes.some((t) => boundsOverlap(t, padBox(b)))) return false;
4286
+ // net labels are on the sheet by now; a slot on top of one is no slot
4287
+ if (labelBoxes.some((t) => boundsOverlap(t, padBox(b)))) return false;
2032
4288
  }
2033
4289
  return true;
2034
4290
  };
2035
4291
  if (!pairClear(sym.refAt, sym.valueAt)) {
2036
4292
  const ladder: [{ x: number; y: number }, { x: number; y: number }][] = [];
2037
- for (const extra of [0, 2.54]) {
4293
+ // corner slots: a part with a stub on its top or bottom centre (a
4294
+ // module's rail pin) still has its top-left and top-right free
4295
+ const xl = pl.body.minX + textW / 2 + 1.27;
4296
+ const xr = pl.body.maxX - textW / 2 - 1.27;
4297
+ // A part lying on a row (leads left and right) has rows one pitch
4298
+ // above and below it; its texts fit BETWEEN the rows, half a pitch
4299
+ // out from the body, wherever the neighbouring row carries no wire
4300
+ // there. Reference above and value below first, then both on one
4301
+ // side, then side by side on one line.
4302
+ if (pinSidesOf(pl).has('left') && pinSidesOf(pl).has('right') && !pinSidesOf(pl).has('top')) {
4303
+ const hh = 1.27;
4304
+ // Both texts on ONE side first, and side by side on one line before
4305
+ // stacked: two parts on rows two pitches apart then keep their texts
4306
+ // in their own half of the gap (C27's value and L3's reference met
4307
+ // in the middle of the five millimetres between their rows).
4308
+ ladder.push(
4309
+ [{ x: cx, y: pl.body.minY - hh - 2.54 }, { x: cx, y: pl.body.minY - hh }],
4310
+ [{ x: cx - textW / 2 - 0.635, y: pl.body.minY - hh }, { x: cx + textW / 2 + 0.635, y: pl.body.minY - hh }],
4311
+ [{ x: cx, y: pl.body.maxY + hh }, { x: cx, y: pl.body.maxY + hh + 2.54 }],
4312
+ [{ x: cx - textW / 2 - 0.635, y: pl.body.maxY + hh }, { x: cx + textW / 2 + 0.635, y: pl.body.maxY + hh }],
4313
+ [{ x: cx, y: pl.body.minY - hh }, { x: cx, y: pl.body.maxY + hh }],
4314
+ );
4315
+ }
4316
+ // rungs reach past a rail symbol and its name on a top or bottom
4317
+ // stub (about 6 mm out), so a module pinned on all four sides still
4318
+ // finds a slot above or below itself instead of on its own labels
4319
+ for (const extra of [0, 2.54, 5.08, 7.62, 10.16]) {
2038
4320
  ladder.push(
2039
4321
  [{ x: cx, y: pl.body.maxY + 2.54 + extra }, { x: cx, y: pl.body.maxY + 5.08 + extra }],
2040
4322
  [{ x: cx, y: pl.body.minY - 5.08 - extra }, { x: cx, y: pl.body.minY - 2.54 - extra }],
4323
+ [{ x: xl, y: pl.body.minY - 5.08 - extra }, { x: xl, y: pl.body.minY - 2.54 - extra }],
4324
+ [{ x: xr, y: pl.body.minY - 5.08 - extra }, { x: xr, y: pl.body.minY - 2.54 - extra }],
4325
+ [{ x: xl, y: pl.body.maxY + 2.54 + extra }, { x: xl, y: pl.body.maxY + 5.08 + extra }],
4326
+ [{ x: xr, y: pl.body.maxY + 2.54 + extra }, { x: xr, y: pl.body.maxY + 5.08 + extra }],
2041
4327
  [
2042
4328
  { x: pl.body.maxX + textW / 2 + 1.27 + extra, y: cy - 1.27 },
2043
4329
  { x: pl.body.maxX + textW / 2 + 1.27 + extra, y: cy + 1.27 },
@@ -2048,13 +4334,16 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2048
4334
  ],
2049
4335
  );
2050
4336
  }
4337
+ let slotted = false;
2051
4338
  for (const [r, v] of ladder) {
2052
4339
  if (pairClear(r, v)) {
2053
4340
  sym.refAt = r;
2054
4341
  sym.valueAt = v;
4342
+ slotted = true;
2055
4343
  break;
2056
4344
  }
2057
4345
  }
4346
+ if (!slotted) trace(`fields of ${dref}: no clear slot in ${ladder.length} rungs; heuristic kept`);
2058
4347
  }
2059
4348
  fieldBoxes.push(centeredTextBox(dref, sym.refAt.x, sym.refAt.y), centeredTextBox(sym.value, sym.valueAt.x, sym.valueAt.y));
2060
4349
  }
@@ -2095,22 +4384,86 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2095
4384
  // are the label's own attachment and never count as collisions.
2096
4385
  for (const rec of wiredLabels) {
2097
4386
  const lb = labels[rec.label]!;
2098
- const clearWired = (x: number, y: number): boolean => {
2099
- const box = labelTextBox(lb.name, x, y, 0);
2100
- if (bodies.some((b) => boundsOverlap(box, b))) return false;
2101
- if (textObstacles.some((b) => boundsOverlap(box, b))) return false;
2102
- if (wires.some((w) => !segContains(w, x, y) && segHitsBox(w, box))) return false;
4387
+ const clearWired = (x: number, y: number, rot: number, pad = TEXT_PAD): boolean => {
4388
+ const box = labelTextBox(lb.name, x, y, rot, lb.kind);
4389
+ const padBox = (b: Bounds): Bounds => ({ minX: b.minX - pad, minY: b.minY - pad, maxX: b.maxX + pad, maxY: b.maxY + pad });
4390
+ const blocked = (why: string): false => {
4391
+ trace(`label ${lb.name} at (${x}, ${y}, ${rot}) blocked by ${why}`);
4392
+ return false;
4393
+ };
4394
+ // A wire through the anchor is the label's own attachment — for a
4395
+ // plain label, whose text stands above the line. A flag is drawn
4396
+ // CENTRED on its anchor line, so a wire continuing under its text
4397
+ // runs straight through the flag; only the wire on the pole side
4398
+ // (behind the flag's tip) is its attachment.
4399
+ const attached = (w: { x1: number; y1: number; x2: number; y2: number }): boolean => {
4400
+ if (!segContains(w, x, y)) return false;
4401
+ if (lb.kind !== 'global') return true;
4402
+ const r = ((rot % 360) + 360) % 360;
4403
+ if (r === 0) return Math.max(w.x1, w.x2) <= x + 0.01;
4404
+ if (r === 180) return Math.min(w.x1, w.x2) >= x - 0.01;
4405
+ if (r === 90) return Math.min(w.y1, w.y2) >= y - 0.01;
4406
+ return Math.max(w.y1, w.y2) <= y + 0.01;
4407
+ };
4408
+ const hitBody = [...placed.entries()].find(([, pl]) => boundsOverlap(box, pl.body));
4409
+ if (hitBody) return blocked(`the body of ${hitBody[0]} (${JSON.stringify(hitBody[1].body)})`);
4410
+ if (textObstacles.some((b) => boundsOverlap(padBox(box), b))) return blocked('field text');
4411
+ if (wires.some((w) => !attached(w) && segHitsBox(w, box))) return blocked('a wire');
2103
4412
  return !labels.some(
2104
- (o, i) => i !== rec.label && o.name !== lb.name && boundsOverlap(box, labelTextBox(o.name, o.x, o.y, o.rot)),
4413
+ (o, i) => i !== rec.label && o.name !== lb.name && boundsOverlap(padBox(box), labelTextBox(o.name, o.x, o.y, o.rot, o.kind)),
2105
4414
  );
2106
4415
  };
2107
- if (clearWired(lb.x, lb.y)) continue;
2108
- const alt = rec.pts.find((p) => clearWired(p.x, p.y));
4416
+ if (clearWired(lb.x, lb.y, lb.rot)) continue;
4417
+ trace(`label ${lb.name}: (${lb.x}, ${lb.y}, ${lb.rot}) not clear, searching`);
4418
+ // A flag may turn as well as move — its candidates each carry a rotation
4419
+ // — and prefers a trunk top; a local name stays horizontal and walks the
4420
+ // run's points in anchor-preference order.
4421
+ if (lb.kind === 'global') {
4422
+ // a run's name reads along the row (the drafting standard keeps text
4423
+ // horizontal); a flag that could only stand up leaves the net to
4424
+ // local labels below
4425
+ const alt = wiredFlagCandidates(rec.segs)
4426
+ .filter((c) => c.rot === 0 || c.rot === 180)
4427
+ .find((c) => clearWired(c.x, c.y, c.rot));
4428
+ if (alt) {
4429
+ lb.x = alt.x;
4430
+ lb.y = alt.y;
4431
+ lb.rot = alt.rot;
4432
+ continue;
4433
+ }
4434
+ // A flag that fits nowhere on its run (a part on a pin row of a
4435
+ // 2.54 mm-pitch IC has no free end and no room for a flag standing up
4436
+ // between the rows) leaves the net named by plain labels instead:
4437
+ // every flag of this net becomes a local label, which KiCad connects
4438
+ // across the sheet just the same, and the run's name goes back to
4439
+ // standing on its wire.
4440
+ for (const o of labels) {
4441
+ if (o.name !== lb.name) continue;
4442
+ o.kind = 'local';
4443
+ delete o.shape;
4444
+ if (o.rot === 90 || o.rot === 270) o.rot = 0; // plain text reads along the row
4445
+ }
4446
+ lb.rot = 0;
4447
+ trace(`label ${lb.name}: no flag position clears; the net keeps local labels`);
4448
+ }
4449
+ const alt = rec.pts.find((p) => clearWired(p.x, p.y, 0));
2109
4450
  if (alt) {
4451
+ trace(`label ${lb.name} moved along its run (${lb.x}, ${lb.y}) -> (${alt.x}, ${alt.y})`);
2110
4452
  lb.x = alt.x;
2111
4453
  lb.y = alt.y;
2112
4454
  continue;
2113
4455
  }
4456
+ // reading leftward from a point on the run is the same text on the same
4457
+ // wire; a run whose right-hand end is a part's body (a resistor on the
4458
+ // row) often clears only that way
4459
+ const altLeft = [...rec.pts, ...interiorGridPoints(rec.segs, U)].find((p) => clearWired(p.x, p.y, 180));
4460
+ if (altLeft) {
4461
+ trace(`label ${lb.name} reads leftward from (${altLeft.x}, ${altLeft.y})`);
4462
+ lb.x = altLeft.x;
4463
+ lb.y = altLeft.y;
4464
+ lb.rot = 180;
4465
+ continue;
4466
+ }
2114
4467
  // No segment endpoint clears, but the label may sit at ANY point of its
2115
4468
  // own net's wires: walk the interior grid points of each segment too
2116
4469
  // (#220 phase 2). Endpoints stay the first choice so a run that used to
@@ -2123,20 +4476,39 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2123
4476
  // one the IR gave it. Three corpus nets shipped exactly that way —
2124
4477
  // Net-(F201-Pad1), Net-(U8-BIN) and Net-(U8-RIN), each anchored on no
2125
4478
  // wire of its own run.
2126
- const inner = interiorGridPoints(rec.segs, U).find((p) => clearWired(p.x, p.y));
4479
+ const inner = interiorGridPoints(rec.segs, U).find((p) => clearWired(p.x, p.y, 0));
2127
4480
  if (inner) {
4481
+ trace(`label ${lb.name} moved to an interior point (${inner.x}, ${inner.y})`);
2128
4482
  lb.x = inner.x;
2129
4483
  lb.y = inner.y;
4484
+ continue;
2130
4485
  }
4486
+ // Last resort before leaving the name on a body: the full pad cannot be
4487
+ // had on a 2.54 mm pin row under a flag (a flag's half height plus a
4488
+ // standing label's height already fill the pitch), so accept a quarter
4489
+ // pad at the first point that clears everything else.
4490
+ // (A flag on the row above already reaches a full height below its line,
4491
+ // so against it even a quarter pad is too much; touching boxes are not a
4492
+ // collision to the checker, and are the last rung.)
4493
+ 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 }))];
4494
+ 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));
4495
+ if (tight) {
4496
+ trace(`label ${lb.name} placed at (${tight.x}, ${tight.y}, ${tight.rot}) with a reduced pad`);
4497
+ lb.x = tight.x;
4498
+ lb.y = tight.y;
4499
+ lb.rot = tight.rot;
4500
+ continue;
4501
+ }
4502
+ trace(`label ${lb.name}: no clear point on its run; left at (${lb.x}, ${lb.y})`);
2131
4503
  }
2132
4504
 
2133
4505
  for (const rec of stubbedLabels) {
2134
4506
  const lb = labels[rec.label]!;
2135
4507
  const stub = wires[rec.wire]!;
2136
4508
  const clearAt = (x: number, y: number, rot: number = lb.rot): boolean => {
2137
- const box = labelTextBox(lb.name, x, y, rot);
4509
+ const box = labelTextBox(lb.name, x, y, rot, lb.kind);
2138
4510
  if (bodies.some((b) => boundsOverlap(box, b))) return false;
2139
- if (textObstacles.some((b) => boundsOverlap(box, b))) return false;
4511
+ if (textObstacles.some((b) => boundsOverlap(padBox(box), b))) return false;
2140
4512
  if (wires.some((w, i) => i !== rec.wire && segHitsBox(w, box))) return false;
2141
4513
  // Foreign labels are part of what a label must clear, not just bodies and
2142
4514
  // wires. Without this the pass declares a point clear that another net's
@@ -2146,12 +4518,10 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2146
4518
  // coordinates. Read live from `labels`, so already-nudged neighbours are
2147
4519
  // seen at their final positions and immovable wired-net labels (which
2148
4520
  // carry no stub to ride) are seen at all.
2149
- return !labels.some(
2150
- (o, i) =>
2151
- i !== rec.label &&
2152
- o.name !== lb.name &&
2153
- boundsOverlap(box, labelTextBox(o.name, o.x, o.y, o.rot)),
2154
- );
4521
+ // A label of the SAME net is still text: two BUCK_EN labels one over
4522
+ // the other read as one smudge and the checker flags them (a hung
4523
+ // part's stub label rode down onto the IC pin's).
4524
+ return !labels.some((o, i) => i !== rec.label && boundsOverlap(padBox(box), labelTextBox(o.name, o.x, o.y, o.rot, o.kind)));
2155
4525
  };
2156
4526
  /**
2157
4527
  * Does another net's label sit on exactly this point? That is the fatal
@@ -2177,11 +4547,23 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2177
4547
  predictStubs: false,
2178
4548
  });
2179
4549
  const rideTo = (x: number, y: number): void => {
4550
+ if (!sameCoord(x, lb.x) || !sameCoord(y, lb.y)) trace(`label ${lb.name}: rides its stub from (${lb.x}, ${lb.y}) to (${x}, ${y})`);
2180
4551
  stub.x2 = x;
2181
4552
  stub.y2 = y;
2182
4553
  lb.x = x;
2183
4554
  lb.y = y;
2184
4555
  };
4556
+ // A flag on a vertical stub reads along the row when nothing stands
4557
+ // there (the drafting standard keeps text horizontal; the checker names
4558
+ // every rotated label a horizontal one would have fitted).
4559
+ if (lb.kind === 'global' && rec.o.dy !== 0 && (lb.rot === 90 || lb.rot === 270)) {
4560
+ const flat = [0, 180].find((r) => clearAt(lb.x, lb.y, r) && !mergesAt(lb.x, lb.y));
4561
+ if (flat !== undefined) {
4562
+ lb.rot = flat;
4563
+ continue;
4564
+ }
4565
+ trace(`flag ${lb.name} at (${lb.x}, ${lb.y}) stays vertical: no horizontal draw clears`);
4566
+ }
2185
4567
  if (clearAt(lb.x, lb.y)) continue;
2186
4568
  /** Candidate points along the stub, nearest first. */
2187
4569
  const candidates: { x: number; y: number }[] = [];
@@ -2220,6 +4602,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2220
4602
  (sameCoord(c.x, lb.x) && sameCoord(c.y, lb.y) ? true : wireClearAt(c.x, c.y)),
2221
4603
  );
2222
4604
  if (flip) {
4605
+ trace(`label ${lb.name}: turns to rotation ${flipRot} to clear`);
2223
4606
  lb.rot = flipRot;
2224
4607
  rideTo(flip.x, flip.y);
2225
4608
  continue;
@@ -2251,7 +4634,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2251
4634
  // measure as clean. Nothing clear keeps the placed slot so the report stays
2252
4635
  // honest, and a value that is already clean does not move at all.
2253
4636
  {
2254
- const labelBoxesFinal = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot));
4637
+ const labelBoxesFinal = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
2255
4638
  const memberText = emitPairs.flatMap(({ sym: s, pl }) => [
2256
4639
  centeredTextBox(displayRefOf(pl), s.refAt.x, s.refAt.y),
2257
4640
  centeredTextBox(s.value, s.valueAt.x, s.valueAt.y),
@@ -2261,9 +4644,9 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2261
4644
  const clearFor = (self: EmitSymbol, b: Bounds): boolean =>
2262
4645
  !bodies.some((bd) => boundsOverlap(b, bd)) &&
2263
4646
  !wires.some((w) => segHitsBoxEarly(w, b)) &&
2264
- !labelBoxesFinal.some((lb) => boundsOverlap(lb, b)) &&
2265
- !memberText.some((t) => boundsOverlap(t, b)) &&
2266
- ![...liveBoxes].some(([o, ob]) => o !== self && boundsOverlap(ob, b));
4647
+ !labelBoxesFinal.some((lb) => boundsOverlap(lb, padBox(b))) &&
4648
+ !memberText.some((t) => boundsOverlap(t, padBox(b))) &&
4649
+ ![...liveBoxes].some(([o, ob]) => o !== self && boundsOverlap(ob, padBox(b)));
2267
4650
  for (const s of valueEntries) {
2268
4651
  if (clearFor(s, liveBoxes.get(s)!)) continue;
2269
4652
  // outward = the side of the symbol the text was already offset to
@@ -2310,6 +4693,420 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2310
4693
  }
2311
4694
 
2312
4695
  // ---------- sheet: content-derived paper, balanced placement ----------
4696
+ // ---------- group boxes enclose their text ----------
4697
+ // what the wrap was fitted on, to measure how far the text grew each box
4698
+ const rectsBeforeText = groupRects.map((r) => ({ name: r.name, x1: r.x1, x2: r.x2, y1: r.y1, y2: r.y2 }));
4699
+ const reachOut = new Map<string, Reach>();
4700
+ // A box drawn from bodies plus a fixed margin cuts through the text its
4701
+ // parts carry: with the IC in the leftmost column, its left-facing pin
4702
+ // labels ran out through the box edge (EN, BTN_PLAY, SPK_L+ on esp32-amp),
4703
+ // and a rail name on a connector stub crossed the line on every reference
4704
+ // board. Grow each box to hold its members' stub labels, their reference
4705
+ // and value fields, and the power symbols on their pins, plus BOX_PAD; the
4706
+ // group gaps above were widened for exactly this text, so boxes stay clear
4707
+ // of one another.
4708
+ {
4709
+ const pinGroup = new Map<string, string>();
4710
+ for (const [key, pl] of placed) {
4711
+ const g = groupOf.get(key);
4712
+ if (!g) continue;
4713
+ for (const pin of pl.sym.pins) {
4714
+ const p = pinAt(pl, pin);
4715
+ pinGroup.set(pointKey(p.x, p.y), g);
4716
+ }
4717
+ }
4718
+ const boxesOf = new Map<string, Bounds[]>();
4719
+ const addBox = (g: string | undefined, b: Bounds): void => {
4720
+ if (!g) return;
4721
+ boxesOf.set(g, [...(boxesOf.get(g) ?? []), b]);
4722
+ };
4723
+ // a label at the end of a stub that starts on a member pin
4724
+ for (const lb of labels) {
4725
+ const stub = wires.find((w) => sameCoord(w.x2, lb.x) && sameCoord(w.y2, lb.y) && pinGroup.has(pointKey(w.x1, w.y1)));
4726
+ const g = stub ? pinGroup.get(pointKey(stub.x1, stub.y1)) : undefined;
4727
+ addBox(g, labelReserveBox(lb.name, lb.x, lb.y, lb.rot, lb.kind));
4728
+ }
4729
+ // reference and value fields of every member, at the reserve advance
4730
+ const fieldBox = (text: string, x: number, y: number): Bounds => {
4731
+ const w = Math.max(1, text.length) * TEXT_RESERVE * LABEL_HEIGHT;
4732
+ return { minX: x - w / 2, minY: y - LABEL_HEIGHT / 2, maxX: x + w / 2, maxY: y + LABEL_HEIGHT / 2 };
4733
+ };
4734
+ const keyOfPlaced = new Map<Placed, string>([...placed.entries()].map(([k, p]) => [p, k]));
4735
+ for (const { sym, pl } of emitPairs) {
4736
+ const g = groupOf.get(keyOfPlaced.get(pl) ?? '');
4737
+ addBox(g, fieldBox(displayRefOf(pl), sym.refAt.x, sym.refAt.y));
4738
+ addBox(g, fieldBox(sym.value, sym.valueAt.x, sym.valueAt.y));
4739
+ }
4740
+ // power symbols on member stubs, with their value text
4741
+ for (const ps of extraSymbols) {
4742
+ const stub = wires.find((w) => sameCoord(w.x2, ps.at.x) && sameCoord(w.y2, ps.at.y) && pinGroup.has(pointKey(w.x1, w.y1)));
4743
+ const g = stub ? pinGroup.get(pointKey(stub.x1, stub.y1)) : undefined;
4744
+ if (!g) continue;
4745
+ 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 });
4746
+ if (!ps.hideValue) addBox(g, powerValueBox(ps.value, ps.valueAt.x, ps.valueAt.y));
4747
+ }
4748
+ // every member body too: a hung part on a shared lane or an infilled
4749
+ // cell may sit past the cells the rect was first drawn from
4750
+ for (const [key, pl] of placed) {
4751
+ const g = groupOf.get(key);
4752
+ if (g) addBox(g, pl.body);
4753
+ }
4754
+ for (const r of groupRects) {
4755
+ for (const b of boxesOf.get(r.name) ?? []) {
4756
+ r.x1 = Math.min(r.x1, b.minX - BOX_PAD * U);
4757
+ r.x2 = Math.max(r.x2, b.maxX + BOX_PAD * U);
4758
+ r.y1 = Math.min(r.y1, b.minY - BOX_PAD * U);
4759
+ r.y2 = Math.max(r.y2, b.maxY + BOX_PAD * U);
4760
+ }
4761
+ // The caption is a band across the top of the box, not a corner the
4762
+ // parts may rise into: nothing drawn starts above the caption's
4763
+ // bottom plus a unit of air, whatever its column (a rail name beside
4764
+ // the caption read as part of it).
4765
+ const contentTop = Math.min(...(boxesOf.get(r.name) ?? []).map((b) => b.minY));
4766
+ if (Number.isFinite(contentTop)) r.y1 = Math.min(r.y1, contentTop - (2 + CAPTION_SIZE + U));
4767
+ r.y1 = grid(Math.floor(r.y1 / U));
4768
+ }
4769
+ // measured before the boxes are aligned: alignment is a choice, not text
4770
+ for (const [i, r] of groupRects.entries()) {
4771
+ const before = rectsBeforeText[i]!;
4772
+ const applied = reachMeasured?.get(r.name);
4773
+ reachOut.set(r.name, {
4774
+ left: Math.max(0, before.x1 - r.x1),
4775
+ right: Math.max(0, r.x2 - before.x2),
4776
+ top: (applied?.top ?? 0) + Math.max(0, before.y1 - r.y1),
4777
+ bottom: (applied?.bottom ?? 0) + Math.max(0, r.y2 - before.y2),
4778
+ });
4779
+ }
4780
+ // Boxes in one row of the wrap share a bottom edge, boxes in one column
4781
+ // a right edge, when the neighbour's space is free anyway: a row of
4782
+ // boxes ending at three different heights reads as three afterthoughts.
4783
+ // A line of the wrap (a row, or a column) is the set of boxes whose
4784
+ // fitted rects landed at one top (one left): the shifts that took them
4785
+ // there differ box by box, since the single-row pass gave them
4786
+ // different tops.
4787
+ const columnar = fit.wrap?.kind === 'columns' || fit.wrap?.kind === 'masonry';
4788
+ // Two boxes share a line when their fitted rects overlap across it (in
4789
+ // height for a row, in width for a column): a row's boxes may start at
4790
+ // different tops (the single-row pass gives a box room above its IC for
4791
+ // a pull-up) and two rows never overlap, the wrap having left a gap.
4792
+ const lineIndex: number[] = new Array(groupRects.length).fill(-1);
4793
+ {
4794
+ const lo = (i: number): number => (columnar ? rectsBeforeText[i]!.x1 : rectsBeforeText[i]!.y1);
4795
+ const hi = (i: number): number => (columnar ? rectsBeforeText[i]!.x2 : rectsBeforeText[i]!.y2);
4796
+ const order = groupRects.map((_, i) => i).sort((a, b) => lo(a) - lo(b));
4797
+ let line = -1;
4798
+ let reach = -Infinity;
4799
+ for (const i of order) {
4800
+ if (lo(i) >= reach - 1e-6) line++;
4801
+ lineIndex[i] = line;
4802
+ reach = Math.max(reach, hi(i));
4803
+ }
4804
+ }
4805
+ const lineOf = (i: number): number => lineIndex[i]!;
4806
+ if (fit.wrap) {
4807
+ const byLine = new Map<number, typeof groupRects>();
4808
+ for (const [i, r] of groupRects.entries()) {
4809
+ byLine.set(lineOf(i), [...(byLine.get(lineOf(i)) ?? []), r]);
4810
+ }
4811
+ for (const line of byLine.values()) {
4812
+ if (line.length < 2) continue;
4813
+ if (fit.wrap.kind === 'columns' || fit.wrap.kind === 'masonry') {
4814
+ const right = Math.max(...line.map((r) => r.x2));
4815
+ for (const r of line) r.x2 = right;
4816
+ } else {
4817
+ const bottom = Math.max(...line.map((r) => r.y2));
4818
+ for (const r of line) r.y2 = bottom;
4819
+ }
4820
+ }
4821
+ }
4822
+ // The boxes grew into the gaps the wrap left between cells, each by its
4823
+ // own text, so two boxes might nearly touch where two others stood 10 mm
4824
+ // apart, and a row's boxes started at three different heights. Every box
4825
+ // now moves, with everything drawn in it, so that BOX_LINE_GAP separates
4826
+ // neighbours along and across the lines of the wrap, and a row shares
4827
+ // its top (a column its left). Content moves by whole units to keep the
4828
+ // grid; the box takes the exact position, so its padding varies by less
4829
+ // than a unit while the gaps do not.
4830
+ {
4831
+ const lineGap = BOX_LINE_GAP * U;
4832
+ const byLine = new Map<number, { r: (typeof groupRects)[number]; i: number }[]>();
4833
+ for (const [i, r] of groupRects.entries()) {
4834
+ byLine.set(lineOf(i), [...(byLine.get(lineOf(i)) ?? []), { r, i }]);
4835
+ }
4836
+ // Every drawn item belongs to one group, decided once before anything
4837
+ // moves: a wire, label, junction, no-connect or power symbol to the
4838
+ // group of the pins on its run (wires joined end to end form the run),
4839
+ // anything unwired to the box it lies in. Deciding by box while boxes
4840
+ // move would hand a moved group's items to the next box they land in.
4841
+ const parent = new Map<string, string>();
4842
+ const find = (k: string): string => {
4843
+ let x = k;
4844
+ while (parent.has(x) && parent.get(x) !== x) x = parent.get(x)!;
4845
+ return x;
4846
+ };
4847
+ const union = (a: string, b: string): void => {
4848
+ if (!parent.has(a)) parent.set(a, a);
4849
+ if (!parent.has(b)) parent.set(b, b);
4850
+ const ra = find(a);
4851
+ const rb = find(b);
4852
+ if (ra !== rb) parent.set(ra, rb);
4853
+ };
4854
+ for (const w of wires) union(pointKey(w.x1, w.y1), pointKey(w.x2, w.y2));
4855
+ const runGroup = new Map<string, string>();
4856
+ for (const [pt, g] of pinGroup) {
4857
+ const root = find(pt);
4858
+ if (!runGroup.has(root)) runGroup.set(root, g);
4859
+ }
4860
+ const inside = (x: number, y: number, r: { x1: number; y1: number; x2: number; y2: number }): boolean => x >= r.x1 - 1e-6 && x <= r.x2 + 1e-6 && y >= r.y1 - 1e-6 && y <= r.y2 + 1e-6;
4861
+ const groupAt = (x: number, y: number): string | undefined => runGroup.get(find(pointKey(x, y))) ?? groupRects.find((r) => inside(x, y, r))?.name;
4862
+ const wireGroup = new Map<(typeof wires)[number], string | undefined>(wires.map((w) => [w, groupAt(w.x1, w.y1) ?? groupAt(w.x2, w.y2)]));
4863
+ const pointGroup = new Map<{ x: number; y: number }, string | undefined>();
4864
+ for (const it of [...labels, ...uniqJunctions, ...noConnects]) pointGroup.set(it, groupAt(it.x, it.y));
4865
+ const powerGroup = new Map<(typeof extraSymbols)[number], string | undefined>(extraSymbols.map((ps) => [ps, groupAt(ps.at.x, ps.at.y)]));
4866
+ const moveGroup = (r: (typeof groupRects)[number], dx: number, dy: number): void => {
4867
+ if (!dx && !dy) return;
4868
+ for (const [key, pl] of placed) {
4869
+ if (groupOf.get(key) !== r.name) continue;
4870
+ pl.x += dx;
4871
+ pl.y += dy;
4872
+ pl.body.minX += dx;
4873
+ pl.body.maxX += dx;
4874
+ pl.body.minY += dy;
4875
+ pl.body.maxY += dy;
4876
+ }
4877
+ for (const { sym, pl } of emitPairs) {
4878
+ if (groupOf.get(keyOfPlaced.get(pl) ?? '') !== r.name) continue;
4879
+ sym.at.x += dx;
4880
+ sym.at.y += dy;
4881
+ sym.refAt.x += dx;
4882
+ sym.refAt.y += dy;
4883
+ sym.valueAt.x += dx;
4884
+ sym.valueAt.y += dy;
4885
+ }
4886
+ for (const ps of extraSymbols) {
4887
+ if (powerGroup.get(ps) !== r.name) continue;
4888
+ ps.at.x += dx;
4889
+ ps.at.y += dy;
4890
+ ps.valueAt.x += dx;
4891
+ ps.valueAt.y += dy;
4892
+ }
4893
+ for (const w of wires) {
4894
+ if (wireGroup.get(w) !== r.name) continue;
4895
+ w.x1 += dx;
4896
+ w.x2 += dx;
4897
+ w.y1 += dy;
4898
+ w.y2 += dy;
4899
+ }
4900
+ for (const it of [...labels, ...uniqJunctions, ...noConnects]) {
4901
+ if (pointGroup.get(it) !== r.name) continue;
4902
+ it.x += dx;
4903
+ it.y += dy;
4904
+ }
4905
+ r.x1 += dx;
4906
+ r.x2 += dx;
4907
+ r.y1 += dy;
4908
+ r.y2 += dy;
4909
+ };
4910
+ const snap = (d: number): number => grid(Math.round(d / U));
4911
+ const lines = [...byLine.entries()].sort((a, b) => a[0] - b[0]).map(([, members]) => members);
4912
+ const left0 = Math.min(...groupRects.map((r) => r.x1));
4913
+ const top0 = Math.min(...groupRects.map((r) => r.y1));
4914
+ // what each box was before the alignment above shared its edges
4915
+ 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 }]));
4916
+ if (columnar) {
4917
+ let colLeft = left0;
4918
+ for (const members of lines) {
4919
+ members.sort((a, b) => a.r.y1 - b.r.y1);
4920
+ const colW = Math.max(...members.map((m) => m.r.x2 - m.r.x1));
4921
+ let y = top0;
4922
+ for (const { r } of members) {
4923
+ const h = r.y2 - r.y1;
4924
+ moveGroup(r, snap(colLeft - r.x1), snap(y - r.y1));
4925
+ r.x1 = colLeft;
4926
+ r.x2 = colLeft + colW;
4927
+ r.y1 = y;
4928
+ r.y2 = y + h;
4929
+ y += h + lineGap;
4930
+ }
4931
+ colLeft += colW + lineGap;
4932
+ }
4933
+ } else {
4934
+ let rowTop = top0;
4935
+ for (const members of lines) {
4936
+ members.sort((a, b) => a.r.x1 - b.r.x1);
4937
+ const rowH = Math.max(...members.map((m) => m.r.y2 - m.r.y1));
4938
+ let x = left0;
4939
+ for (const { r } of members) {
4940
+ const w = r.x2 - r.x1;
4941
+ moveGroup(r, snap(x - r.x1), snap(rowTop - r.y1));
4942
+ r.x1 = x;
4943
+ r.x2 = x + w;
4944
+ r.y1 = rowTop;
4945
+ r.y2 = rowTop + rowH;
4946
+ x += w + lineGap;
4947
+ }
4948
+ rowTop += rowH + lineGap;
4949
+ }
4950
+ }
4951
+ // A shared edge may reach where the box's own text did not: the
4952
+ // title-block corner. The sheet pass below centres the content in the
4953
+ // frame; predicted here the same way, any box whose aligned edge would
4954
+ // enter the corner falls back to its own width or height there.
4955
+ {
4956
+ const paper = fit.paper;
4957
+ const xs = groupRects.flatMap((r) => [r.x1, r.x2]);
4958
+ const ys = groupRects.flatMap((r) => [r.y1, r.y2]);
4959
+ const contentW = Math.max(...xs) - Math.min(...xs);
4960
+ const contentH = Math.max(...ys) - Math.min(...ys);
4961
+ const dx = grid(Math.round((FRAME + Math.max(0, (paper.w - 2 * FRAME - contentW) / 2) - Math.min(...xs)) / U));
4962
+ const dy = grid(Math.round((FRAME + 4 * U + Math.max(0, (paper.h - 2 * FRAME - TITLE_STRIP - contentH) / 2) - Math.min(...ys)) / U));
4963
+ const cornerX = paper.w - FRAME - TITLE_BLOCK_W - dx;
4964
+ const cornerY = paper.h - FRAME - TITLE_STRIP - dy;
4965
+ for (const r of groupRects) {
4966
+ if (r.x2 <= cornerX || r.y2 <= cornerY) continue;
4967
+ const o = ownSize.get(r.name)!;
4968
+ const ownX2 = r.x1 + o.w;
4969
+ const ownY2 = r.y1 + o.h;
4970
+ // give back the aligned width first (a wide short row), then the height
4971
+ if (ownX2 <= cornerX && r.x2 > ownX2) r.x2 = ownX2;
4972
+ if (r.x2 > cornerX && ownY2 <= cornerY && r.y2 > ownY2) r.y2 = ownY2;
4973
+ }
4974
+ }
4975
+ }
4976
+ }
4977
+
4978
+ // ---------- measure what every cell drew (for the squeeze), before the sheet shift ----------
4979
+ // Every drawn item is attributed to the cells it belongs to: a body and its
4980
+ // fields to their instance; a wire, with the labels and power symbols on
4981
+ // it, to every instance with a pin on its run (a comb belongs to the IC and
4982
+ // the parts hung on it, which roll up to the IC below). A cell's overhang is
4983
+ // then how far that union reaches past its body on each side.
4984
+ const measuredOut = new Map<string, Overhang>();
4985
+ {
4986
+ const parent = new Map<string, string>();
4987
+ const find = (k: string): string => {
4988
+ let r = k;
4989
+ while (parent.has(r) && parent.get(r) !== r) r = parent.get(r)!;
4990
+ return r;
4991
+ };
4992
+ const union = (a: string, b: string): void => {
4993
+ if (!parent.has(a)) parent.set(a, a);
4994
+ if (!parent.has(b)) parent.set(b, b);
4995
+ const ra = find(a);
4996
+ const rb = find(b);
4997
+ if (ra !== rb) parent.set(ra, rb);
4998
+ };
4999
+ for (const w of wires) union(pointKey(w.x1, w.y1), pointKey(w.x2, w.y2));
5000
+ const boxes = new Map<string, Bounds>();
5001
+ const dbgSrc = new Map<string, string[]>();
5002
+ const extend = (key: string, b: Bounds, why = 'body'): void => {
5003
+ if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') dbgSrc.set(key, [...(dbgSrc.get(key) ?? []), `${why}[${b.minX.toFixed(0)},${b.minY.toFixed(0)}..${b.maxX.toFixed(0)},${b.maxY.toFixed(0)}]`]);
5004
+ const cur = boxes.get(key);
5005
+ 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 });
5006
+ };
5007
+ const compOwners = new Map<string, Set<string>>();
5008
+ for (const [key, pl] of placed) {
5009
+ extend(key, pl.body);
5010
+ for (const pin of pl.sym.pins) {
5011
+ const p = pinAt(pl, pin);
5012
+ const pk = pointKey(p.x, p.y);
5013
+ if (!parent.has(pk)) continue;
5014
+ const c = find(pk);
5015
+ compOwners.set(c, (compOwners.get(c) ?? new Set<string>()).add(key));
5016
+ }
5017
+ }
5018
+ const keyOf = new Map<Placed, string>([...placed.entries()].map(([k, p]) => [p, k]));
5019
+ for (const { sym, pl } of emitPairs) {
5020
+ const key = keyOf.get(pl);
5021
+ if (!key) continue;
5022
+ extend(key, centeredTextBox(displayRefOf(pl), sym.refAt.x, sym.refAt.y), 'ref');
5023
+ extend(key, centeredTextBox(sym.value, sym.valueAt.x, sym.valueAt.y), 'value');
5024
+ }
5025
+ const compBox = new Map<string, Bounds>();
5026
+ const extendComp = (c: string, b: Bounds): void => {
5027
+ const cur = compBox.get(c);
5028
+ 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 });
5029
+ };
5030
+ for (const w of wires) 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) });
5031
+ const compAt = (x: number, y: number): string | null => {
5032
+ const pk = pointKey(x, y);
5033
+ if (parent.has(pk)) return find(pk);
5034
+ const w = wires.find((s) => segContains(s, x, y));
5035
+ return w ? find(pointKey(w.x1, w.y1)) : null;
5036
+ };
5037
+ for (const l of labels) {
5038
+ const c = compAt(l.x, l.y);
5039
+ if (c) extendComp(c, labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
5040
+ }
5041
+ for (const ps of extraSymbols) {
5042
+ const c = compAt(ps.at.x, ps.at.y);
5043
+ if (!c) continue;
5044
+ 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 });
5045
+ if (!ps.hideValue) extendComp(c, powerValueBox(ps.value, ps.valueAt.x, ps.valueAt.y));
5046
+ }
5047
+ const rootOf = (k: string): string => {
5048
+ let r = k;
5049
+ for (let i = 0; i < 6 && boundTo.has(r); i++) r = boundTo.get(r)!;
5050
+ return r;
5051
+ };
5052
+ // A run with one owner cell (an IC and the parts hung on it) is that
5053
+ // cell's; a run shared by several independent cells (a bank's rail and
5054
+ // ground trunks, a cluster wire between two column parts) gives each
5055
+ // owner only the wires that touch its own pins, with the labels and
5056
+ // symbols at their far ends — attributed whole, a bank's trunk made every
5057
+ // capacitor's cell as wide as the bank.
5058
+ const pinComp = new Map<string, string>(); // pin point -> owner key
5059
+ for (const [key, pl] of placed) for (const pin of pl.sym.pins) {
5060
+ const p = pinAt(pl, pin);
5061
+ pinComp.set(pointKey(p.x, p.y), key);
5062
+ }
5063
+ const itemsAt = new Map<string, Bounds[]>(); // point -> boxes of labels/symbols anchored there
5064
+ const addItem = (x: number, y: number, b: Bounds): void => {
5065
+ itemsAt.set(pointKey(x, y), [...(itemsAt.get(pointKey(x, y)) ?? []), b]);
5066
+ };
5067
+ for (const l of labels) addItem(l.x, l.y, labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
5068
+ for (const ps of extraSymbols) {
5069
+ 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 });
5070
+ if (!ps.hideValue) addItem(ps.at.x, ps.at.y, powerValueBox(ps.value, ps.valueAt.x, ps.valueAt.y));
5071
+ }
5072
+ for (const [c, owners] of compOwners) {
5073
+ const roots = new Set([...owners].map(rootOf));
5074
+ const b = compBox.get(c);
5075
+ if (!b) continue;
5076
+ if (roots.size === 1) {
5077
+ extend([...roots][0]!, b, `run(${[...owners].join('+')})`);
5078
+ continue;
5079
+ }
5080
+ for (const w of wires) {
5081
+ if (find(pointKey(w.x1, w.y1)) !== c) continue;
5082
+ const ends = [pointKey(w.x1, w.y1), pointKey(w.x2, w.y2)];
5083
+ const touching = new Set(ends.map((e) => pinComp.get(e)).filter((k): k is string => k !== undefined).map(rootOf));
5084
+ for (const r of touching) {
5085
+ 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})`);
5086
+ for (const e of ends) for (const ib of itemsAt.get(e) ?? []) extend(r, ib, `item@${e}`);
5087
+ }
5088
+ }
5089
+ }
5090
+ for (const [key, b] of [...boxes.entries()]) {
5091
+ const r = rootOf(key);
5092
+ if (r !== key) extend(r, b, `child ${key}`);
5093
+ }
5094
+ for (const [key, pl] of placed) {
5095
+ if (rootOf(key) !== key) continue;
5096
+ const b = boxes.get(key);
5097
+ if (!b) continue;
5098
+ 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)) {
5099
+ 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(' ')}`);
5100
+ }
5101
+ measuredOut.set(key, {
5102
+ left: Math.max(0, pl.body.minX - b.minX),
5103
+ right: Math.max(0, b.maxX - pl.body.maxX),
5104
+ top: Math.max(0, pl.body.minY - b.minY),
5105
+ bottom: Math.max(0, b.maxY - pl.body.maxY),
5106
+ });
5107
+ }
5108
+ }
5109
+
2313
5110
  const allX = [...groupRects.map((r) => r.x1), ...groupRects.map((r) => r.x2)];
2314
5111
  const allY = [...groupRects.map((r) => r.y1), ...groupRects.map((r) => r.y2)];
2315
5112
  const contentW = allX.length ? Math.max(...allX) - Math.min(...allX) : 0;
@@ -2337,11 +5134,15 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2337
5134
  // the frame, and an extent wider than the window keeps the centered offset
2338
5135
  // (that overflow was already noted by the fit pass).
2339
5136
  const textBoxes = [
2340
- ...labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot)),
5137
+ ...labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind)),
2341
5138
  // power VALUE text is measured by the checker too, and the sweep above
2342
5139
  // may have slid it past its group rect's margin (pic_programmer put a
2343
5140
  // rail name 1 mm over the top edge of a compacted sheet)
2344
5141
  ...extraSymbols.filter((s) => !s.hideValue).map((s) => powerValueBox(s.value, s.valueAt.x, s.valueAt.y)),
5142
+ // group captions: left-top justified at CAPTION_SIZE from the box
5143
+ // corner, the way emit.ts writes them and the checker measures them; a
5144
+ // long caption on a narrow group can outrun the box's right edge
5145
+ ...groupRects.map((r): Bounds => ({ 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 })),
2345
5146
  ];
2346
5147
  const fullMinX = Math.min(minX, ...textBoxes.map((b) => b.minX));
2347
5148
  const fullMaxX = Math.max(minX + contentW, ...textBoxes.map((b) => b.maxX));
@@ -2363,7 +5164,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2363
5164
  // the bottom edge is the engine's own usable bottom, ABOVE the title strip:
2364
5165
  // content that fills the sheet's height exactly would otherwise carry the
2365
5166
  // centering pass's 4-unit downward offset into the reserved corner
2366
- dy = clampShift(dy, fullMinY, fullMaxY, FRAME, paper.h - FRAME - TITLE_STRIP);
5167
+ dy = clampShift(dy, fullMinY, fullMaxY, FRAME, fit.intoStrip ? paper.h - FRAME : paper.h - FRAME - TITLE_STRIP);
2367
5168
  const shift = <T extends { x?: number; y?: number; x1?: number; y1?: number; x2?: number; y2?: number }>(o: T): T => {
2368
5169
  if (o.x !== undefined) o.x += dx;
2369
5170
  if (o.y !== undefined) o.y += dy;
@@ -2439,6 +5240,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2439
5240
  noConnects,
2440
5241
  rectangles: groupRects.map((r) => ({ x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2, stroke: 'solid' as const, name: r.name })),
2441
5242
  captions: groupRects.map((r) => ({ text: r.name, x: r.x1 + 2, y: r.y1 + 2, name: r.name })),
5243
+ netColors: netColorsOf(intent.nets, netClasses),
2442
5244
  };
2443
5245
 
2444
5246
  const report: SchematicDraftReport = {
@@ -2456,11 +5258,17 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
2456
5258
  labelCount: labels.length,
2457
5259
  pwrFlags,
2458
5260
  noConnects: noConnects.length,
5261
+ sheetFit: {
5262
+ paper: paper.name,
5263
+ inkUtilization: [...placed.values()].reduce((a, p) => a + p.cellW * p.cellH * U * U, 0) / (usableW(paper) * usableH(paper)),
5264
+ compaction,
5265
+ misses: [...budgetMisses],
5266
+ },
2459
5267
  paper: paper.name,
2460
5268
  notes,
2461
5269
  mergedNets,
2462
5270
  labelOverlaps,
2463
5271
  labelOverlapBudgetExceeded,
2464
5272
  };
2465
- return { model, report };
5273
+ return { model, report, rects: groupRects, measured: measuredOut, reach: reachOut, wrapped: fit.wrap !== null };
2466
5274
  }