copperhead 0.9.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 (142) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +47 -8
  3. package/dist/agent/context.js +2 -0
  4. package/dist/agent/context.js.map +1 -0
  5. package/dist/agent/dock-renderer.js +2 -2
  6. package/dist/agent/dock-renderer.js.map +1 -1
  7. package/dist/agent/envelope.js +105 -0
  8. package/dist/agent/envelope.js.map +1 -0
  9. package/dist/agent/filetools.js +24 -1
  10. package/dist/agent/filetools.js.map +1 -1
  11. package/dist/agent/ledger.js +24 -0
  12. package/dist/agent/ledger.js.map +1 -1
  13. package/dist/agent/loop.js +63 -72
  14. package/dist/agent/loop.js.map +1 -1
  15. package/dist/agent/prompts.js +4 -3
  16. package/dist/agent/prompts.js.map +1 -1
  17. package/dist/agent/providers/claude-code.js +17 -1
  18. package/dist/agent/providers/claude-code.js.map +1 -1
  19. package/dist/agent/providers/codex.js +84 -39
  20. package/dist/agent/providers/codex.js.map +1 -1
  21. package/dist/agent/providers/tool-protocol.js +21 -0
  22. package/dist/agent/providers/tool-protocol.js.map +1 -1
  23. package/dist/agent/recovery.js +186 -15
  24. package/dist/agent/recovery.js.map +1 -1
  25. package/dist/agent/registry.js +49 -0
  26. package/dist/agent/registry.js.map +1 -0
  27. package/dist/agent/render.js +2 -2
  28. package/dist/agent/render.js.map +1 -1
  29. package/dist/agent/theme.js +10 -5
  30. package/dist/agent/theme.js.map +1 -1
  31. package/dist/agent/tools.js +100 -586
  32. package/dist/agent/tools.js.map +1 -1
  33. package/dist/agent/transcript.js +2 -0
  34. package/dist/agent/transcript.js.map +1 -1
  35. package/dist/capabilities/define.js +35 -0
  36. package/dist/capabilities/define.js.map +1 -0
  37. package/dist/capabilities/handlers.js +744 -0
  38. package/dist/capabilities/handlers.js.map +1 -0
  39. package/dist/capabilities/helpers.js +39 -0
  40. package/dist/capabilities/helpers.js.map +1 -0
  41. package/dist/capabilities/index.js +50 -0
  42. package/dist/capabilities/index.js.map +1 -0
  43. package/dist/capabilities/skills/generate-report.js +23 -0
  44. package/dist/capabilities/skills/generate-report.js.map +1 -0
  45. package/dist/cli.js +158 -0
  46. package/dist/cli.js.map +1 -1
  47. package/dist/commands/check.js +33 -1
  48. package/dist/commands/check.js.map +1 -1
  49. package/dist/commands/create.js +181 -26
  50. package/dist/commands/create.js.map +1 -1
  51. package/dist/commands/doctor.js +83 -6
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/skill.js +109 -0
  54. package/dist/commands/skill.js.map +1 -0
  55. package/dist/commands/sync.js +3 -1
  56. package/dist/commands/sync.js.map +1 -1
  57. package/dist/config.js +19 -6
  58. package/dist/config.js.map +1 -1
  59. package/dist/kicad/bootstrap.js +24 -3
  60. package/dist/kicad/bootstrap.js.map +1 -1
  61. package/dist/kicad/cli.js +106 -18
  62. package/dist/kicad/cli.js.map +1 -1
  63. package/dist/kicad/dossier.js +207 -0
  64. package/dist/kicad/dossier.js.map +1 -0
  65. package/dist/kicad/draft/draft.js +135 -0
  66. package/dist/kicad/draft/draft.js.map +1 -0
  67. package/dist/kicad/draft/engine.js +5310 -0
  68. package/dist/kicad/draft/engine.js.map +1 -0
  69. package/dist/kicad/draft/ir.js +368 -0
  70. package/dist/kicad/draft/ir.js.map +1 -0
  71. package/dist/kicad/draft/symsource.js +504 -0
  72. package/dist/kicad/draft/symsource.js.map +1 -0
  73. package/dist/kicad/emit.js +220 -0
  74. package/dist/kicad/emit.js.map +1 -0
  75. package/dist/kicad/fab.js +13 -0
  76. package/dist/kicad/fab.js.map +1 -1
  77. package/dist/kicad/legibility.js +608 -0
  78. package/dist/kicad/legibility.js.map +1 -0
  79. package/dist/kicad/score.js +431 -0
  80. package/dist/kicad/score.js.map +1 -0
  81. package/dist/kicad/sexp.js +265 -6
  82. package/dist/kicad/sexp.js.map +1 -1
  83. package/dist/kicad/symlib.js +346 -16
  84. package/dist/kicad/symlib.js.map +1 -1
  85. package/dist/mcp/server.js +485 -0
  86. package/dist/mcp/server.js.map +1 -0
  87. package/dist/memory/bom-table.js +75 -39
  88. package/dist/memory/bom-table.js.map +1 -1
  89. package/dist/memory/scaffold.js +14 -1
  90. package/dist/memory/scaffold.js.map +1 -1
  91. package/dist/util/redact.js +6 -0
  92. package/dist/util/redact.js.map +1 -1
  93. package/package.json +13 -8
  94. package/src/agent/context.ts +35 -0
  95. package/src/agent/dock-renderer.ts +3 -2
  96. package/src/agent/envelope.ts +124 -0
  97. package/src/agent/filetools.ts +26 -1
  98. package/src/agent/ledger.ts +24 -0
  99. package/src/agent/loop.ts +73 -78
  100. package/src/agent/prompts.ts +4 -3
  101. package/src/agent/providers/claude-code.ts +22 -1
  102. package/src/agent/providers/codex.ts +91 -42
  103. package/src/agent/providers/tool-protocol.ts +22 -0
  104. package/src/agent/recovery.ts +183 -13
  105. package/src/agent/registry.ts +58 -0
  106. package/src/agent/render.ts +4 -3
  107. package/src/agent/theme.ts +15 -5
  108. package/src/agent/tools.ts +124 -628
  109. package/src/agent/transcript.ts +6 -0
  110. package/src/agent/types.ts +10 -5
  111. package/src/capabilities/define.ts +88 -0
  112. package/src/capabilities/handlers.ts +769 -0
  113. package/src/capabilities/helpers.ts +37 -0
  114. package/src/capabilities/index.ts +53 -0
  115. package/src/capabilities/skills/generate-report.ts +25 -0
  116. package/src/cli.ts +154 -0
  117. package/src/commands/check.ts +51 -1
  118. package/src/commands/create.ts +183 -21
  119. package/src/commands/doctor.ts +85 -6
  120. package/src/commands/skill.ts +127 -0
  121. package/src/commands/sync.ts +5 -3
  122. package/src/config.ts +56 -8
  123. package/src/kicad/bootstrap.ts +24 -3
  124. package/src/kicad/cli.ts +129 -18
  125. package/src/kicad/dossier.ts +217 -0
  126. package/src/kicad/draft/draft.ts +173 -0
  127. package/src/kicad/draft/engine.ts +5274 -0
  128. package/src/kicad/draft/ir.ts +416 -0
  129. package/src/kicad/draft/symsource.ts +549 -0
  130. package/src/kicad/emit.ts +300 -0
  131. package/src/kicad/fab.ts +15 -0
  132. package/src/kicad/legibility.ts +695 -0
  133. package/src/kicad/score.ts +502 -0
  134. package/src/kicad/sexp.ts +346 -6
  135. package/src/kicad/symlib.ts +364 -18
  136. package/src/mcp/server.ts +560 -0
  137. package/src/memory/bom-table.ts +85 -38
  138. package/src/memory/scaffold.ts +14 -1
  139. package/src/util/redact.ts +6 -0
  140. package/dist/memory/synap.js +0 -152
  141. package/dist/memory/synap.js.map +0 -1
  142. package/src/memory/synap.ts +0 -217
package/src/kicad/sexp.ts CHANGED
@@ -22,7 +22,11 @@ export function parseSexp(text: string): SexpNode[] {
22
22
  let s = '';
23
23
  while (j < text.length && text[j] !== '"') {
24
24
  if (text[j] === '\\' && j + 1 < text.length) {
25
- s += text[j + 1];
25
+ // KiCad writes multi-line text as `\n` (and tabs as `\t`) inside the
26
+ // quoted atom; mapping the escape to the literal letter would give
27
+ // the legibility width model a one-line `line1nline2` string.
28
+ const e = text[j + 1];
29
+ s += e === 'n' ? '\n' : e === 't' ? '\t' : e!;
26
30
  j += 2;
27
31
  } else {
28
32
  s += text[j];
@@ -88,6 +92,8 @@ export interface SchematicSymbol {
88
92
  sheet: string;
89
93
  at: { x: number; y: number; rot: number };
90
94
  uuid: string;
95
+ /** `(unit N)` of the placed instance; 1 for single-unit symbols. */
96
+ unit: number;
91
97
  }
92
98
 
93
99
  export interface PinDef {
@@ -95,6 +101,9 @@ export interface PinDef {
95
101
  name: string;
96
102
  x: number;
97
103
  y: number;
104
+ /** Unit the pin belongs to (from the `_<unit>_<style>` child it sits in);
105
+ * 0 for a pin in the common unit, drawn on every placed unit. */
106
+ unit: number;
98
107
  }
99
108
 
100
109
  interface ParsedSheet {
@@ -126,7 +135,10 @@ async function loadSheets(rootSch: string): Promise<ParsedSheet[]> {
126
135
  return out;
127
136
  }
128
137
 
129
- /** Pin definitions per lib symbol name, in symbol coordinates. */
138
+ /** Pin definitions per lib symbol name, in symbol coordinates. Each pin
139
+ * carries the unit of the `_<unit>_<style>` child it was defined in (0 for
140
+ * the common unit); de Morgan alternates (style ≥ 2) are skipped so a pin is
141
+ * recorded once. */
130
142
  function libPinDefs(root: SexpNode[]): Map<string, PinDef[]> {
131
143
  const map = new Map<string, PinDef[]>();
132
144
  const libs = child(root, 'lib_symbols');
@@ -135,8 +147,15 @@ function libPinDefs(root: SexpNode[]): Map<string, PinDef[]> {
135
147
  const name = atomAt(sym, 1);
136
148
  if (!name) continue;
137
149
  const pins: PinDef[] = [];
138
- const walk = (n: SexpNode): void => {
150
+ const walk = (n: SexpNode, unit: number): void => {
139
151
  if (!isList(n)) return;
152
+ if (tag(n) === 'symbol') {
153
+ const m = /_(\d+)_(\d+)$/.exec(atomAt(n, 1) ?? '');
154
+ if (m) {
155
+ if (Number(m[2]) >= 2) return; // de Morgan alternate body style
156
+ unit = Number(m[1]);
157
+ }
158
+ }
140
159
  if (tag(n) === 'pin') {
141
160
  const at = child(n, 'at');
142
161
  const num = atomAt(child(n, 'number'), 1);
@@ -147,17 +166,26 @@ function libPinDefs(root: SexpNode[]): Map<string, PinDef[]> {
147
166
  name: pinName ?? '~',
148
167
  x: parseFloat(atomAt(at, 1) ?? '0'),
149
168
  y: parseFloat(atomAt(at, 2) ?? '0'),
169
+ unit,
150
170
  });
151
171
  }
152
172
  }
153
- for (const c of n) walk(c);
173
+ for (const c of n) walk(c, unit);
154
174
  };
155
- walk(sym);
175
+ walk(sym, 0);
156
176
  map.set(name, pins);
157
177
  }
158
178
  return map;
159
179
  }
160
180
 
181
+ /** The pins a PLACED instance actually shows: its own unit's plus the common
182
+ * unit's. A single-unit symbol keeps every pin (they are all unit ≤ 1). */
183
+ export function pinsOfUnit(defs: PinDef[], unit: number): PinDef[] {
184
+ const multi = defs.some((p) => p.unit >= 2);
185
+ if (!multi) return defs;
186
+ return defs.filter((p) => p.unit === 0 || p.unit === unit);
187
+ }
188
+
161
189
  /** Symbol-space → schematic-space transform (schematic Y grows downward). */
162
190
  export function pinAbsolute(
163
191
  symAt: { x: number; y: number; rot: number },
@@ -218,6 +246,7 @@ function symbolsOf(sheet: ParsedSheet): { node: SexpNode[]; sym: SchematicSymbol
218
246
  rot: parseFloat(atomAt(at, 3) ?? '0'),
219
247
  },
220
248
  uuid: atomAt(child(s, 'uuid'), 1) ?? '',
249
+ unit: parseInt(atomAt(child(s, 'unit'), 1) ?? '1', 10) || 1,
221
250
  },
222
251
  });
223
252
  }
@@ -282,6 +311,314 @@ export interface PinNet {
282
311
  net: string | null;
283
312
  }
284
313
 
314
+ // ---------- Read-only geometry accessors for the legibility checker ----------
315
+
316
+ export interface Bounds {
317
+ minX: number;
318
+ minY: number;
319
+ maxX: number;
320
+ maxY: number;
321
+ }
322
+
323
+ export interface WireSeg {
324
+ x1: number;
325
+ y1: number;
326
+ x2: number;
327
+ y2: number;
328
+ }
329
+
330
+ export interface TextItem {
331
+ text: string;
332
+ x: number;
333
+ y: number;
334
+ rot: number;
335
+ /** Font height in mm (KiCad `(size h w)`); 1.27 when unstated. */
336
+ height: number;
337
+ hidden: boolean;
338
+ /** `(justify …)` components, or null when unspecified: KiCad anchors a
339
+ * free text at its centre unless the effects say otherwise, and the
340
+ * engine's group captions say `left top`. */
341
+ justifyH: 'left' | 'right' | null;
342
+ justifyV: 'top' | 'bottom' | null;
343
+ }
344
+
345
+ export interface LabelItem {
346
+ name: string;
347
+ kind: 'label' | 'global_label' | 'hierarchical_label';
348
+ x: number;
349
+ y: number;
350
+ rot: number;
351
+ height: number;
352
+ /** Horizontal component of `(justify …)`, or null when unspecified. KiCad
353
+ * normalizes angles 180/270 to 0/90 at draw time and the stored justify
354
+ * describes that DRAWN frame, so a leftward label appears as angle 180 (no
355
+ * justify), angle 180 + justify right, or angle 0 + justify right — all the
356
+ * same box. */
357
+ justify: 'left' | 'right' | null;
358
+ }
359
+
360
+ export interface RectItem {
361
+ x1: number;
362
+ y1: number;
363
+ x2: number;
364
+ y2: number;
365
+ strokeType: string;
366
+ }
367
+
368
+ export interface PlacedSymbolGeom {
369
+ ref: string;
370
+ libId: string;
371
+ value: string;
372
+ at: { x: number; y: number; rot: number };
373
+ mirror: 'x' | 'y' | null;
374
+ isPower: boolean;
375
+ /** `(unit N)` of the placed instance; 1 for single-unit symbols. */
376
+ unit: number;
377
+ /** Reference and Value property text (absolute schematic coordinates). */
378
+ props: TextItem[];
379
+ }
380
+
381
+ export interface SheetGeometry {
382
+ filePath: string;
383
+ sheetName: string;
384
+ /** `(paper …)`: standard name, or explicit size when the file states one. */
385
+ paper: { name: string | null; width: number | null; height: number | null; portrait: boolean };
386
+ titleBlock: { title: string; date: string; rev: string } | null;
387
+ symbols: PlacedSymbolGeom[];
388
+ wires: WireSeg[];
389
+ labels: LabelItem[];
390
+ /** Free `(text …)` items (group captions live here). */
391
+ texts: TextItem[];
392
+ /** Sheet-level `(rectangle …)` graphics (group boxes live here). */
393
+ rectangles: RectItem[];
394
+ /** Union bounds of each lib symbol's body graphics, symbol space; null when the entry draws nothing. */
395
+ libBounds: Map<string, Bounds | null>;
396
+ libPins: Map<string, PinDef[]>;
397
+ }
398
+
399
+ const num = (n: SexpNode[] | undefined, idx: number, fallback = 0): number => {
400
+ const v = atomAt(n, idx);
401
+ return v === undefined ? fallback : parseFloat(v);
402
+ };
403
+
404
+ function effectsOf(node: SexpNode[]): { height: number; hidden: boolean } {
405
+ const effects = child(node, 'effects');
406
+ const font = effects ? child(effects, 'font') : undefined;
407
+ const size = font ? child(font, 'size') : undefined;
408
+ // hidden: legacy bare `hide` atom, v9 `(hide yes)` inside effects, or
409
+ // KiCad 10's `(hide yes)` as a direct child of the property (a sheet
410
+ // re-saved by eeschema 10 flagged every hidden power reference as text
411
+ // on a wire until this was read)
412
+ const hideNode = effects ? child(effects, 'hide') : undefined;
413
+ const hideProp = child(node, 'hide');
414
+ const hidden =
415
+ (effects?.some((c) => c === 'hide') ?? false) ||
416
+ (hideNode !== undefined && atomAt(hideNode, 1) !== 'no') ||
417
+ (hideProp !== undefined && atomAt(hideProp, 1) !== 'no');
418
+ return { height: size ? num(size, 1, 1.27) : 1.27, hidden };
419
+ }
420
+
421
+ function libBodyBounds(root: SexpNode[]): Map<string, Bounds | null> {
422
+ const map = new Map<string, Bounds | null>();
423
+ const libs = child(root, 'lib_symbols');
424
+ if (!libs) return map;
425
+ for (const sym of children(libs, 'symbol')) {
426
+ const name = atomAt(sym, 1);
427
+ if (!name) continue;
428
+ /** Union across the whole symbol under `name`, per-unit union (common
429
+ * graphics included) under `name#<unit>` — a placed unit of a multi-unit
430
+ * symbol draws only its own unit's graphics, so measuring it with the
431
+ * package union would size every instance like the whole gate pack. */
432
+ const perUnit = new Map<number, Bounds | null>();
433
+ let b: Bounds | null = null;
434
+ const grow = (prev: Bounds | null, x: number, y: number): Bounds => {
435
+ if (!prev) return { minX: x, minY: y, maxX: x, maxY: y };
436
+ prev.minX = Math.min(prev.minX, x);
437
+ prev.minY = Math.min(prev.minY, y);
438
+ prev.maxX = Math.max(prev.maxX, x);
439
+ prev.maxY = Math.max(prev.maxY, y);
440
+ return prev;
441
+ };
442
+ const walk = (n: SexpNode, unit: number | null): void => {
443
+ if (!isList(n)) return;
444
+ const t = tag(n);
445
+ if (t === 'symbol') {
446
+ const m = /_(\d+)_(\d+)$/.exec(atomAt(n, 1) ?? '');
447
+ if (m) {
448
+ if (Number(m[2]) >= 2) return; // de Morgan alternate body style
449
+ unit = Number(m[1]);
450
+ }
451
+ }
452
+ const extend = (x: number, y: number): void => {
453
+ b = grow(b, x, y);
454
+ if (unit !== null) perUnit.set(unit, grow(perUnit.get(unit) ?? null, x, y));
455
+ };
456
+ if (t === 'rectangle') {
457
+ const s = child(n, 'start');
458
+ const e = child(n, 'end');
459
+ extend(num(s, 1), num(s, 2));
460
+ extend(num(e, 1), num(e, 2));
461
+ } else if (t === 'circle') {
462
+ const c = child(n, 'center');
463
+ const r = num(child(n, 'radius'), 1);
464
+ extend(num(c, 1) - r, num(c, 2) - r);
465
+ extend(num(c, 1) + r, num(c, 2) + r);
466
+ } else if (t === 'arc') {
467
+ for (const part of ['start', 'mid', 'end']) {
468
+ const p = child(n, part);
469
+ if (p) extend(num(p, 1), num(p, 2));
470
+ }
471
+ } else if (t === 'polyline') {
472
+ for (const xy of children(child(n, 'pts') ?? [], 'xy')) extend(num(xy, 1), num(xy, 2));
473
+ }
474
+ // pin/text graphics are deliberately excluded from the body box (design C2)
475
+ if (t !== 'pin' && t !== 'text') for (const c of n) walk(c, unit);
476
+ };
477
+ walk(sym, null);
478
+ map.set(name, b);
479
+ const common = perUnit.get(0) ?? null;
480
+ for (const [unit, ub] of perUnit) {
481
+ if (unit === 0) continue;
482
+ const merged =
483
+ ub && common
484
+ ? {
485
+ minX: Math.min(ub.minX, common.minX),
486
+ minY: Math.min(ub.minY, common.minY),
487
+ maxX: Math.max(ub.maxX, common.maxX),
488
+ maxY: Math.max(ub.maxY, common.maxY),
489
+ }
490
+ : (ub ?? (common ? { ...common } : null));
491
+ map.set(`${name}#${unit}`, merged);
492
+ }
493
+ }
494
+ return map;
495
+ }
496
+
497
+ function textItemsOf(sym: SexpNode[], symRot = 0): TextItem[] {
498
+ const out: TextItem[] = [];
499
+ for (const p of children(sym, 'property')) {
500
+ const key = atomAt(p, 1);
501
+ if (key !== 'Reference' && key !== 'Value') continue;
502
+ const at = child(p, 'at');
503
+ const fx = effectsOf(p);
504
+ out.push({
505
+ text: atomAt(p, 2) ?? '',
506
+ x: num(at, 1),
507
+ y: num(at, 2),
508
+ // KiCad draws a property at its stored angle PLUS the symbol's: a
509
+ // resistor turned 90 stores 90 on its fields to read horizontally,
510
+ // and a field stored at 0 on that resistor stands on end. The
511
+ // checker measures what is drawn.
512
+ rot: (((num(at, 3) + symRot) % 360) + 360) % 360,
513
+ height: fx.height,
514
+ hidden: fx.hidden,
515
+ // property text keeps the centred measurement: its justification is
516
+ // relative to the symbol's own rotation and mirror, which the checker
517
+ // does not model, and the engine emits it centred
518
+ justifyH: null,
519
+ justifyV: null,
520
+ });
521
+ }
522
+ return out;
523
+ }
524
+
525
+ /**
526
+ * Everything the legibility checker needs to see per sheet, extracted read-only.
527
+ * Never serializes and never mutates the file.
528
+ */
529
+ export async function readSheetGeometry(rootSch: string): Promise<SheetGeometry[]> {
530
+ const sheets = await loadSheets(rootSch);
531
+ const powerSyms = collectPowerSymbols(sheets);
532
+ return sheets.map((sheet) => {
533
+ const paperNode = child(sheet.root, 'paper');
534
+ const paperName = atomAt(paperNode, 1) ?? null;
535
+ const portrait = paperNode?.includes('portrait') ?? false;
536
+ const explicitW = num(paperNode, 2, NaN);
537
+ const explicitH = num(paperNode, 3, NaN);
538
+ const tb = child(sheet.root, 'title_block');
539
+ const tbField = (name: string): string => (tb ? (atomAt(child(tb, name), 1) ?? '') : '');
540
+ const wires: WireSeg[] = [];
541
+ for (const w of children(sheet.root, 'wire')) {
542
+ const pts = children(child(w, 'pts') ?? [], 'xy');
543
+ for (let i = 1; i < pts.length; i++) {
544
+ wires.push({ x1: num(pts[i - 1], 1), y1: num(pts[i - 1], 2), x2: num(pts[i], 1), y2: num(pts[i], 2) });
545
+ }
546
+ }
547
+ const labels: LabelItem[] = [];
548
+ for (const kind of ['label', 'global_label', 'hierarchical_label'] as const) {
549
+ for (const l of children(sheet.root, kind)) {
550
+ const at = child(l, 'at');
551
+ const effects = child(l, 'effects');
552
+ const justify = effects ? child(effects, 'justify') : undefined;
553
+ labels.push({
554
+ name: atomAt(l, 1) ?? '',
555
+ kind,
556
+ x: num(at, 1),
557
+ y: num(at, 2),
558
+ rot: num(at, 3),
559
+ height: effectsOf(l).height,
560
+ justify: justify?.some((c) => c === 'right') ? 'right' : justify?.some((c) => c === 'left') ? 'left' : null,
561
+ });
562
+ }
563
+ }
564
+ const texts: TextItem[] = children(sheet.root, 'text').map((t) => {
565
+ const at = child(t, 'at');
566
+ const fx = effectsOf(t);
567
+ const effects = child(t, 'effects');
568
+ const justify = effects ? child(effects, 'justify') : undefined;
569
+ return {
570
+ text: atomAt(t, 1) ?? '',
571
+ x: num(at, 1),
572
+ y: num(at, 2),
573
+ rot: num(at, 3),
574
+ height: fx.height,
575
+ hidden: fx.hidden,
576
+ justifyH: justify?.some((c) => c === 'right') ? 'right' : justify?.some((c) => c === 'left') ? 'left' : null,
577
+ justifyV: justify?.some((c) => c === 'bottom') ? 'bottom' : justify?.some((c) => c === 'top') ? 'top' : null,
578
+ };
579
+ });
580
+ const rectangles: RectItem[] = children(sheet.root, 'rectangle').map((r) => {
581
+ const s = child(r, 'start');
582
+ const e = child(r, 'end');
583
+ const stroke = child(r, 'stroke');
584
+ return {
585
+ x1: num(s, 1),
586
+ y1: num(s, 2),
587
+ x2: num(e, 1),
588
+ y2: num(e, 2),
589
+ strokeType: atomAt(child(stroke ?? [], 'type'), 1) ?? 'default',
590
+ };
591
+ });
592
+ return {
593
+ filePath: sheet.filePath,
594
+ sheetName: sheet.sheetName,
595
+ paper: {
596
+ name: paperName,
597
+ width: Number.isNaN(explicitW) ? null : explicitW,
598
+ height: Number.isNaN(explicitH) ? null : explicitH,
599
+ portrait,
600
+ },
601
+ titleBlock: tb ? { title: tbField('title'), date: tbField('date'), rev: tbField('rev') } : null,
602
+ symbols: symbolsOf(sheet).map(({ sym, mirror, node }) => ({
603
+ ref: sym.ref,
604
+ libId: sym.libId,
605
+ value: sym.value,
606
+ at: sym.at,
607
+ mirror,
608
+ isPower: isPowerSymbol(sym.libId, powerSyms),
609
+ unit: sym.unit,
610
+ props: textItemsOf(node, sym.at.rot),
611
+ })),
612
+ wires,
613
+ labels,
614
+ texts,
615
+ rectangles,
616
+ libBounds: libBodyBounds(sheet.root),
617
+ libPins: libPinDefs(sheet.root),
618
+ };
619
+ });
620
+ }
621
+
285
622
  /**
286
623
  * Geometric connectivity per sheet: pins, labels, and wire endpoints that share
287
624
  * coordinates (or are joined by wires) form a group; a group's net name comes
@@ -319,7 +656,10 @@ export async function pinNets(rootSch: string): Promise<PinNet[]> {
319
656
 
320
657
  const symPins: { sym: SchematicSymbol; pin: PinDef; k: string }[] = [];
321
658
  for (const { sym, mirror } of symbolsOf(sheet)) {
322
- const defs = pinDefs.get(sym.libId) ?? [];
659
+ // a placed unit of a multi-unit symbol shows only its own unit's pins;
660
+ // resolving the whole package at each instance would invent phantom
661
+ // connection points at every other unit's coordinates
662
+ const defs = pinsOfUnit(pinDefs.get(sym.libId) ?? [], sym.unit);
323
663
  for (const pin of defs) {
324
664
  const abs = pinAbsolute(sym.at, mirror, pin);
325
665
  const k = key(abs.x, abs.y);