copperhead 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +13 -5
  3. package/dist/agent/filetools.js +24 -1
  4. package/dist/agent/filetools.js.map +1 -1
  5. package/dist/agent/ledger.js +24 -0
  6. package/dist/agent/ledger.js.map +1 -1
  7. package/dist/agent/loop.js +67 -62
  8. package/dist/agent/loop.js.map +1 -1
  9. package/dist/agent/prompts.js +4 -3
  10. package/dist/agent/prompts.js.map +1 -1
  11. package/dist/agent/providers/openai.js +28 -6
  12. package/dist/agent/providers/openai.js.map +1 -1
  13. package/dist/agent/providers/tool-protocol.js +21 -0
  14. package/dist/agent/providers/tool-protocol.js.map +1 -1
  15. package/dist/agent/recovery.js +95 -1
  16. package/dist/agent/recovery.js.map +1 -1
  17. package/dist/agent/response-cache.js +18 -2
  18. package/dist/agent/response-cache.js.map +1 -1
  19. package/dist/agent/tools.js +185 -1
  20. package/dist/agent/tools.js.map +1 -1
  21. package/dist/agent/transcript.js +2 -0
  22. package/dist/agent/transcript.js.map +1 -1
  23. package/dist/cli.js +77 -2
  24. package/dist/cli.js.map +1 -1
  25. package/dist/commands/check.js +33 -1
  26. package/dist/commands/check.js.map +1 -1
  27. package/dist/commands/create.js +282 -26
  28. package/dist/commands/create.js.map +1 -1
  29. package/dist/commands/doctor.js +211 -11
  30. package/dist/commands/doctor.js.map +1 -1
  31. package/dist/config.js +61 -4
  32. package/dist/config.js.map +1 -1
  33. package/dist/kicad/bootstrap.js +24 -3
  34. package/dist/kicad/bootstrap.js.map +1 -1
  35. package/dist/kicad/cli.js +7 -26
  36. package/dist/kicad/cli.js.map +1 -1
  37. package/dist/kicad/dossier.js +207 -0
  38. package/dist/kicad/dossier.js.map +1 -0
  39. package/dist/kicad/draft/draft.js +132 -0
  40. package/dist/kicad/draft/draft.js.map +1 -0
  41. package/dist/kicad/draft/engine.js +2389 -0
  42. package/dist/kicad/draft/engine.js.map +1 -0
  43. package/dist/kicad/draft/ir.js +368 -0
  44. package/dist/kicad/draft/ir.js.map +1 -0
  45. package/dist/kicad/draft/symsource.js +490 -0
  46. package/dist/kicad/draft/symsource.js.map +1 -0
  47. package/dist/kicad/emit.js +181 -0
  48. package/dist/kicad/emit.js.map +1 -0
  49. package/dist/kicad/fab.js +13 -0
  50. package/dist/kicad/fab.js.map +1 -1
  51. package/dist/kicad/legibility.js +561 -0
  52. package/dist/kicad/legibility.js.map +1 -0
  53. package/dist/kicad/score.js +261 -0
  54. package/dist/kicad/score.js.map +1 -0
  55. package/dist/kicad/sexp.js +262 -10
  56. package/dist/kicad/sexp.js.map +1 -1
  57. package/dist/kicad/symlib.js +346 -16
  58. package/dist/kicad/symlib.js.map +1 -1
  59. package/dist/memory/bom-table.js +108 -34
  60. package/dist/memory/bom-table.js.map +1 -1
  61. package/dist/memory/scaffold.js +6 -0
  62. package/dist/memory/scaffold.js.map +1 -1
  63. package/dist/openspec/cli.js +2 -1
  64. package/dist/openspec/cli.js.map +1 -1
  65. package/dist/util/preflight.js +17 -0
  66. package/dist/util/preflight.js.map +1 -1
  67. package/dist/util/redact.js +12 -2
  68. package/dist/util/redact.js.map +1 -1
  69. package/package.json +9 -7
  70. package/src/agent/filetools.ts +26 -1
  71. package/src/agent/ledger.ts +24 -0
  72. package/src/agent/loop.ts +88 -65
  73. package/src/agent/prompts.ts +4 -3
  74. package/src/agent/providers/openai.ts +38 -4
  75. package/src/agent/providers/tool-protocol.ts +22 -0
  76. package/src/agent/recovery.ts +94 -1
  77. package/src/agent/response-cache.ts +17 -1
  78. package/src/agent/tools.ts +189 -1
  79. package/src/agent/transcript.ts +6 -0
  80. package/src/cli.ts +73 -2
  81. package/src/commands/check.ts +51 -1
  82. package/src/commands/create.ts +278 -22
  83. package/src/commands/doctor.ts +219 -12
  84. package/src/config.ts +107 -2
  85. package/src/kicad/bootstrap.ts +24 -3
  86. package/src/kicad/cli.ts +6 -19
  87. package/src/kicad/dossier.ts +217 -0
  88. package/src/kicad/draft/draft.ts +171 -0
  89. package/src/kicad/draft/engine.ts +2466 -0
  90. package/src/kicad/draft/ir.ts +416 -0
  91. package/src/kicad/draft/symsource.ts +535 -0
  92. package/src/kicad/emit.ts +236 -0
  93. package/src/kicad/fab.ts +15 -0
  94. package/src/kicad/legibility.ts +646 -0
  95. package/src/kicad/score.ts +323 -0
  96. package/src/kicad/sexp.ts +339 -10
  97. package/src/kicad/symlib.ts +364 -18
  98. package/src/memory/bom-table.ts +119 -31
  99. package/src/memory/scaffold.ts +6 -0
  100. package/src/openspec/cli.ts +3 -2
  101. package/src/util/preflight.ts +18 -0
  102. package/src/util/redact.ts +12 -2
  103. package/dist/memory/synap.js +0 -152
  104. package/dist/memory/synap.js.map +0 -1
  105. package/src/memory/synap.ts +0 -217
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,21 +246,40 @@ 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
  }
224
253
  return out;
225
254
  }
226
255
 
227
- const isPowerSymbol = (libId: string): boolean => libId.startsWith('power:');
256
+ function collectPowerSymbols(sheets: ParsedSheet[]): Set<string> {
257
+ const set = new Set<string>();
258
+ for (const sheet of sheets) {
259
+ const libs = child(sheet.root, 'lib_symbols');
260
+ if (!libs) continue;
261
+ for (const sym of children(libs, 'symbol')) {
262
+ const name = atomAt(sym, 1);
263
+ const p = child(sym, 'power');
264
+ if (name && p !== undefined && atomAt(p, 1) !== 'no') {
265
+ set.add(name);
266
+ }
267
+ }
268
+ }
269
+ return set;
270
+ }
271
+
272
+ const isPowerSymbol = (libId: string, powerSyms: Set<string>): boolean =>
273
+ libId.startsWith('power:') || powerSyms.has(libId);
228
274
 
229
275
  /** One row per real component (power symbols excluded), across all sheets. */
230
276
  export async function listSymbols(rootSch: string): Promise<SchematicSymbol[]> {
231
277
  const sheets = await loadSheets(rootSch);
278
+ const powerSyms = collectPowerSymbols(sheets);
232
279
  const out: SchematicSymbol[] = [];
233
280
  for (const sheet of sheets) {
234
281
  for (const { sym } of symbolsOf(sheet)) {
235
- if (!isPowerSymbol(sym.libId)) out.push(sym);
282
+ if (!isPowerSymbol(sym.libId, powerSyms)) out.push(sym);
236
283
  }
237
284
  }
238
285
  return out.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }));
@@ -241,6 +288,7 @@ export async function listSymbols(rootSch: string): Promise<SchematicSymbol[]> {
241
288
  /** All net names visible via labels and power symbols, across all sheets. */
242
289
  export async function listNets(rootSch: string): Promise<string[]> {
243
290
  const sheets = await loadSheets(rootSch);
291
+ const powerSyms = collectPowerSymbols(sheets);
244
292
  const names = new Set<string>();
245
293
  for (const sheet of sheets) {
246
294
  for (const kind of ['label', 'global_label', 'hierarchical_label']) {
@@ -250,7 +298,7 @@ export async function listNets(rootSch: string): Promise<string[]> {
250
298
  }
251
299
  }
252
300
  for (const { sym } of symbolsOf(sheet)) {
253
- if (isPowerSymbol(sym.libId)) names.add(sym.value);
301
+ if (isPowerSymbol(sym.libId, powerSyms)) names.add(sym.value);
254
302
  }
255
303
  }
256
304
  return [...names].sort();
@@ -263,6 +311,283 @@ export interface PinNet {
263
311
  net: string | null;
264
312
  }
265
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
+ }
339
+
340
+ export interface LabelItem {
341
+ name: string;
342
+ kind: 'label' | 'global_label' | 'hierarchical_label';
343
+ x: number;
344
+ y: number;
345
+ rot: number;
346
+ height: number;
347
+ /** Horizontal component of `(justify …)`, or null when unspecified. KiCad
348
+ * normalizes angles 180/270 to 0/90 at draw time and the stored justify
349
+ * describes that DRAWN frame, so a leftward label appears as angle 180 (no
350
+ * justify), angle 180 + justify right, or angle 0 + justify right — all the
351
+ * same box. */
352
+ justify: 'left' | 'right' | null;
353
+ }
354
+
355
+ export interface RectItem {
356
+ x1: number;
357
+ y1: number;
358
+ x2: number;
359
+ y2: number;
360
+ strokeType: string;
361
+ }
362
+
363
+ export interface PlacedSymbolGeom {
364
+ ref: string;
365
+ libId: string;
366
+ value: string;
367
+ at: { x: number; y: number; rot: number };
368
+ mirror: 'x' | 'y' | null;
369
+ isPower: boolean;
370
+ /** `(unit N)` of the placed instance; 1 for single-unit symbols. */
371
+ unit: number;
372
+ /** Reference and Value property text (absolute schematic coordinates). */
373
+ props: TextItem[];
374
+ }
375
+
376
+ export interface SheetGeometry {
377
+ filePath: string;
378
+ sheetName: string;
379
+ /** `(paper …)`: standard name, or explicit size when the file states one. */
380
+ paper: { name: string | null; width: number | null; height: number | null; portrait: boolean };
381
+ titleBlock: { title: string; date: string; rev: string } | null;
382
+ symbols: PlacedSymbolGeom[];
383
+ wires: WireSeg[];
384
+ labels: LabelItem[];
385
+ /** Free `(text …)` items (group captions live here). */
386
+ texts: TextItem[];
387
+ /** Sheet-level `(rectangle …)` graphics (group boxes live here). */
388
+ rectangles: RectItem[];
389
+ /** Union bounds of each lib symbol's body graphics, symbol space; null when the entry draws nothing. */
390
+ libBounds: Map<string, Bounds | null>;
391
+ libPins: Map<string, PinDef[]>;
392
+ }
393
+
394
+ const num = (n: SexpNode[] | undefined, idx: number, fallback = 0): number => {
395
+ const v = atomAt(n, idx);
396
+ return v === undefined ? fallback : parseFloat(v);
397
+ };
398
+
399
+ function effectsOf(node: SexpNode[]): { height: number; hidden: boolean } {
400
+ const effects = child(node, 'effects');
401
+ const font = effects ? child(effects, 'font') : undefined;
402
+ const size = font ? child(font, 'size') : undefined;
403
+ // hidden: legacy bare `hide` atom, or v9 `(hide yes)`
404
+ const hideNode = effects ? child(effects, 'hide') : undefined;
405
+ const hidden =
406
+ (effects?.some((c) => c === 'hide') ?? false) || (hideNode !== undefined && atomAt(hideNode, 1) !== 'no');
407
+ return { height: size ? num(size, 1, 1.27) : 1.27, hidden };
408
+ }
409
+
410
+ function libBodyBounds(root: SexpNode[]): Map<string, Bounds | null> {
411
+ const map = new Map<string, Bounds | null>();
412
+ const libs = child(root, 'lib_symbols');
413
+ if (!libs) return map;
414
+ for (const sym of children(libs, 'symbol')) {
415
+ const name = atomAt(sym, 1);
416
+ if (!name) continue;
417
+ /** Union across the whole symbol under `name`, per-unit union (common
418
+ * graphics included) under `name#<unit>` — a placed unit of a multi-unit
419
+ * symbol draws only its own unit's graphics, so measuring it with the
420
+ * package union would size every instance like the whole gate pack. */
421
+ const perUnit = new Map<number, Bounds | null>();
422
+ let b: Bounds | null = null;
423
+ const grow = (prev: Bounds | null, x: number, y: number): Bounds => {
424
+ if (!prev) return { minX: x, minY: y, maxX: x, maxY: y };
425
+ prev.minX = Math.min(prev.minX, x);
426
+ prev.minY = Math.min(prev.minY, y);
427
+ prev.maxX = Math.max(prev.maxX, x);
428
+ prev.maxY = Math.max(prev.maxY, y);
429
+ return prev;
430
+ };
431
+ const walk = (n: SexpNode, unit: number | null): void => {
432
+ if (!isList(n)) return;
433
+ const t = tag(n);
434
+ if (t === 'symbol') {
435
+ const m = /_(\d+)_(\d+)$/.exec(atomAt(n, 1) ?? '');
436
+ if (m) {
437
+ if (Number(m[2]) >= 2) return; // de Morgan alternate body style
438
+ unit = Number(m[1]);
439
+ }
440
+ }
441
+ const extend = (x: number, y: number): void => {
442
+ b = grow(b, x, y);
443
+ if (unit !== null) perUnit.set(unit, grow(perUnit.get(unit) ?? null, x, y));
444
+ };
445
+ if (t === 'rectangle') {
446
+ const s = child(n, 'start');
447
+ const e = child(n, 'end');
448
+ extend(num(s, 1), num(s, 2));
449
+ extend(num(e, 1), num(e, 2));
450
+ } else if (t === 'circle') {
451
+ const c = child(n, 'center');
452
+ const r = num(child(n, 'radius'), 1);
453
+ extend(num(c, 1) - r, num(c, 2) - r);
454
+ extend(num(c, 1) + r, num(c, 2) + r);
455
+ } else if (t === 'arc') {
456
+ for (const part of ['start', 'mid', 'end']) {
457
+ const p = child(n, part);
458
+ if (p) extend(num(p, 1), num(p, 2));
459
+ }
460
+ } else if (t === 'polyline') {
461
+ for (const xy of children(child(n, 'pts') ?? [], 'xy')) extend(num(xy, 1), num(xy, 2));
462
+ }
463
+ // pin/text graphics are deliberately excluded from the body box (design C2)
464
+ if (t !== 'pin' && t !== 'text') for (const c of n) walk(c, unit);
465
+ };
466
+ walk(sym, null);
467
+ map.set(name, b);
468
+ const common = perUnit.get(0) ?? null;
469
+ for (const [unit, ub] of perUnit) {
470
+ if (unit === 0) continue;
471
+ const merged =
472
+ ub && common
473
+ ? {
474
+ minX: Math.min(ub.minX, common.minX),
475
+ minY: Math.min(ub.minY, common.minY),
476
+ maxX: Math.max(ub.maxX, common.maxX),
477
+ maxY: Math.max(ub.maxY, common.maxY),
478
+ }
479
+ : (ub ?? (common ? { ...common } : null));
480
+ map.set(`${name}#${unit}`, merged);
481
+ }
482
+ }
483
+ return map;
484
+ }
485
+
486
+ function textItemsOf(sym: SexpNode[]): TextItem[] {
487
+ const out: TextItem[] = [];
488
+ for (const p of children(sym, 'property')) {
489
+ const key = atomAt(p, 1);
490
+ if (key !== 'Reference' && key !== 'Value') continue;
491
+ const at = child(p, 'at');
492
+ const fx = effectsOf(p);
493
+ out.push({
494
+ text: atomAt(p, 2) ?? '',
495
+ x: num(at, 1),
496
+ y: num(at, 2),
497
+ rot: num(at, 3),
498
+ height: fx.height,
499
+ hidden: fx.hidden,
500
+ });
501
+ }
502
+ return out;
503
+ }
504
+
505
+ /**
506
+ * Everything the legibility checker needs to see per sheet, extracted read-only.
507
+ * Never serializes and never mutates the file.
508
+ */
509
+ export async function readSheetGeometry(rootSch: string): Promise<SheetGeometry[]> {
510
+ const sheets = await loadSheets(rootSch);
511
+ const powerSyms = collectPowerSymbols(sheets);
512
+ return sheets.map((sheet) => {
513
+ const paperNode = child(sheet.root, 'paper');
514
+ const paperName = atomAt(paperNode, 1) ?? null;
515
+ const portrait = paperNode?.includes('portrait') ?? false;
516
+ const explicitW = num(paperNode, 2, NaN);
517
+ const explicitH = num(paperNode, 3, NaN);
518
+ const tb = child(sheet.root, 'title_block');
519
+ const tbField = (name: string): string => (tb ? (atomAt(child(tb, name), 1) ?? '') : '');
520
+ const wires: WireSeg[] = [];
521
+ for (const w of children(sheet.root, 'wire')) {
522
+ const pts = children(child(w, 'pts') ?? [], 'xy');
523
+ for (let i = 1; i < pts.length; i++) {
524
+ wires.push({ x1: num(pts[i - 1], 1), y1: num(pts[i - 1], 2), x2: num(pts[i], 1), y2: num(pts[i], 2) });
525
+ }
526
+ }
527
+ const labels: LabelItem[] = [];
528
+ for (const kind of ['label', 'global_label', 'hierarchical_label'] as const) {
529
+ for (const l of children(sheet.root, kind)) {
530
+ const at = child(l, 'at');
531
+ const effects = child(l, 'effects');
532
+ const justify = effects ? child(effects, 'justify') : undefined;
533
+ labels.push({
534
+ name: atomAt(l, 1) ?? '',
535
+ kind,
536
+ x: num(at, 1),
537
+ y: num(at, 2),
538
+ rot: num(at, 3),
539
+ height: effectsOf(l).height,
540
+ justify: justify?.some((c) => c === 'right') ? 'right' : justify?.some((c) => c === 'left') ? 'left' : null,
541
+ });
542
+ }
543
+ }
544
+ const texts: TextItem[] = children(sheet.root, 'text').map((t) => {
545
+ const at = child(t, 'at');
546
+ const fx = effectsOf(t);
547
+ return { text: atomAt(t, 1) ?? '', x: num(at, 1), y: num(at, 2), rot: num(at, 3), height: fx.height, hidden: fx.hidden };
548
+ });
549
+ const rectangles: RectItem[] = children(sheet.root, 'rectangle').map((r) => {
550
+ const s = child(r, 'start');
551
+ const e = child(r, 'end');
552
+ const stroke = child(r, 'stroke');
553
+ return {
554
+ x1: num(s, 1),
555
+ y1: num(s, 2),
556
+ x2: num(e, 1),
557
+ y2: num(e, 2),
558
+ strokeType: atomAt(child(stroke ?? [], 'type'), 1) ?? 'default',
559
+ };
560
+ });
561
+ return {
562
+ filePath: sheet.filePath,
563
+ sheetName: sheet.sheetName,
564
+ paper: {
565
+ name: paperName,
566
+ width: Number.isNaN(explicitW) ? null : explicitW,
567
+ height: Number.isNaN(explicitH) ? null : explicitH,
568
+ portrait,
569
+ },
570
+ titleBlock: tb ? { title: tbField('title'), date: tbField('date'), rev: tbField('rev') } : null,
571
+ symbols: symbolsOf(sheet).map(({ sym, mirror, node }) => ({
572
+ ref: sym.ref,
573
+ libId: sym.libId,
574
+ value: sym.value,
575
+ at: sym.at,
576
+ mirror,
577
+ isPower: isPowerSymbol(sym.libId, powerSyms),
578
+ unit: sym.unit,
579
+ props: textItemsOf(node),
580
+ })),
581
+ wires,
582
+ labels,
583
+ texts,
584
+ rectangles,
585
+ libBounds: libBodyBounds(sheet.root),
586
+ libPins: libPinDefs(sheet.root),
587
+ };
588
+ });
589
+ }
590
+
266
591
  /**
267
592
  * Geometric connectivity per sheet: pins, labels, and wire endpoints that share
268
593
  * coordinates (or are joined by wires) form a group; a group's net name comes
@@ -271,6 +596,7 @@ export interface PinNet {
271
596
  */
272
597
  export async function pinNets(rootSch: string): Promise<PinNet[]> {
273
598
  const sheets = await loadSheets(rootSch);
599
+ const powerSyms = collectPowerSymbols(sheets);
274
600
  const out: PinNet[] = [];
275
601
  for (const sheet of sheets) {
276
602
  const pinDefs = libPinDefs(sheet.root);
@@ -299,12 +625,15 @@ export async function pinNets(rootSch: string): Promise<PinNet[]> {
299
625
 
300
626
  const symPins: { sym: SchematicSymbol; pin: PinDef; k: string }[] = [];
301
627
  for (const { sym, mirror } of symbolsOf(sheet)) {
302
- const defs = pinDefs.get(sym.libId) ?? [];
628
+ // a placed unit of a multi-unit symbol shows only its own unit's pins;
629
+ // resolving the whole package at each instance would invent phantom
630
+ // connection points at every other unit's coordinates
631
+ const defs = pinsOfUnit(pinDefs.get(sym.libId) ?? [], sym.unit);
303
632
  for (const pin of defs) {
304
633
  const abs = pinAbsolute(sym.at, mirror, pin);
305
634
  const k = key(abs.x, abs.y);
306
635
  uf.find(k);
307
- if (isPowerSymbol(sym.libId)) {
636
+ if (isPowerSymbol(sym.libId, powerSyms)) {
308
637
  netNameAt.set(k, sym.value);
309
638
  } else {
310
639
  symPins.push({ sym, pin, k });