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
@@ -308,14 +308,26 @@ export class SymbolSource {
308
308
  return [...this.libs].sort();
309
309
  }
310
310
 
311
- /** Write an engine-generated symbol block into the vendored cache (power lib). */
311
+ /**
312
+ * Write an engine-generated symbol block into the vendored cache (power lib).
313
+ *
314
+ * A generated block is the engine's, not a vendor's: when the engine's
315
+ * drawing of a rail or ground changes, the cached copy must follow, or every
316
+ * embedded power symbol on the sheet raises a lib_symbol_mismatch warning
317
+ * against the stale library (the triangle-ground redraw hit 111 of them on
318
+ * esp32-amp). A block already cached byte-for-byte is left alone.
319
+ */
312
320
  async vendorGenerated(libId: string, block: string): Promise<void> {
313
321
  const lib = libId.slice(0, libId.indexOf(':'));
314
322
  const name = libId.slice(libId.indexOf(':') + 1);
315
323
  const file = path.join(this.cacheDir(), vendorFileName(lib));
316
324
  await mkdir(this.cacheDir(), { recursive: true });
317
- const text = existsSync(file) ? await readFile(file, 'utf8') : emptyVendorLib();
318
- if (!extractSymbolBlock(text, name)) await writeFile(file, appendToVendorLib(text, block), 'utf8');
325
+ let text = existsSync(file) ? await readFile(file, 'utf8') : emptyVendorLib();
326
+ const cached = extractSymbolBlock(text, name);
327
+ if (cached !== block) {
328
+ if (cached) text = text.replace(cached + '\n', '').replace(cached, '');
329
+ await writeFile(file, appendToVendorLib(text, block), 'utf8');
330
+ }
319
331
  this.libs.add(lib);
320
332
  }
321
333
 
@@ -473,15 +485,16 @@ export function powerSymbolSource(rawNet: string, kind: 'rail' | 'ground'): { li
473
485
  const name = powerNetToken(rawNet);
474
486
  const libId = `copperhead_power:${name}`;
475
487
  // Symbol space is Y-up and the schematic transform flips it: negative-Y
476
- // graphics here render BELOW the connection point on the sheet. Ground bars
477
- // hang below their pin (angle 270 = line toward -Y body); rail bars sit
478
- // above (angle 90).
488
+ // graphics here render BELOW the connection point on the sheet. A ground
489
+ // hangs below its pin (angle 270 = line toward -Y body) as a stem and an
490
+ // open triangle, the reference-ground glyph; a rail rises above (angle 90)
491
+ // as a stem and a filled arrowhead, the way KiCad's own +3V3 draws. Both
492
+ // reach 2.54 from the pin, inside the 2-unit box the engine reserves.
479
493
  const body =
480
494
  kind === 'ground'
481
495
  ? `\t(symbol "${name}_0_1"
482
- \t\t(polyline (pts (xy -1.27 -1.27) (xy 1.27 -1.27)) (stroke (width 0.254) (type default)) (fill (type none)))
483
- \t\t(polyline (pts (xy -0.762 -1.778) (xy 0.762 -1.778)) (stroke (width 0.254) (type default)) (fill (type none)))
484
- \t\t(polyline (pts (xy -0.254 -2.286) (xy 0.254 -2.286)) (stroke (width 0.254) (type default)) (fill (type none)))
496
+ \t\t(polyline (pts (xy 0 0) (xy 0 -1.27)) (stroke (width 0.254) (type default)) (fill (type none)))
497
+ \t\t(polyline (pts (xy -1.27 -1.27) (xy 1.27 -1.27) (xy 0 -2.54) (xy -1.27 -1.27)) (stroke (width 0.254) (type default)) (fill (type none)))
485
498
  \t)
486
499
  \t(symbol "${name}_1_1"
487
500
  \t\t(pin power_in line (at 0 0 270) (length 0) hide
@@ -490,7 +503,8 @@ export function powerSymbolSource(rawNet: string, kind: 'rail' | 'ground'): { li
490
503
  \t\t)
491
504
  \t)`
492
505
  : `\t(symbol "${name}_0_1"
493
- \t\t(polyline (pts (xy -1.27 1.27) (xy 1.27 1.27)) (stroke (width 0.254) (type default)) (fill (type none)))
506
+ \t\t(polyline (pts (xy 0 0) (xy 0 1.27)) (stroke (width 0.254) (type default)) (fill (type none)))
507
+ \t\t(polyline (pts (xy -0.762 1.27) (xy 0 2.54) (xy 0.762 1.27) (xy -0.762 1.27)) (stroke (width 0.254) (type default)) (fill (type outline)))
494
508
  \t)
495
509
  \t(symbol "${name}_1_1"
496
510
  \t\t(pin power_in line (at 0 0 90) (length 0) hide
package/src/kicad/emit.ts CHANGED
@@ -41,6 +41,8 @@ export interface EmitSymbol {
41
41
  value: string;
42
42
  footprint: string;
43
43
  at: { x: number; y: number; rot: number };
44
+ /** KiCad `(mirror x|y)`: y flips the symbol left-for-right (a transistor whose base must face right). */
45
+ mirror?: 'x' | 'y';
44
46
  /** Reference/Value text anchors (absolute). Hidden properties reuse the origin. */
45
47
  refAt: { x: number; y: number };
46
48
  valueAt: { x: number; y: number };
@@ -55,6 +57,38 @@ export interface EmitSymbol {
55
57
  unit?: number;
56
58
  }
57
59
 
60
+ /** Flag outline of a global label: KiCad's shape vocabulary, chosen from the
61
+ * electrical type of the pin the label serves (`shape` is cosmetic in ERC). */
62
+ export type LabelShape = 'input' | 'output' | 'bidirectional' | 'passive';
63
+
64
+ export interface EmitLabel {
65
+ name: string;
66
+ x: number;
67
+ y: number;
68
+ /** KiCad's own rotation: text extends right (0), up (90), left (180) or
69
+ * down (270) from the anchor, in schematic Y-down coordinates. */
70
+ rot: number;
71
+ /** A `global` label draws as a bordered flag whose tip sits on the wire
72
+ * end; a `local` one is bare text standing on its wire. Nets that leave a
73
+ * wired run through a stub carry global flags everywhere (a local and a
74
+ * global label of one name do not connect in KiCad); a net wired whole
75
+ * keeps a plain local name on the run. Unstated means local. */
76
+ kind?: 'local' | 'global';
77
+ /** Flag shape of a global label; unstated means passive (a plain box). */
78
+ shape?: LabelShape;
79
+ }
80
+
81
+ /** Font height, mm, of a group caption; bold so a subsystem title reads at
82
+ * arm's length on an A1/A2 sheet. */
83
+ export const CAPTION_SIZE = 3.5;
84
+ /** Font height, mm, of every net label. */
85
+ export const LABEL_SIZE = 1.27;
86
+ /** Stroke thickness, mm, of label text and the flag outline drawn with it;
87
+ * KiCad's default is size/8 ≈ 0.16, too faint against a coloured wire. */
88
+ export const LABEL_THICKNESS = 0.2;
89
+ /** Wire stroke width, mm; 0 would mean KiCad's default of 0.1524. */
90
+ export const WIRE_WIDTH = 0.254;
91
+
58
92
  export interface PlacementModel {
59
93
  projectName: string;
60
94
  paper: string;
@@ -64,10 +98,12 @@ export interface PlacementModel {
64
98
  symbols: EmitSymbol[];
65
99
  wires: { x1: number; y1: number; x2: number; y2: number; net: string; index: number }[];
66
100
  junctions: { x: number; y: number }[];
67
- labels: { name: string; x: number; y: number; rot: number }[];
101
+ labels: EmitLabel[];
68
102
  noConnects: { x: number; y: number }[];
69
103
  rectangles: { x1: number; y1: number; x2: number; y2: number; stroke: 'solid' | 'dash'; name: string }[];
70
104
  captions: { text: string; x: number; y: number; name: string }[];
105
+ /** Wire and label colour per net, RGB 0..255; nets absent here draw in the theme default. */
106
+ netColors?: Record<string, [number, number, number]>;
71
107
  }
72
108
 
73
109
  /**
@@ -147,11 +183,15 @@ export function emitSchematic(model: PlacementModel): string {
147
183
  L.push(`\t(no_connect (at ${knum(n.x)} ${knum(n.y)}) (uuid ${q(id(`no_connect/${knum(n.x)},${knum(n.y)}`))}))`);
148
184
  }
149
185
 
186
+ const colorOf = (net: string): string => {
187
+ const c = model.netColors?.[net];
188
+ return c ? ` (color ${c[0]} ${c[1]} ${c[2]} 1)` : '';
189
+ };
150
190
  const wires = [...model.wires].sort((a, b) => a.net.localeCompare(b.net) || a.index - b.index);
151
191
  for (const w of wires) {
152
192
  L.push('\t(wire');
153
193
  L.push(`\t\t(pts (xy ${knum(w.x1)} ${knum(w.y1)}) (xy ${knum(w.x2)} ${knum(w.y2)}))`);
154
- L.push('\t\t(stroke (width 0) (type default))');
194
+ L.push(`\t\t(stroke (width ${knum(WIRE_WIDTH)}) (type default)${colorOf(w.net)})`);
155
195
  L.push(`\t\t(uuid ${q(id(`wire/${w.net}/${w.index}`))})`);
156
196
  L.push('\t)');
157
197
  }
@@ -168,18 +208,36 @@ export function emitSchematic(model: PlacementModel): string {
168
208
  const caps = [...model.captions].sort((a, b) => a.name.localeCompare(b.name));
169
209
  for (const c of caps) {
170
210
  L.push(`\t(text ${q(c.text)} (at ${knum(c.x)} ${knum(c.y)} 0)`);
171
- L.push('\t\t(effects (font (size 2 2)) (justify left top))');
211
+ L.push(`\t\t(effects (font (size ${knum(CAPTION_SIZE)} ${knum(CAPTION_SIZE)}) bold) (justify left top))`);
172
212
  L.push(`\t\t(uuid ${q(id(`caption/${c.name}`))})`);
173
213
  L.push('\t)');
174
214
  }
175
215
 
176
216
  const labels = [...model.labels].sort((a, b) => a.name.localeCompare(b.name) || a.x - b.x || a.y - b.y);
177
217
  for (const lb of labels) {
218
+ const uuid = q(id(`label/${lb.name}/${knum(lb.x)},${knum(lb.y)}`));
219
+ if (lb.kind === 'global') {
220
+ // eeschema's own encoding of a flag: the stored angle is the true one
221
+ // and the justification names the side of the anchor the text is on
222
+ // (left = extends right/up, right = extends left/down)
223
+ const rot = ((lb.rot % 360) + 360) % 360;
224
+ const justify = rot === 180 || rot === 270 ? 'right' : 'left';
225
+ L.push(`\t(global_label ${q(lb.name)} (shape ${lb.shape ?? 'passive'}) (at ${knum(lb.x)} ${knum(lb.y)} ${knum(rot)}) (fields_autoplaced yes)`);
226
+ L.push(`\t\t(effects (font (size ${knum(LABEL_SIZE)} ${knum(LABEL_SIZE)}) (thickness ${knum(LABEL_THICKNESS)})${colorOf(lb.name)}) (justify ${justify}))`);
227
+ L.push(`\t\t(uuid ${uuid})`);
228
+ L.push(`\t\t(property "Intersheetrefs" "\${INTERSHEET_REFS}" (at ${knum(lb.x)} ${knum(lb.y)} 0)`);
229
+ L.push(`\t\t\t(effects (font (size ${knum(LABEL_SIZE)} ${knum(LABEL_SIZE)})) hide)`);
230
+ L.push('\t\t)');
231
+ L.push('\t)');
232
+ continue;
233
+ }
234
+ // a local label never draws upside down: 180/270 are drawn as 0/90 with
235
+ // the justification mirrored, which is how eeschema itself stores them
178
236
  const justify = lb.rot === 180 || lb.rot === 270 ? 'right bottom' : 'left bottom';
179
237
  const drawRot = lb.rot === 180 ? 0 : lb.rot === 270 ? 90 : lb.rot;
180
238
  L.push(`\t(label ${q(lb.name)} (at ${knum(lb.x)} ${knum(lb.y)} ${knum(drawRot)})`);
181
- L.push(`\t\t(effects (font (size 1.27 1.27)) (justify ${justify}))`);
182
- L.push(`\t\t(uuid ${q(id(`label/${lb.name}/${knum(lb.x)},${knum(lb.y)}`))})`);
239
+ L.push(`\t\t(effects (font (size ${knum(LABEL_SIZE)} ${knum(LABEL_SIZE)}) (thickness ${knum(LABEL_THICKNESS)})${colorOf(lb.name)}) (justify ${justify}))`);
240
+ L.push(`\t\t(uuid ${uuid})`);
183
241
  L.push('\t)');
184
242
  }
185
243
 
@@ -196,16 +254,22 @@ export function emitSchematic(model: PlacementModel): string {
196
254
  L.push('\t(symbol');
197
255
  L.push(`\t\t(lib_id ${q(s.libId)})`);
198
256
  L.push(`\t\t(at ${knum(s.at.x)} ${knum(s.at.y)} ${knum(s.at.rot)})`);
257
+ if (s.mirror) L.push(`\t\t(mirror ${s.mirror})`);
199
258
  L.push(`\t\t(unit ${unit})`);
200
259
  L.push('\t\t(exclude_from_sim no)');
201
260
  L.push('\t\t(in_bom yes)');
202
261
  L.push('\t\t(on_board yes)');
203
262
  L.push('\t\t(dnp no)');
204
263
  L.push(`\t\t(uuid ${q(sid)})`);
205
- L.push(`\t\t(property "Reference" ${q(s.ref)} (at ${knum(s.refAt.x)} ${knum(s.refAt.y)} 0)`);
264
+ // KiCad adds the symbol's rotation to a property's angle when it draws
265
+ // the text, so a part turned 90 or 270 stores 90 to keep its reference
266
+ // and value reading horizontally (every rotated resistor in KiCad's own
267
+ // demos does). Storing 0 stood the bootstrap caps' values on end.
268
+ const fieldRot = s.at.rot % 180 === 90 ? 90 : 0;
269
+ L.push(`\t\t(property "Reference" ${q(s.ref)} (at ${knum(s.refAt.x)} ${knum(s.refAt.y)} ${fieldRot})`);
206
270
  L.push(`\t\t\t(effects (font (size 1.27 1.27))${hideRef})`);
207
271
  L.push('\t\t)');
208
- L.push(`\t\t(property "Value" ${q(s.value)} (at ${knum(s.valueAt.x)} ${knum(s.valueAt.y)} 0)`);
272
+ L.push(`\t\t(property "Value" ${q(s.value)} (at ${knum(s.valueAt.x)} ${knum(s.valueAt.y)} ${fieldRot})`);
209
273
  L.push(`\t\t\t(effects (font (size 1.27 1.27))${hideValue})`);
210
274
  L.push('\t\t)');
211
275
  L.push(`\t\t(property "Footprint" ${q(s.footprint)} (at ${knum(s.at.x)} ${knum(s.at.y)} 0)`);
@@ -183,12 +183,29 @@ function transformBounds(at: { x: number; y: number; rot: number }, mirror: 'x'
183
183
  }
184
184
 
185
185
  /** Conservative text box, centered on the anchor; vertical when rotated 90/270. */
186
- function textBounds(t: { text: string; x: number; y: number; rot: number; height: number }): Bounds {
186
+ function textBounds(t: {
187
+ text: string;
188
+ x: number;
189
+ y: number;
190
+ rot: number;
191
+ height: number;
192
+ justifyH?: 'left' | 'right' | null;
193
+ justifyV?: 'top' | 'bottom' | null;
194
+ }): Bounds {
187
195
  const w = Math.max(1, t.text.length) * TEXT_ADVANCE * t.height;
188
196
  const h = t.height;
189
197
  const vertical = Math.abs(t.rot % 180) === 90;
190
198
  const [bw, bh] = vertical ? [h, w] : [w, h];
191
- return { minX: t.x - bw / 2, minY: t.y - bh / 2, maxX: t.x + bw / 2, maxY: t.y + bh / 2 };
199
+ // A free text is centred on its anchor unless its effects justify it: the
200
+ // engine's group captions are `left top`, so their box runs right and down
201
+ // from the anchor. Measuring those as centred put half the caption's width
202
+ // left of the group box and flagged a caption 12 mm inside the frame as
203
+ // out of it (esp32-amp, A1 at full width).
204
+ const jh = vertical ? null : (t.justifyH ?? null);
205
+ const jv = vertical ? null : (t.justifyV ?? null);
206
+ const minX = jh === 'left' ? t.x : jh === 'right' ? t.x - bw : t.x - bw / 2;
207
+ const minY = jv === 'top' ? t.y : jv === 'bottom' ? t.y - bh : t.y - bh / 2;
208
+ return { minX, minY, maxX: minX + bw, maxY: minY + bh };
192
209
  }
193
210
 
194
211
  /**
@@ -210,19 +227,44 @@ function textBounds(t: { text: string; x: number; y: number; rot: number; height
210
227
  * of them on the wrong side of the anchor either invents a collision with the
211
228
  * symbol the label serves or hides a real one.
212
229
  */
213
- function labelBounds(l: { name: string; x: number; y: number; rot: number; height: number; justify?: 'left' | 'right' | null }): Bounds {
230
+ function labelBounds(l: { name: string; x: number; y: number; rot: number; height: number; justify?: 'left' | 'right' | null; kind?: string }): Bounds {
214
231
  const w = Math.max(1, l.name.length) * TEXT_ADVANCE * l.height;
215
232
  const h = l.height;
216
233
  const rot = ((l.rot % 360) + 360) % 360;
234
+ if (l.kind === 'global_label' || l.kind === 'hierarchical_label') {
235
+ // A flag is drawn two heights tall, CENTRED on the anchor line, with its
236
+ // outline (a margin each side of the text plus the pointed tip) running
237
+ // away from the anchor in the angle's direction; the stored justification
238
+ // only restates that direction. Measured from eeschema's own outline.
239
+ const fw = w + 2 * h;
240
+ if (rot === 90) return { minX: l.x - h, minY: l.y - fw, maxX: l.x + h, maxY: l.y };
241
+ if (rot === 270) return { minX: l.x - h, minY: l.y, maxX: l.x + h, maxY: l.y + fw };
242
+ if (rot === 180) return { minX: l.x - fw, minY: l.y - h, maxX: l.x, maxY: l.y + h };
243
+ return { minX: l.x, minY: l.y - h, maxX: l.x + fw, maxY: l.y + h };
244
+ }
217
245
  const justified = l.justify ?? (rot === 180 || rot === 270 ? 'right' : 'left');
218
246
  if (rot === 90 || rot === 270) {
219
247
  return justified === 'left'
220
248
  ? { minX: l.x - h / 2, minY: l.y - w, maxX: l.x + h / 2, maxY: l.y }
221
249
  : { minX: l.x - h / 2, minY: l.y, maxX: l.x + h / 2, maxY: l.y + w };
222
250
  }
251
+ // Vertically the text STANDS on its anchor: eeschema draws a horizontal
252
+ // label bottom-justified, so the box runs one height above the anchor line
253
+ // and nothing below it. Measuring it centred put half the box below the
254
+ // wire the label sits on and called every label on a horizontal wire "text
255
+ // on a wire" — the ordinary way a person names a wire.
223
256
  return justified === 'right'
224
- ? { minX: l.x - w, minY: l.y - h / 2, maxX: l.x, maxY: l.y + h / 2 }
225
- : { minX: l.x, minY: l.y - h / 2, maxX: l.x + w, maxY: l.y + h / 2 };
257
+ ? { minX: l.x - w, minY: l.y - h, maxX: l.x, maxY: l.y }
258
+ : { minX: l.x, minY: l.y - h, maxX: l.x + w, maxY: l.y };
259
+ }
260
+
261
+ /** Does the wire lie behind a flag's tip, along the direction the flag's text does NOT extend? */
262
+ function onPoleSide(w: WireSeg, l: { x: number; y: number; rot: number }): boolean {
263
+ const r = ((l.rot % 360) + 360) % 360;
264
+ if (r === 0) return Math.max(w.x1, w.x2) <= l.x + TOL;
265
+ if (r === 180) return Math.min(w.x1, w.x2) >= l.x - TOL;
266
+ if (r === 90) return Math.min(w.y1, w.y2) >= l.y - TOL;
267
+ return Math.max(w.y1, w.y2) <= l.y + TOL;
226
268
  }
227
269
 
228
270
  function segIntersectsBounds(s: WireSeg, b: Bounds): boolean {
@@ -397,7 +439,11 @@ function checkSheet(
397
439
  const labelBoxes = sheet.labels.map((l) => ({
398
440
  l,
399
441
  box: labelBounds(l),
400
- attached: sheet.wires.filter((w) => pointOnSeg(l.x, l.y, w)),
442
+ // a plain label stands above its wire, so every wire through the anchor
443
+ // is its attachment; a flag is drawn centred on the anchor line, so a
444
+ // wire continuing under the text runs through the flag — only the wire
445
+ // behind the tip (the pole side) attaches it
446
+ attached: sheet.wires.filter((w) => pointOnSeg(l.x, l.y, w) && (l.kind === 'label' || onPoleSide(w, l))),
401
447
  }));
402
448
 
403
449
  // --- groups (design C1): sheet rectangles with a caption in the top band ---
@@ -529,6 +575,9 @@ function checkSheet(
529
575
  // --- label-orientation: rotated where a horizontal draw would collide with nothing ---
530
576
  for (const lb of labelBoxes) {
531
577
  if (Math.abs(lb.l.rot % 180) !== 90) continue;
578
+ // a flag continuing a vertical wire is oriented by that wire, not by
579
+ // taste: turning it horizontal would hang it sideways off the wire end
580
+ if (lb.l.kind !== 'label' && lb.attached.some((w) => Math.abs(w.x1 - w.x2) < TOL)) continue;
532
581
  const horizontal = labelBounds({ ...lb.l, rot: 0 });
533
582
  const collides =
534
583
  realSyms.some((s) => s.body && boundsOverlap(horizontal, s.body)) ||
@@ -1,4 +1,4 @@
1
- import { readSheetGeometry, type SheetGeometry, type Bounds } from './sexp.js';
1
+ import { readSheetGeometry, pinsOfUnit, pinAbsolute, type SheetGeometry, type Bounds } from './sexp.js';
2
2
  import { checkLegibility, type LegibilityReport } from './legibility.js';
3
3
  import type { LegibilityUserConfig } from '../config.js';
4
4
 
@@ -25,12 +25,23 @@ export interface ScoreMetric {
25
25
  export interface ScoreReport {
26
26
  composite: number;
27
27
  metrics: ScoreMetric[];
28
+ /**
29
+ * Wiring style, scored without the drafting standard's conventions. The
30
+ * composite above needs group boxes, captions and a title block, so a
31
+ * hand-drawn sheet that lacks them is capped at 40 whatever its craft.
32
+ * This block measures only how the parts connect — the thing a reader of
33
+ * any schematic judges first — and is never capped, so an engine draft
34
+ * and the human drawing of the same circuit compare on one scale.
35
+ */
36
+ style: { composite: number; metrics: ScoreMetric[] };
28
37
  /** Applied error-finding cap, or null. */
29
38
  cap: { appliedAt: number; reason: string } | null;
30
39
  legibility: { error: number; advisory: number };
31
40
  }
32
41
 
33
42
  const DEFAULT_WEIGHTS: Record<string, number> = {
43
+ 'pin-attachment': 10,
44
+ 'island-parts': 5,
34
45
  'wire-crossings': 10,
35
46
  'wire-bends': 5,
36
47
  'wire-length': 5,
@@ -46,6 +57,23 @@ const DEFAULT_WEIGHTS: Record<string, number> = {
46
57
  'pair-symmetry': 5,
47
58
  };
48
59
 
60
+ /**
61
+ * Weights of the convention-free style composite. Calibrated on the KiCad
62
+ * demo corpus (fifteen hand-drawn projects, 17 to 1138 parts): a person wires
63
+ * 90 to 100 % of two-pin parts straight to another part, leaves 0 to 2 % as
64
+ * label-only islands, draws 0.3 to 1.2 power symbols per part and, outside
65
+ * bus-heavy boards, under one label per part. Engine drafts before pin
66
+ * attachment measured 14 to 53 % attached and 9 to 13 % islands.
67
+ */
68
+ const STYLE_WEIGHTS: Record<string, number> = {
69
+ 'pin-attachment': 30,
70
+ 'island-parts': 20,
71
+ 'power-symbol-economy': 15,
72
+ 'labels-per-part': 15,
73
+ 'crossings-per-wire': 10,
74
+ 'straight-wire-ratio': 10,
75
+ };
76
+
49
77
  /** Composite is capped this far below the Tier A floor when errors exist. */
50
78
  const DEFAULT_FLOOR = 85;
51
79
  const ERROR_CAP = 40;
@@ -81,6 +109,124 @@ function properCrossing(a: Seg, b: Seg): boolean {
81
109
  return vx > hx1 + 0.01 && vx < hx2 - 0.01 && hy > vy1 + 0.01 && hy < vy2 - 0.01;
82
110
  }
83
111
 
112
+ /** Library prefixes whose multi-pin symbols are not ICs for the style metrics. */
113
+ const NOT_IC = /^(Connector|Switch|TestPoint|Mechanical|Jumper|Graphic|Device:Crystal|Device:Q_|Device:D_|Device:LED)/;
114
+
115
+ export interface WiringStyle {
116
+ parts: number;
117
+ ics: number;
118
+ twoPin: number;
119
+ powerSymbols: number;
120
+ labels: number;
121
+ /** two-pin parts with at least one pin wired straight to another part */
122
+ twoPinAttached: number;
123
+ /** two-pin parts whose every pin ends in a label or nothing */
124
+ twoPinIslands: number;
125
+ icPins: number;
126
+ icPinsWired: number;
127
+ icPinsLabelled: number;
128
+ }
129
+
130
+ /**
131
+ * How the parts of a sheet set connect, by following wires from each placed
132
+ * pin: to another part's pin, to a power symbol, to a net label, or nowhere.
133
+ * Wire joins are endpoint-to-endpoint and endpoint-onto-segment (a T), the
134
+ * same two ways KiCad itself joins them.
135
+ */
136
+ export function measureWiringStyle(sheets: SheetGeometry[]): WiringStyle {
137
+ const W: WiringStyle = { parts: 0, ics: 0, twoPin: 0, powerSymbols: 0, labels: 0, twoPinAttached: 0, twoPinIslands: 0, icPins: 0, icPinsWired: 0, icPinsLabelled: 0 };
138
+ const key = (x: number, y: number): string => `${Math.round(x * 100)},${Math.round(y * 100)}`;
139
+ for (const sh of sheets) {
140
+ const parent = new Map<string, string>();
141
+ const find = (k: string): string => {
142
+ let r = k;
143
+ while (parent.get(r) !== r) r = parent.get(r)!;
144
+ while (parent.get(k) !== r) {
145
+ const n = parent.get(k)!;
146
+ parent.set(k, r);
147
+ k = n;
148
+ }
149
+ return r;
150
+ };
151
+ const add = (k: string): void => {
152
+ if (!parent.has(k)) parent.set(k, k);
153
+ };
154
+ const union = (a: string, b: string): void => {
155
+ add(a);
156
+ add(b);
157
+ const ra = find(a);
158
+ const rb = find(b);
159
+ if (ra !== rb) parent.set(ra, rb);
160
+ };
161
+ for (const w of sh.wires) union(key(w.x1, w.y1), key(w.x2, w.y2));
162
+ const placed = sh.symbols.map((s) => ({
163
+ s,
164
+ pins: pinsOfUnit(sh.libPins.get(s.libId) ?? [], s.unit).map((p) => pinAbsolute(s.at, s.mirror, p)),
165
+ }));
166
+ for (const { pins } of placed) for (const p of pins) add(key(p.x, p.y));
167
+ for (const l of sh.labels) add(key(l.x, l.y));
168
+ // a point on a segment's interior joins it (T junction); index segments by
169
+ // their fixed coordinate so this stays linear in practice
170
+ const horiz = new Map<number, Seg[]>();
171
+ const vert = new Map<number, Seg[]>();
172
+ for (const w of sh.wires) {
173
+ if (Math.abs(w.y1 - w.y2) < 0.01) horiz.set(Math.round(w.y1 * 100), [...(horiz.get(Math.round(w.y1 * 100)) ?? []), w]);
174
+ else if (Math.abs(w.x1 - w.x2) < 0.01) vert.set(Math.round(w.x1 * 100), [...(vert.get(Math.round(w.x1 * 100)) ?? []), w]);
175
+ }
176
+ for (const k of [...parent.keys()]) {
177
+ const [xs, ys] = k.split(',');
178
+ const x = Number(xs) / 100;
179
+ const y = Number(ys) / 100;
180
+ for (const w of horiz.get(Number(ys)) ?? []) {
181
+ if (x > Math.min(w.x1, w.x2) + 0.01 && x < Math.max(w.x1, w.x2) - 0.01) union(k, key(w.x1, w.y1));
182
+ }
183
+ for (const w of vert.get(Number(xs)) ?? []) {
184
+ if (y > Math.min(w.y1, w.y2) + 0.01 && y < Math.max(w.y1, w.y2) - 0.01) union(k, key(w.x1, w.y1));
185
+ }
186
+ }
187
+ const compOf = (x: number, y: number): string => find(key(x, y));
188
+ const labelComps = new Set(sh.labels.map((l) => compOf(l.x, l.y)));
189
+ const pinComps = new Map<string, { ref: string; power: boolean }[]>();
190
+ for (const { s, pins } of placed) {
191
+ for (const p of pins) {
192
+ const c = compOf(p.x, p.y);
193
+ pinComps.set(c, [...(pinComps.get(c) ?? []), { ref: s.ref, power: s.isPower }]);
194
+ }
195
+ }
196
+ W.labels += sh.labels.length;
197
+ for (const { s, pins } of placed) {
198
+ if (s.isPower) {
199
+ W.powerSymbols++;
200
+ continue;
201
+ }
202
+ if (!pins.length) continue;
203
+ W.parts++;
204
+ const kinds = pins.map((p) => {
205
+ const c = compOf(p.x, p.y);
206
+ const others = (pinComps.get(c) ?? []).filter((o) => o.ref !== s.ref);
207
+ if (others.some((o) => !o.power)) return 'part';
208
+ if (others.some((o) => o.power)) return 'power';
209
+ if (labelComps.has(c)) return 'label';
210
+ return 'open';
211
+ });
212
+ if (pins.length === 2) {
213
+ W.twoPin++;
214
+ if (kinds.includes('part')) W.twoPinAttached++;
215
+ else if (kinds.every((k) => k === 'label' || k === 'open')) W.twoPinIslands++;
216
+ }
217
+ if (pins.length >= 3 && !NOT_IC.test(s.libId)) {
218
+ W.ics++;
219
+ for (const k of kinds) {
220
+ W.icPins++;
221
+ if (k === 'part') W.icPinsWired++;
222
+ else if (k === 'label') W.icPinsLabelled++;
223
+ }
224
+ }
225
+ }
226
+ }
227
+ return W;
228
+ }
229
+
84
230
  export interface ScoreOptions {
85
231
  docsDir?: string | null;
86
232
  config?: LegibilityUserConfig;
@@ -259,6 +405,13 @@ export function scoreFromGeometry(
259
405
  }
260
406
  const pairSymmetry = pairs ? symmetric / pairs : 1;
261
407
 
408
+ // wiring style: how the parts connect (see STYLE_WEIGHTS for the calibration)
409
+ const ws = measureWiringStyle(sheets);
410
+ const attachment = ws.twoPin ? ws.twoPinAttached / ws.twoPin : 1;
411
+ const islands = ws.twoPin ? ws.twoPinIslands / ws.twoPin : 0;
412
+ const powerPerPart = ws.parts ? ws.powerSymbols / ws.parts : 0;
413
+ const labelsPerPart = ws.parts ? ws.labels / ws.parts : 0;
414
+
262
415
  const horizLabels = labels.length
263
416
  ? labels.filter((l) => Math.abs(l.rot % 180) !== 90).length / labels.length
264
417
  : 1;
@@ -267,7 +420,15 @@ export function scoreFromGeometry(
267
420
 
268
421
  // ---- normalize to [0,1] goodness and compose ----
269
422
  const raw: Record<string, { raw: number; score: number }> = {
423
+ 'pin-attachment': { raw: attachment, score: Math.min(1, attachment / 0.9) },
424
+ 'island-parts': { raw: islands, score: Math.max(0, 1 - islands / 0.2) },
425
+ 'power-symbol-economy': { raw: powerPerPart, score: powerPerPart <= 0.8 ? 1 : 0.8 / powerPerPart },
426
+ 'labels-per-part': { raw: labelsPerPart, score: labelsPerPart <= 1 ? 1 : 1 / labelsPerPart },
270
427
  'wire-crossings': { raw: crossings, score: 1 / (1 + crossings) },
428
+ // the absolute count above floors at zero on any large board; per wire it
429
+ // stays comparable across sizes (the 1138-part demo crosses 0.77 per wire,
430
+ // the 89-part one 0.03)
431
+ 'crossings-per-wire': { raw: segs.length ? crossings / segs.length : 0, score: 1 / (1 + 10 * (segs.length ? crossings / segs.length : 0)) },
271
432
  'wire-bends': { raw: bends, score: 1 / (1 + bends / 8) },
272
433
  'wire-length': { raw: totalLen, score: 1 / (1 + totalLen / (4 * diag)) },
273
434
  'label-to-wire-ratio': { raw: labelRatio, score: labelRatio <= 1.5 ? 1 : 1.5 / labelRatio },
@@ -281,15 +442,26 @@ export function scoreFromGeometry(
281
442
  'whitespace-balance': { raw: centroidOff, score: Math.max(0, 1 - centroidOff) },
282
443
  'pair-symmetry': { raw: pairSymmetry, score: pairSymmetry },
283
444
  };
284
- const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0) || 1;
285
- const metrics: ScoreMetric[] = Object.entries(raw).map(([name, m]) => ({
445
+ const totalWeight = Object.entries(weights).reduce((a, [k, v]) => a + (k in raw ? v : 0), 0) || 1;
446
+ const metrics: ScoreMetric[] = Object.entries(raw)
447
+ .filter(([name]) => name in weights)
448
+ .map(([name, m]) => ({
449
+ name,
450
+ raw: m.raw,
451
+ score: m.score,
452
+ weight: weights[name]!,
453
+ contribution: (100 * weights[name]! * m.score) / totalWeight,
454
+ }));
455
+ let composite = metrics.reduce((a, m) => a + m.contribution, 0);
456
+ const styleTotal = Object.values(STYLE_WEIGHTS).reduce((a, b) => a + b, 0);
457
+ const styleMetrics: ScoreMetric[] = Object.entries(STYLE_WEIGHTS).map(([name, weight]) => ({
286
458
  name,
287
- raw: m.raw,
288
- score: m.score,
289
- weight: weights[name]!,
290
- contribution: (100 * weights[name]! * m.score) / totalWeight,
459
+ raw: raw[name]!.raw,
460
+ score: raw[name]!.score,
461
+ weight,
462
+ contribution: (100 * weight * raw[name]!.score) / styleTotal,
291
463
  }));
292
- let composite = metrics.reduce((a, m) => a + m.contribution, 0);
464
+ const styleComposite = Math.round(styleMetrics.reduce((a, m) => a + m.contribution, 0) * 100) / 100;
293
465
 
294
466
  let cap: ScoreReport['cap'] = null;
295
467
  if (legibility.counts.error > 0) {
@@ -306,6 +478,7 @@ export function scoreFromGeometry(
306
478
  return {
307
479
  composite: Math.round(composite * 100) / 100,
308
480
  metrics,
481
+ style: { composite: styleComposite, metrics: styleMetrics },
309
482
  cap,
310
483
  legibility: legibility.counts,
311
484
  };
@@ -319,5 +492,11 @@ export function formatScore(report: ScoreReport): string {
319
492
  ` ${m.name}: raw ${Math.round(m.raw * 10000) / 10000}, score ${Math.round(m.score * 1000) / 1000}, weight ${m.weight}, contributes ${Math.round(m.contribution * 100) / 100}`,
320
493
  );
321
494
  }
495
+ if (report.style) {
496
+ lines.push(`wiring style: ${report.style.composite}/100 (convention-free, never capped)`);
497
+ for (const m of report.style.metrics) {
498
+ lines.push(` ${m.name}: raw ${Math.round(m.raw * 10000) / 10000}, score ${Math.round(m.score * 1000) / 1000}, weight ${m.weight}`);
499
+ }
500
+ }
322
501
  return lines.join('\n');
323
502
  }
package/src/kicad/sexp.ts CHANGED
@@ -335,6 +335,11 @@ export interface TextItem {
335
335
  /** Font height in mm (KiCad `(size h w)`); 1.27 when unstated. */
336
336
  height: number;
337
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;
338
343
  }
339
344
 
340
345
  export interface LabelItem {
@@ -400,10 +405,16 @@ function effectsOf(node: SexpNode[]): { height: number; hidden: boolean } {
400
405
  const effects = child(node, 'effects');
401
406
  const font = effects ? child(effects, 'font') : undefined;
402
407
  const size = font ? child(font, 'size') : undefined;
403
- // hidden: legacy bare `hide` atom, or v9 `(hide yes)`
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)
404
412
  const hideNode = effects ? child(effects, 'hide') : undefined;
413
+ const hideProp = child(node, 'hide');
405
414
  const hidden =
406
- (effects?.some((c) => c === 'hide') ?? false) || (hideNode !== undefined && atomAt(hideNode, 1) !== 'no');
415
+ (effects?.some((c) => c === 'hide') ?? false) ||
416
+ (hideNode !== undefined && atomAt(hideNode, 1) !== 'no') ||
417
+ (hideProp !== undefined && atomAt(hideProp, 1) !== 'no');
407
418
  return { height: size ? num(size, 1, 1.27) : 1.27, hidden };
408
419
  }
409
420
 
@@ -483,7 +494,7 @@ function libBodyBounds(root: SexpNode[]): Map<string, Bounds | null> {
483
494
  return map;
484
495
  }
485
496
 
486
- function textItemsOf(sym: SexpNode[]): TextItem[] {
497
+ function textItemsOf(sym: SexpNode[], symRot = 0): TextItem[] {
487
498
  const out: TextItem[] = [];
488
499
  for (const p of children(sym, 'property')) {
489
500
  const key = atomAt(p, 1);
@@ -494,9 +505,18 @@ function textItemsOf(sym: SexpNode[]): TextItem[] {
494
505
  text: atomAt(p, 2) ?? '',
495
506
  x: num(at, 1),
496
507
  y: num(at, 2),
497
- rot: num(at, 3),
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,
498
513
  height: fx.height,
499
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,
500
520
  });
501
521
  }
502
522
  return out;
@@ -544,7 +564,18 @@ export async function readSheetGeometry(rootSch: string): Promise<SheetGeometry[
544
564
  const texts: TextItem[] = children(sheet.root, 'text').map((t) => {
545
565
  const at = child(t, 'at');
546
566
  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 };
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
+ };
548
579
  });
549
580
  const rectangles: RectItem[] = children(sheet.root, 'rectangle').map((r) => {
550
581
  const s = child(r, 'start');
@@ -576,7 +607,7 @@ export async function readSheetGeometry(rootSch: string): Promise<SheetGeometry[
576
607
  mirror,
577
608
  isPower: isPowerSymbol(sym.libId, powerSyms),
578
609
  unit: sym.unit,
579
- props: textItemsOf(node),
610
+ props: textItemsOf(node, sym.at.rot),
580
611
  })),
581
612
  wires,
582
613
  labels,