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.
- package/NOTICE +1 -1
- package/README.md +47 -8
- package/dist/agent/context.js +2 -0
- package/dist/agent/context.js.map +1 -0
- package/dist/agent/dock-renderer.js +2 -2
- package/dist/agent/dock-renderer.js.map +1 -1
- package/dist/agent/envelope.js +105 -0
- package/dist/agent/envelope.js.map +1 -0
- package/dist/agent/filetools.js +24 -1
- package/dist/agent/filetools.js.map +1 -1
- package/dist/agent/ledger.js +24 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +63 -72
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +4 -3
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/claude-code.js +17 -1
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/codex.js +84 -39
- package/dist/agent/providers/codex.js.map +1 -1
- package/dist/agent/providers/tool-protocol.js +21 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -1
- package/dist/agent/recovery.js +186 -15
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/registry.js +49 -0
- package/dist/agent/registry.js.map +1 -0
- package/dist/agent/render.js +2 -2
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/theme.js +10 -5
- package/dist/agent/theme.js.map +1 -1
- package/dist/agent/tools.js +100 -586
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +2 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/capabilities/define.js +35 -0
- package/dist/capabilities/define.js.map +1 -0
- package/dist/capabilities/handlers.js +744 -0
- package/dist/capabilities/handlers.js.map +1 -0
- package/dist/capabilities/helpers.js +39 -0
- package/dist/capabilities/helpers.js.map +1 -0
- package/dist/capabilities/index.js +50 -0
- package/dist/capabilities/index.js.map +1 -0
- package/dist/capabilities/skills/generate-report.js +23 -0
- package/dist/capabilities/skills/generate-report.js.map +1 -0
- package/dist/cli.js +158 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +33 -1
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +181 -26
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +83 -6
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/skill.js +109 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/sync.js +3 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/config.js +19 -6
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +24 -3
- package/dist/kicad/bootstrap.js.map +1 -1
- package/dist/kicad/cli.js +106 -18
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/dossier.js +207 -0
- package/dist/kicad/dossier.js.map +1 -0
- package/dist/kicad/draft/draft.js +135 -0
- package/dist/kicad/draft/draft.js.map +1 -0
- package/dist/kicad/draft/engine.js +5310 -0
- package/dist/kicad/draft/engine.js.map +1 -0
- package/dist/kicad/draft/ir.js +368 -0
- package/dist/kicad/draft/ir.js.map +1 -0
- package/dist/kicad/draft/symsource.js +504 -0
- package/dist/kicad/draft/symsource.js.map +1 -0
- package/dist/kicad/emit.js +220 -0
- package/dist/kicad/emit.js.map +1 -0
- package/dist/kicad/fab.js +13 -0
- package/dist/kicad/fab.js.map +1 -1
- package/dist/kicad/legibility.js +608 -0
- package/dist/kicad/legibility.js.map +1 -0
- package/dist/kicad/score.js +431 -0
- package/dist/kicad/score.js.map +1 -0
- package/dist/kicad/sexp.js +265 -6
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/kicad/symlib.js +346 -16
- package/dist/kicad/symlib.js.map +1 -1
- package/dist/mcp/server.js +485 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/memory/bom-table.js +75 -39
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/scaffold.js +14 -1
- package/dist/memory/scaffold.js.map +1 -1
- package/dist/util/redact.js +6 -0
- package/dist/util/redact.js.map +1 -1
- package/package.json +13 -8
- package/src/agent/context.ts +35 -0
- package/src/agent/dock-renderer.ts +3 -2
- package/src/agent/envelope.ts +124 -0
- package/src/agent/filetools.ts +26 -1
- package/src/agent/ledger.ts +24 -0
- package/src/agent/loop.ts +73 -78
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/claude-code.ts +22 -1
- package/src/agent/providers/codex.ts +91 -42
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +183 -13
- package/src/agent/registry.ts +58 -0
- package/src/agent/render.ts +4 -3
- package/src/agent/theme.ts +15 -5
- package/src/agent/tools.ts +124 -628
- package/src/agent/transcript.ts +6 -0
- package/src/agent/types.ts +10 -5
- package/src/capabilities/define.ts +88 -0
- package/src/capabilities/handlers.ts +769 -0
- package/src/capabilities/helpers.ts +37 -0
- package/src/capabilities/index.ts +53 -0
- package/src/capabilities/skills/generate-report.ts +25 -0
- package/src/cli.ts +154 -0
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +183 -21
- package/src/commands/doctor.ts +85 -6
- package/src/commands/skill.ts +127 -0
- package/src/commands/sync.ts +5 -3
- package/src/config.ts +56 -8
- package/src/kicad/bootstrap.ts +24 -3
- package/src/kicad/cli.ts +129 -18
- package/src/kicad/dossier.ts +217 -0
- package/src/kicad/draft/draft.ts +173 -0
- package/src/kicad/draft/engine.ts +5274 -0
- package/src/kicad/draft/ir.ts +416 -0
- package/src/kicad/draft/symsource.ts +549 -0
- package/src/kicad/emit.ts +300 -0
- package/src/kicad/fab.ts +15 -0
- package/src/kicad/legibility.ts +695 -0
- package/src/kicad/score.ts +502 -0
- package/src/kicad/sexp.ts +346 -6
- package/src/kicad/symlib.ts +364 -18
- package/src/mcp/server.ts +560 -0
- package/src/memory/bom-table.ts +85 -38
- package/src/memory/scaffold.ts +14 -1
- package/src/util/redact.ts +6 -0
- package/dist/memory/synap.js +0 -152
- package/dist/memory/synap.js.map +0 -1
- package/src/memory/synap.ts +0 -217
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { readSheetGeometry, pinsOfUnit, pinAbsolute, type SheetGeometry, type Bounds } from './sexp.js';
|
|
2
|
+
import { checkLegibility, type LegibilityReport } from './legibility.js';
|
|
3
|
+
import type { LegibilityUserConfig } from '../config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic quantitative scorer (design D7/D11). The checker decides
|
|
7
|
+
* pass/fail on unambiguous defects; this measures the judgment calls — wiring
|
|
8
|
+
* economy, alignment, symmetry, balance — as a weighted 0-100 composite whose
|
|
9
|
+
* per-metric breakdown is always reported. Any error-severity legibility
|
|
10
|
+
* finding caps the composite below the known-good floor, so a high score can
|
|
11
|
+
* never coexist with a gating defect. No LLM, no network, no subprocess.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ScoreMetric {
|
|
15
|
+
name: string;
|
|
16
|
+
/** Raw measured value, full precision before rounding. */
|
|
17
|
+
raw: number;
|
|
18
|
+
/** Normalized goodness in [0, 1]. */
|
|
19
|
+
score: number;
|
|
20
|
+
weight: number;
|
|
21
|
+
/** Weighted contribution to the composite (points). */
|
|
22
|
+
contribution: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ScoreReport {
|
|
26
|
+
composite: number;
|
|
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[] };
|
|
37
|
+
/** Applied error-finding cap, or null. */
|
|
38
|
+
cap: { appliedAt: number; reason: string } | null;
|
|
39
|
+
legibility: { error: number; advisory: number };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_WEIGHTS: Record<string, number> = {
|
|
43
|
+
'pin-attachment': 10,
|
|
44
|
+
'island-parts': 5,
|
|
45
|
+
'wire-crossings': 10,
|
|
46
|
+
'wire-bends': 5,
|
|
47
|
+
'wire-length': 5,
|
|
48
|
+
'label-to-wire-ratio': 5,
|
|
49
|
+
'group-cohesion': 10,
|
|
50
|
+
'flow-direction': 10,
|
|
51
|
+
utilization: 10,
|
|
52
|
+
'axis-alignment': 10,
|
|
53
|
+
'spacing-uniformity': 10,
|
|
54
|
+
'straight-wire-ratio': 5,
|
|
55
|
+
'label-alignment': 5,
|
|
56
|
+
'whitespace-balance': 10,
|
|
57
|
+
'pair-symmetry': 5,
|
|
58
|
+
};
|
|
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
|
+
|
|
77
|
+
/** Composite is capped this far below the Tier A floor when errors exist. */
|
|
78
|
+
const DEFAULT_FLOOR = 85;
|
|
79
|
+
const ERROR_CAP = 40;
|
|
80
|
+
/** Top-edge spread (mm) within which two group boxes count as the same row. */
|
|
81
|
+
const ROW_BAND = 12;
|
|
82
|
+
|
|
83
|
+
const PAPER: Record<string, { w: number; h: number }> = {
|
|
84
|
+
A5: { w: 210, h: 148 },
|
|
85
|
+
A4: { w: 297, h: 210 },
|
|
86
|
+
A3: { w: 420, h: 297 },
|
|
87
|
+
A2: { w: 594, h: 420 },
|
|
88
|
+
A1: { w: 841, h: 594 },
|
|
89
|
+
A0: { w: 1189, h: 841 },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
interface Seg {
|
|
93
|
+
x1: number;
|
|
94
|
+
y1: number;
|
|
95
|
+
x2: number;
|
|
96
|
+
y2: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const segLen = (s: Seg): number => Math.hypot(s.x2 - s.x1, s.y2 - s.y1);
|
|
100
|
+
const isVert = (s: Seg): boolean => s.x1 === s.x2;
|
|
101
|
+
|
|
102
|
+
function properCrossing(a: Seg, b: Seg): boolean {
|
|
103
|
+
// orthogonal proper crossing (interiors intersect); shared endpoints are joins
|
|
104
|
+
const [v, h] = isVert(a) && !isVert(b) ? [a, b] : !isVert(a) && isVert(b) ? [b, a] : [null, null];
|
|
105
|
+
if (!v || !h) return false;
|
|
106
|
+
const [hy, vx] = [h.y1, v.x1];
|
|
107
|
+
const [hx1, hx2] = [Math.min(h.x1, h.x2), Math.max(h.x1, h.x2)];
|
|
108
|
+
const [vy1, vy2] = [Math.min(v.y1, v.y2), Math.max(v.y1, v.y2)];
|
|
109
|
+
return vx > hx1 + 0.01 && vx < hx2 - 0.01 && hy > vy1 + 0.01 && hy < vy2 - 0.01;
|
|
110
|
+
}
|
|
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
|
+
|
|
230
|
+
export interface ScoreOptions {
|
|
231
|
+
docsDir?: string | null;
|
|
232
|
+
config?: LegibilityUserConfig;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function scoreSchematic(rootSch: string, opts: ScoreOptions = {}): Promise<ScoreReport> {
|
|
236
|
+
const sheets = await readSheetGeometry(rootSch);
|
|
237
|
+
const legibility = await checkLegibility(rootSch, {
|
|
238
|
+
docsDir: opts.docsDir ?? null,
|
|
239
|
+
...(opts.config ? { config: opts.config } : {}),
|
|
240
|
+
});
|
|
241
|
+
return scoreFromGeometry(sheets, legibility, opts.config);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function scoreFromGeometry(
|
|
245
|
+
sheets: SheetGeometry[],
|
|
246
|
+
legibility: LegibilityReport,
|
|
247
|
+
config?: LegibilityUserConfig,
|
|
248
|
+
): ScoreReport {
|
|
249
|
+
const weights = { ...DEFAULT_WEIGHTS };
|
|
250
|
+
for (const [k, v] of Object.entries(config?.score?.weights ?? {})) {
|
|
251
|
+
if (k in weights && typeof v === 'number' && Number.isFinite(v) && v >= 0) weights[k] = v;
|
|
252
|
+
}
|
|
253
|
+
// Sanitized like the weights above: a floor of 0 (or a non-number) would
|
|
254
|
+
// push the error cap `min(ERROR_CAP, floor - 1)` negative or NaN.
|
|
255
|
+
const rawFloor = config?.score?.floor;
|
|
256
|
+
const floor = typeof rawFloor === 'number' && Number.isFinite(rawFloor) && rawFloor >= 1 ? rawFloor : DEFAULT_FLOOR;
|
|
257
|
+
|
|
258
|
+
// ---- collect geometry across sheets ----
|
|
259
|
+
const segs: Seg[] = sheets.flatMap((s) => s.wires);
|
|
260
|
+
const labels = sheets.flatMap((s) => s.labels);
|
|
261
|
+
const realSyms = sheets.flatMap((s) =>
|
|
262
|
+
s.symbols.filter((y) => !y.isPower).map((y) => ({ ...y, sheet: s.sheetName })),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// wire crossings
|
|
266
|
+
let crossings = 0;
|
|
267
|
+
for (let i = 0; i < segs.length; i++) {
|
|
268
|
+
for (let j = i + 1; j < segs.length; j++) if (properCrossing(segs[i]!, segs[j]!)) crossings++;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// bends: points where exactly two segments of different orientation meet
|
|
272
|
+
const ends = new Map<string, Seg[]>();
|
|
273
|
+
for (const s of segs) {
|
|
274
|
+
for (const [x, y] of [[s.x1, s.y1], [s.x2, s.y2]] as const) {
|
|
275
|
+
const k = `${x},${y}`;
|
|
276
|
+
ends.set(k, [...(ends.get(k) ?? []), s]);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
let bends = 0;
|
|
280
|
+
for (const list of ends.values()) {
|
|
281
|
+
if (list.length === 2 && isVert(list[0]!) !== isVert(list[1]!)) bends++;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const totalLen = segs.reduce((s, w) => s + segLen(w), 0);
|
|
285
|
+
|
|
286
|
+
// group cohesion: same-name label pairs, mean anchor distance vs sheet diagonal
|
|
287
|
+
const byName = new Map<string, { x: number; y: number }[]>();
|
|
288
|
+
for (const l of labels) byName.set(l.name, [...(byName.get(l.name) ?? []), { x: l.x, y: l.y }]);
|
|
289
|
+
const paper = PAPER[sheets[0]?.paper.name ?? 'A4'] ?? PAPER.A4!;
|
|
290
|
+
const diag = Math.hypot(paper.w, paper.h);
|
|
291
|
+
const pairDists: number[] = [];
|
|
292
|
+
for (const pts of byName.values()) {
|
|
293
|
+
for (let i = 0; i < pts.length; i++) {
|
|
294
|
+
for (let j = i + 1; j < pts.length; j++) pairDists.push(Math.hypot(pts[i]!.x - pts[j]!.x, pts[i]!.y - pts[j]!.y));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const meanPair = pairDists.length ? pairDists.reduce((a, b) => a + b, 0) / pairDists.length : 0;
|
|
298
|
+
|
|
299
|
+
// flow-direction: cross-group label nets should connect adjacent groups in
|
|
300
|
+
// reading order; a hop across intervening groups reads as against-the-flow
|
|
301
|
+
// wiring
|
|
302
|
+
const rects = sheets.flatMap((s) => s.rectangles.map((r) => ({
|
|
303
|
+
minX: Math.min(r.x1, r.x2),
|
|
304
|
+
maxX: Math.max(r.x1, r.x2),
|
|
305
|
+
minY: Math.min(r.y1, r.y2),
|
|
306
|
+
maxY: Math.max(r.y1, r.y2),
|
|
307
|
+
})));
|
|
308
|
+
// Reading order is rows first, then left-to-right within a row. Ordering by
|
|
309
|
+
// minX alone was correct only while every group sat in one band; on a sheet
|
|
310
|
+
// whose groups wrap onto several rows it interleaves row 2 back into row 1
|
|
311
|
+
// and reports flow violations for a drawing that reads perfectly.
|
|
312
|
+
const byRow = [...rects].sort((a, b) => a.minY - b.minY || a.minX - b.minX);
|
|
313
|
+
const rows: (typeof rects)[] = [];
|
|
314
|
+
for (const r of byRow) {
|
|
315
|
+
const row = rows[rows.length - 1];
|
|
316
|
+
// Rows are separated by a group box height; members of one row share a top
|
|
317
|
+
// edge to within a symbol cell, so a generous band still cannot merge two.
|
|
318
|
+
if (row && r.minY - row[0]!.minY <= ROW_BAND) row.push(r);
|
|
319
|
+
else rows.push([r]);
|
|
320
|
+
}
|
|
321
|
+
const ordered = rows.flatMap((row) => [...row].sort((a, b) => a.minX - b.minX));
|
|
322
|
+
const groupIdx = (x: number, y: number): number =>
|
|
323
|
+
ordered.findIndex((r) => x > r.minX && x < r.maxX && y > r.minY && y < r.maxY);
|
|
324
|
+
/** Which wrapped row a point falls in; -1 when it is in no group box. */
|
|
325
|
+
const rowIdx = (x: number, y: number): number =>
|
|
326
|
+
rows.findIndex((row) => row.some((r) => x > r.minX && x < r.maxX && y > r.minY && y < r.maxY));
|
|
327
|
+
let crossNets = 0;
|
|
328
|
+
let flowViolations = 0;
|
|
329
|
+
for (const pts of byName.values()) {
|
|
330
|
+
const gs = [...new Set(pts.map((p) => groupIdx(p.x, p.y)).filter((g) => g >= 0))];
|
|
331
|
+
if (gs.length > 1) {
|
|
332
|
+
crossNets++;
|
|
333
|
+
if (Math.max(...gs) - Math.min(...gs) > 1) flowViolations++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// utilization + whitespace balance over the first sheet's usable frame
|
|
338
|
+
const items: Bounds[] = [
|
|
339
|
+
...realSyms.map((s) => ({ minX: s.at.x - 2, minY: s.at.y - 2, maxX: s.at.x + 2, maxY: s.at.y + 2 })),
|
|
340
|
+
...rects,
|
|
341
|
+
];
|
|
342
|
+
const content = items.length
|
|
343
|
+
? items.reduce((a, b) => ({
|
|
344
|
+
minX: Math.min(a.minX, b.minX),
|
|
345
|
+
minY: Math.min(a.minY, b.minY),
|
|
346
|
+
maxX: Math.max(a.maxX, b.maxX),
|
|
347
|
+
maxY: Math.max(a.maxY, b.maxY),
|
|
348
|
+
}))
|
|
349
|
+
: null;
|
|
350
|
+
const usable = { minX: 10, minY: 10, maxX: paper.w - 10, maxY: paper.h - 10 - 30 };
|
|
351
|
+
const usableArea = (usable.maxX - usable.minX) * (usable.maxY - usable.minY);
|
|
352
|
+
const utilization = content
|
|
353
|
+
? Math.min(1, ((content.maxX - content.minX) * (content.maxY - content.minY)) / usableArea)
|
|
354
|
+
: 0;
|
|
355
|
+
const centroidOff = content
|
|
356
|
+
? Math.hypot(
|
|
357
|
+
(content.minX + content.maxX) / 2 - (usable.minX + usable.maxX) / 2,
|
|
358
|
+
(content.minY + content.maxY) / 2 - (usable.minY + usable.maxY) / 2,
|
|
359
|
+
) / (diag / 2)
|
|
360
|
+
: 1;
|
|
361
|
+
|
|
362
|
+
// axis alignment: symbols sharing an origin axis with at least one other
|
|
363
|
+
const sharesAxis = (i: number): boolean =>
|
|
364
|
+
realSyms.some((o, j) => j !== i && (Math.abs(o.at.x - realSyms[i]!.at.x) < 0.01 || Math.abs(o.at.y - realSyms[i]!.at.y) < 0.01));
|
|
365
|
+
const axisAligned = realSyms.length > 1 ? realSyms.filter((_, i) => sharesAxis(i)).length / realSyms.length : 1;
|
|
366
|
+
|
|
367
|
+
// spacing uniformity: variance of consecutive gaps within shared-x columns
|
|
368
|
+
const cols = new Map<string, number[]>();
|
|
369
|
+
for (const s of realSyms) {
|
|
370
|
+
// Keyed by row as well as x: a column is a vertical run the eye reads as
|
|
371
|
+
// one stack. Once groups wrap, two symbols in different rows can share an x
|
|
372
|
+
// while sitting a whole row apart, and treating that jump as a "gap" pushes
|
|
373
|
+
// the coefficient of variation — and the score — off a cliff.
|
|
374
|
+
const k = `${s.sheet}:${Math.round(s.at.x * 100)}:${rowIdx(s.at.x, s.at.y)}`;
|
|
375
|
+
cols.set(k, [...(cols.get(k) ?? []), s.at.y]);
|
|
376
|
+
}
|
|
377
|
+
const gapCVs: number[] = [];
|
|
378
|
+
for (const ys of cols.values()) {
|
|
379
|
+
if (ys.length < 3) continue;
|
|
380
|
+
ys.sort((a, b) => a - b);
|
|
381
|
+
const gaps = ys.slice(1).map((y, i) => y - ys[i]!);
|
|
382
|
+
const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
|
|
383
|
+
const sd = Math.sqrt(gaps.reduce((a, g) => a + (g - mean) ** 2, 0) / gaps.length);
|
|
384
|
+
gapCVs.push(mean > 0 ? sd / mean : 0);
|
|
385
|
+
}
|
|
386
|
+
const spacingUniformity = gapCVs.length ? Math.max(0, 1 - gapCVs.reduce((a, b) => a + b, 0) / gapCVs.length) : 1;
|
|
387
|
+
|
|
388
|
+
// pair symmetry: same-libId+value symbols in one group aligned on a shared axis
|
|
389
|
+
const pairKeys = new Map<string, { x: number; y: number }[]>();
|
|
390
|
+
for (const s of realSyms) {
|
|
391
|
+
const g = groupIdx(s.at.x, s.at.y);
|
|
392
|
+
const k = `${g}:${s.libId}:${s.value}`;
|
|
393
|
+
pairKeys.set(k, [...(pairKeys.get(k) ?? []), { x: s.at.x, y: s.at.y }]);
|
|
394
|
+
}
|
|
395
|
+
let pairs = 0;
|
|
396
|
+
let symmetric = 0;
|
|
397
|
+
for (const pts of pairKeys.values()) {
|
|
398
|
+
if (pts.length < 2) continue;
|
|
399
|
+
for (let i = 0; i < pts.length; i++) {
|
|
400
|
+
for (let j = i + 1; j < pts.length; j++) {
|
|
401
|
+
pairs++;
|
|
402
|
+
if (Math.abs(pts[i]!.x - pts[j]!.x) < 0.01 || Math.abs(pts[i]!.y - pts[j]!.y) < 0.01) symmetric++;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const pairSymmetry = pairs ? symmetric / pairs : 1;
|
|
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
|
+
|
|
415
|
+
const horizLabels = labels.length
|
|
416
|
+
? labels.filter((l) => Math.abs(l.rot % 180) !== 90).length / labels.length
|
|
417
|
+
: 1;
|
|
418
|
+
const straightRatio = segs.length ? Math.max(0, 1 - bends / segs.length) : 1;
|
|
419
|
+
const labelRatio = segs.length ? labels.length / segs.length : labels.length ? 1 : 0;
|
|
420
|
+
|
|
421
|
+
// ---- normalize to [0,1] goodness and compose ----
|
|
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 },
|
|
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)) },
|
|
432
|
+
'wire-bends': { raw: bends, score: 1 / (1 + bends / 8) },
|
|
433
|
+
'wire-length': { raw: totalLen, score: 1 / (1 + totalLen / (4 * diag)) },
|
|
434
|
+
'label-to-wire-ratio': { raw: labelRatio, score: labelRatio <= 1.5 ? 1 : 1.5 / labelRatio },
|
|
435
|
+
'group-cohesion': { raw: meanPair, score: pairDists.length ? Math.max(0, 1 - meanPair / diag) : 1 },
|
|
436
|
+
'flow-direction': { raw: flowViolations, score: crossNets ? 1 - flowViolations / crossNets : 1 },
|
|
437
|
+
utilization: { raw: utilization, score: Math.min(1, utilization / 0.5) },
|
|
438
|
+
'axis-alignment': { raw: axisAligned, score: axisAligned },
|
|
439
|
+
'spacing-uniformity': { raw: spacingUniformity, score: spacingUniformity },
|
|
440
|
+
'straight-wire-ratio': { raw: straightRatio, score: straightRatio },
|
|
441
|
+
'label-alignment': { raw: horizLabels, score: horizLabels },
|
|
442
|
+
'whitespace-balance': { raw: centroidOff, score: Math.max(0, 1 - centroidOff) },
|
|
443
|
+
'pair-symmetry': { raw: pairSymmetry, score: pairSymmetry },
|
|
444
|
+
};
|
|
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]) => ({
|
|
458
|
+
name,
|
|
459
|
+
raw: raw[name]!.raw,
|
|
460
|
+
score: raw[name]!.score,
|
|
461
|
+
weight,
|
|
462
|
+
contribution: (100 * weight * raw[name]!.score) / styleTotal,
|
|
463
|
+
}));
|
|
464
|
+
const styleComposite = Math.round(styleMetrics.reduce((a, m) => a + m.contribution, 0) * 100) / 100;
|
|
465
|
+
|
|
466
|
+
let cap: ScoreReport['cap'] = null;
|
|
467
|
+
if (legibility.counts.error > 0) {
|
|
468
|
+
const capValue = Math.min(ERROR_CAP, floor - 1);
|
|
469
|
+
if (composite > capValue) {
|
|
470
|
+
cap = {
|
|
471
|
+
appliedAt: capValue,
|
|
472
|
+
reason: `${legibility.counts.error} error-severity legibility finding(s) cap the composite below the known-good floor (${floor})`,
|
|
473
|
+
};
|
|
474
|
+
composite = capValue;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
composite: Math.round(composite * 100) / 100,
|
|
480
|
+
metrics,
|
|
481
|
+
style: { composite: styleComposite, metrics: styleMetrics },
|
|
482
|
+
cap,
|
|
483
|
+
legibility: legibility.counts,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function formatScore(report: ScoreReport): string {
|
|
488
|
+
const lines = [`score: ${report.composite}/100`];
|
|
489
|
+
if (report.cap) lines.push(` cap: ${report.cap.appliedAt} — ${report.cap.reason}`);
|
|
490
|
+
for (const m of report.metrics) {
|
|
491
|
+
lines.push(
|
|
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}`,
|
|
493
|
+
);
|
|
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
|
+
}
|
|
501
|
+
return lines.join('\n');
|
|
502
|
+
}
|