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,695 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
readSheetGeometry,
|
|
6
|
+
pinAbsolute,
|
|
7
|
+
pinsOfUnit,
|
|
8
|
+
type Bounds,
|
|
9
|
+
type SheetGeometry,
|
|
10
|
+
type TextItem,
|
|
11
|
+
type LabelItem,
|
|
12
|
+
type RectItem,
|
|
13
|
+
type WireSeg,
|
|
14
|
+
type PlacedSymbolGeom,
|
|
15
|
+
} from './sexp.js';
|
|
16
|
+
import type { LegibilityUserConfig } from '../config.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Deterministic read-only legibility checker (design C1-C9). Pure geometry over
|
|
20
|
+
* the sexp accessors: no serialization, no file writes, no subprocess, no
|
|
21
|
+
* network, no LLM. `check` reports its findings; the create pipeline gates on
|
|
22
|
+
* them (design C6).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const LEGIBILITY_FAMILIES = [
|
|
26
|
+
'off-grid',
|
|
27
|
+
'symbol-overlap',
|
|
28
|
+
'text-collision',
|
|
29
|
+
'wire-through-symbol',
|
|
30
|
+
'out-of-frame',
|
|
31
|
+
'ungrouped-symbol',
|
|
32
|
+
'unlabeled-group',
|
|
33
|
+
'group-overlap',
|
|
34
|
+
'empty-title-block',
|
|
35
|
+
'label-orientation',
|
|
36
|
+
'low-utilization',
|
|
37
|
+
'crowding',
|
|
38
|
+
'cross-group-wire',
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export type LegibilityKind = (typeof LEGIBILITY_FAMILIES)[number];
|
|
42
|
+
export type Severity = 'error' | 'advisory';
|
|
43
|
+
|
|
44
|
+
const DEFAULT_SEVERITY: Record<LegibilityKind, Severity> = {
|
|
45
|
+
'off-grid': 'error',
|
|
46
|
+
'symbol-overlap': 'error',
|
|
47
|
+
'text-collision': 'error',
|
|
48
|
+
'wire-through-symbol': 'error',
|
|
49
|
+
'out-of-frame': 'error',
|
|
50
|
+
'ungrouped-symbol': 'error',
|
|
51
|
+
'unlabeled-group': 'error',
|
|
52
|
+
'group-overlap': 'error',
|
|
53
|
+
'empty-title-block': 'error',
|
|
54
|
+
'label-orientation': 'advisory',
|
|
55
|
+
'low-utilization': 'advisory',
|
|
56
|
+
crowding: 'advisory',
|
|
57
|
+
'cross-group-wire': 'advisory',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface LegibilityThresholds {
|
|
61
|
+
gridPitch: number;
|
|
62
|
+
minPitch: number;
|
|
63
|
+
utilization: number;
|
|
64
|
+
maxWireLength: number;
|
|
65
|
+
familyCap: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const DEFAULT_THRESHOLDS: LegibilityThresholds = {
|
|
69
|
+
gridPitch: 1.27,
|
|
70
|
+
minPitch: 2.54,
|
|
71
|
+
utilization: 0.5,
|
|
72
|
+
maxWireLength: 50.8,
|
|
73
|
+
familyCap: 10,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** File-precision noise must never flip a finding (design C7). */
|
|
77
|
+
const TOL = 0.01;
|
|
78
|
+
/** Stroke-font advance as a fraction of height, tuned BELOW the true average (design C3). */
|
|
79
|
+
const TEXT_ADVANCE = 0.6;
|
|
80
|
+
/** Drawing-frame inset from the paper edge. */
|
|
81
|
+
const FRAME_BORDER = 10;
|
|
82
|
+
/** Reserved title-block rectangle in the bottom-right of the frame, clamped on small pages. */
|
|
83
|
+
const TITLE_BLOCK_W = 110;
|
|
84
|
+
const TITLE_BLOCK_H = 30;
|
|
85
|
+
|
|
86
|
+
/** Standard sizes as KiCad draws them for schematics: landscape, mm. */
|
|
87
|
+
const PAPER_SIZES: Record<string, { w: number; h: number }> = {
|
|
88
|
+
A5: { w: 210, h: 148 },
|
|
89
|
+
A4: { w: 297, h: 210 },
|
|
90
|
+
A3: { w: 420, h: 297 },
|
|
91
|
+
A2: { w: 594, h: 420 },
|
|
92
|
+
A1: { w: 841, h: 594 },
|
|
93
|
+
A0: { w: 1189, h: 841 },
|
|
94
|
+
A: { w: 279.4, h: 215.9 },
|
|
95
|
+
B: { w: 431.8, h: 279.4 },
|
|
96
|
+
C: { w: 558.8, h: 431.8 },
|
|
97
|
+
D: { w: 863.6, h: 558.8 },
|
|
98
|
+
E: { w: 1117.6, h: 863.6 },
|
|
99
|
+
USLetter: { w: 279.4, h: 215.9 },
|
|
100
|
+
USLegal: { w: 355.6, h: 215.9 },
|
|
101
|
+
USLedger: { w: 431.8, h: 279.4 },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export interface LegibilityFinding {
|
|
105
|
+
kind: LegibilityKind;
|
|
106
|
+
severity: Severity;
|
|
107
|
+
sheet: string;
|
|
108
|
+
at: { x: number; y: number };
|
|
109
|
+
refs: string[];
|
|
110
|
+
detail: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface LegibilityReport {
|
|
114
|
+
findings: LegibilityFinding[];
|
|
115
|
+
counts: { error: number; advisory: number };
|
|
116
|
+
skipped: { family: string; reason: string }[];
|
|
117
|
+
disabled: string[];
|
|
118
|
+
suppressed: { family: LegibilityKind; sheet: string; count: number }[];
|
|
119
|
+
sheets: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface ResolvedConfig {
|
|
123
|
+
thresholds: LegibilityThresholds;
|
|
124
|
+
severity: Record<LegibilityKind, Severity | 'off'>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveConfig(user?: LegibilityUserConfig): ResolvedConfig {
|
|
128
|
+
const t = { ...DEFAULT_THRESHOLDS };
|
|
129
|
+
for (const key of Object.keys(t) as (keyof LegibilityThresholds)[]) {
|
|
130
|
+
const v = user?.thresholds?.[key];
|
|
131
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) t[key] = v;
|
|
132
|
+
}
|
|
133
|
+
const severity = { ...DEFAULT_SEVERITY } as Record<LegibilityKind, Severity | 'off'>;
|
|
134
|
+
for (const [k, v] of Object.entries(user?.severity ?? {})) {
|
|
135
|
+
if ((LEGIBILITY_FAMILIES as readonly string[]).includes(k) && (v === 'error' || v === 'advisory' || v === 'off')) {
|
|
136
|
+
severity[k as LegibilityKind] = v;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { thresholds: t, severity };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const fmt = (n: number): string => String(Math.round(n * 100) / 100);
|
|
143
|
+
const boundsOverlap = (a: Bounds, b: Bounds): boolean =>
|
|
144
|
+
a.minX < b.maxX - TOL && b.minX < a.maxX - TOL && a.minY < b.maxY - TOL && b.minY < a.maxY - TOL;
|
|
145
|
+
const boundsContain = (outer: Bounds, inner: Bounds): boolean =>
|
|
146
|
+
outer.minX <= inner.minX + TOL && outer.minY <= inner.minY + TOL && outer.maxX >= inner.maxX - TOL && outer.maxY >= inner.maxY - TOL;
|
|
147
|
+
const pointIn = (x: number, y: number, b: Bounds): boolean =>
|
|
148
|
+
x > b.minX + TOL && x < b.maxX - TOL && y > b.minY + TOL && y < b.maxY - TOL;
|
|
149
|
+
const area = (b: Bounds): number => Math.max(0, b.maxX - b.minX) * Math.max(0, b.maxY - b.minY);
|
|
150
|
+
const center = (b: Bounds): { x: number; y: number } => ({ x: (b.minX + b.maxX) / 2, y: (b.minY + b.maxY) / 2 });
|
|
151
|
+
|
|
152
|
+
/** Edge-to-edge distance between two boxes; 0 when they touch or overlap. */
|
|
153
|
+
function edgeDistance(a: Bounds, b: Bounds): number {
|
|
154
|
+
const dx = Math.max(0, Math.max(a.minX - b.maxX, b.minX - a.maxX));
|
|
155
|
+
const dy = Math.max(0, Math.max(a.minY - b.maxY, b.minY - a.maxY));
|
|
156
|
+
return Math.hypot(dx, dy);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function union(bs: Bounds[]): Bounds | null {
|
|
160
|
+
if (!bs.length) return null;
|
|
161
|
+
return bs.reduce((acc, b) => ({
|
|
162
|
+
minX: Math.min(acc.minX, b.minX),
|
|
163
|
+
minY: Math.min(acc.minY, b.minY),
|
|
164
|
+
maxX: Math.max(acc.maxX, b.maxX),
|
|
165
|
+
maxY: Math.max(acc.maxY, b.maxY),
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Transform a symbol-space box into schematic space (same math as pinAbsolute). */
|
|
170
|
+
function transformBounds(at: { x: number; y: number; rot: number }, mirror: 'x' | 'y' | null, b: Bounds): Bounds {
|
|
171
|
+
const corners = [
|
|
172
|
+
{ x: b.minX, y: b.minY },
|
|
173
|
+
{ x: b.minX, y: b.maxY },
|
|
174
|
+
{ x: b.maxX, y: b.minY },
|
|
175
|
+
{ x: b.maxX, y: b.maxY },
|
|
176
|
+
].map((c) => pinAbsolute(at, mirror, c));
|
|
177
|
+
return {
|
|
178
|
+
minX: Math.min(...corners.map((c) => c.x)),
|
|
179
|
+
minY: Math.min(...corners.map((c) => c.y)),
|
|
180
|
+
maxX: Math.max(...corners.map((c) => c.x)),
|
|
181
|
+
maxY: Math.max(...corners.map((c) => c.y)),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Conservative text box, centered on the anchor; vertical when rotated 90/270. */
|
|
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 {
|
|
195
|
+
const w = Math.max(1, t.text.length) * TEXT_ADVANCE * t.height;
|
|
196
|
+
const h = t.height;
|
|
197
|
+
const vertical = Math.abs(t.rot % 180) === 90;
|
|
198
|
+
const [bw, bh] = vertical ? [h, w] : [w, h];
|
|
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 };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Label text extends AWAY from the anchor in the rotation's direction (this is
|
|
213
|
+
* how eeschema renders net/global labels: 0 rightward, 180 leftward, 90 upward,
|
|
214
|
+
* 270 downward in schematic Y-down coordinates). Centering the box instead
|
|
215
|
+
* would bleed half of it across the attachment point and invent collisions
|
|
216
|
+
* with the very symbol the label serves — the false positive C3 forbids.
|
|
217
|
+
*
|
|
218
|
+
* KiCad never draws a label upside down: angles 180/270 are normalized to 0/90
|
|
219
|
+
* at draw time with the DEFAULT justification mirrored, and a stored
|
|
220
|
+
* `(justify …)` describes that drawn frame. So the effective justification is
|
|
221
|
+
* the stored one when present, else left for 0/90 and right for 180/270 — and
|
|
222
|
+
* left-justified text starts at the anchor (extends right / up) while
|
|
223
|
+
* right-justified text ends there (extends left / down). This makes the three
|
|
224
|
+
* wild encodings of a leftward label — angle 180 alone, angle 180 + justify
|
|
225
|
+
* right (eeschema's own output, pinned by the open-key golden), and angle 0 +
|
|
226
|
+
* justify right (the engine's emitter) — measure the same box; measuring any
|
|
227
|
+
* of them on the wrong side of the anchor either invents a collision with the
|
|
228
|
+
* symbol the label serves or hides a real one.
|
|
229
|
+
*/
|
|
230
|
+
function labelBounds(l: { name: string; x: number; y: number; rot: number; height: number; justify?: 'left' | 'right' | null; kind?: string }): Bounds {
|
|
231
|
+
const w = Math.max(1, l.name.length) * TEXT_ADVANCE * l.height;
|
|
232
|
+
const h = l.height;
|
|
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
|
+
}
|
|
245
|
+
const justified = l.justify ?? (rot === 180 || rot === 270 ? 'right' : 'left');
|
|
246
|
+
if (rot === 90 || rot === 270) {
|
|
247
|
+
return justified === 'left'
|
|
248
|
+
? { minX: l.x - h / 2, minY: l.y - w, maxX: l.x + h / 2, maxY: l.y }
|
|
249
|
+
: { minX: l.x - h / 2, minY: l.y, maxX: l.x + h / 2, maxY: l.y + w };
|
|
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.
|
|
256
|
+
return justified === 'right'
|
|
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;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function segIntersectsBounds(s: WireSeg, b: Bounds): boolean {
|
|
271
|
+
// trivial reject / accept via endpoints, then conservative sampling along the
|
|
272
|
+
// segment (segments here are axis-aligned or short; sampling at grid density
|
|
273
|
+
// cannot miss a crossing wider than the tolerance)
|
|
274
|
+
if (pointIn(s.x1, s.y1, b) || pointIn(s.x2, s.y2, b)) return true;
|
|
275
|
+
const len = Math.hypot(s.x2 - s.x1, s.y2 - s.y1);
|
|
276
|
+
const steps = Math.max(2, Math.ceil(len / 0.5));
|
|
277
|
+
for (let i = 1; i < steps; i++) {
|
|
278
|
+
const t = i / steps;
|
|
279
|
+
if (pointIn(s.x1 + (s.x2 - s.x1) * t, s.y1 + (s.y2 - s.y1) * t, b)) return true;
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const onGrid = (v: number, pitch: number): boolean => {
|
|
285
|
+
const r = Math.abs(v - Math.round(v / pitch) * pitch);
|
|
286
|
+
return r <= TOL;
|
|
287
|
+
};
|
|
288
|
+
const nearestGrid = (v: number, pitch: number): number => Math.round(v / pitch) * pitch;
|
|
289
|
+
|
|
290
|
+
const pointOnSeg = (x: number, y: number, s: WireSeg): boolean => {
|
|
291
|
+
const cross = Math.abs((s.x2 - s.x1) * (y - s.y1) - (s.y2 - s.y1) * (x - s.x1));
|
|
292
|
+
const len = Math.hypot(s.x2 - s.x1, s.y2 - s.y1) || 1;
|
|
293
|
+
if (cross / len > TOL) return false;
|
|
294
|
+
const dot = (x - s.x1) * (s.x2 - s.x1) + (y - s.y1) * (s.y2 - s.y1);
|
|
295
|
+
return dot >= -TOL && dot <= len * len + TOL;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/** Markdown headings (## and deeper) from SUBSYSTEMS.md and BOM.md name the caption vocabulary. */
|
|
299
|
+
async function loadCaptionNames(docsDir: string | null): Promise<{ names: string[] | null; reason: string | null }> {
|
|
300
|
+
if (!docsDir) return { names: null, reason: 'no docs directory provided' };
|
|
301
|
+
const names: string[] = [];
|
|
302
|
+
let anyFile = false;
|
|
303
|
+
for (const file of ['SUBSYSTEMS.md', 'BOM.md']) {
|
|
304
|
+
const p = path.join(docsDir, file);
|
|
305
|
+
if (!existsSync(p)) continue;
|
|
306
|
+
anyFile = true;
|
|
307
|
+
const text = await readFile(p, 'utf8');
|
|
308
|
+
for (const m of text.matchAll(/^#{2,6}\s+(.+?)\s*$/gm)) {
|
|
309
|
+
const name = m[1]!.trim();
|
|
310
|
+
if (name) names.push(name);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (!anyFile) return { names: null, reason: 'SUBSYSTEMS.md and BOM.md are absent' };
|
|
314
|
+
if (!names.length) return { names: null, reason: 'no parseable subsystem or component-group headings in SUBSYSTEMS.md/BOM.md' };
|
|
315
|
+
return { names, reason: null };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const captionMatches = (caption: string, names: string[]): boolean => {
|
|
319
|
+
const c = caption.trim().toLowerCase();
|
|
320
|
+
return names.some((n) => {
|
|
321
|
+
const l = n.toLowerCase();
|
|
322
|
+
return c === l || c.includes(l) || l.includes(c);
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
interface Group {
|
|
327
|
+
rect: RectItem;
|
|
328
|
+
bounds: Bounds;
|
|
329
|
+
caption: string | null;
|
|
330
|
+
label: string;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
interface SymbolGeom {
|
|
334
|
+
sym: PlacedSymbolGeom;
|
|
335
|
+
body: Bounds | null;
|
|
336
|
+
pins: { x: number; y: number }[];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface CheckLegibilityOptions {
|
|
340
|
+
/** Directory holding SUBSYSTEMS.md/BOM.md for caption validation. */
|
|
341
|
+
docsDir?: string | null;
|
|
342
|
+
config?: LegibilityUserConfig;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function checkLegibility(rootSch: string, opts: CheckLegibilityOptions = {}): Promise<LegibilityReport> {
|
|
346
|
+
const { thresholds: th, severity } = resolveConfig(opts.config);
|
|
347
|
+
const sheets = await readSheetGeometry(rootSch);
|
|
348
|
+
const captions = await loadCaptionNames(opts.docsDir ?? null);
|
|
349
|
+
|
|
350
|
+
const disabled = LEGIBILITY_FAMILIES.filter((f) => severity[f] === 'off');
|
|
351
|
+
const skipped: { family: string; reason: string }[] = [];
|
|
352
|
+
if (captions.names === null && captions.reason) {
|
|
353
|
+
skipped.push({ family: 'caption-validation', reason: `${captions.reason}; group captions not validated against docs` });
|
|
354
|
+
}
|
|
355
|
+
const raw: LegibilityFinding[] = [];
|
|
356
|
+
const add = (kind: LegibilityKind, sheet: string, at: { x: number; y: number }, refs: string[], detail: string): void => {
|
|
357
|
+
const sev = severity[kind];
|
|
358
|
+
if (sev === 'off') return;
|
|
359
|
+
raw.push({ kind, severity: sev, sheet, at: { x: Math.round(at.x * 100) / 100, y: Math.round(at.y * 100) / 100 }, refs, detail });
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
for (const sheet of sheets) {
|
|
363
|
+
checkSheet(sheet, th, add, skipped, captions.names);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Per-family per-sheet cap with an explicit suppressed count (design C5),
|
|
367
|
+
// then off-grid first (design C9), remaining families in declaration order.
|
|
368
|
+
const suppressed: { family: LegibilityKind; sheet: string; count: number }[] = [];
|
|
369
|
+
const kept: LegibilityFinding[] = [];
|
|
370
|
+
const perKey = new Map<string, number>();
|
|
371
|
+
for (const f of raw) {
|
|
372
|
+
// Separator written as an escape, never as a literal NUL byte: a raw NUL in
|
|
373
|
+
// the source makes git-grep, ripgrep and most editors classify this whole file
|
|
374
|
+
// as binary and silently skip it, so a search for anything in here comes back
|
|
375
|
+
// empty with no error rather than no match. Same string at runtime, and still a
|
|
376
|
+
// separator no kind or sheet name can contain.
|
|
377
|
+
const key = `${f.kind}\u0000${f.sheet}`;
|
|
378
|
+
const n = (perKey.get(key) ?? 0) + 1;
|
|
379
|
+
perKey.set(key, n);
|
|
380
|
+
if (n <= th.familyCap) kept.push(f);
|
|
381
|
+
else {
|
|
382
|
+
const s = suppressed.find((x) => x.family === f.kind && x.sheet === f.sheet);
|
|
383
|
+
if (s) s.count++;
|
|
384
|
+
else suppressed.push({ family: f.kind, sheet: f.sheet, count: 1 });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const order = new Map(LEGIBILITY_FAMILIES.map((f, i) => [f, i === 0 ? -1 : i]));
|
|
388
|
+
const findings = kept
|
|
389
|
+
.map((f, i) => ({ f, i }))
|
|
390
|
+
.sort((a, b) => (order.get(a.f.kind)! - order.get(b.f.kind)!) || a.i - b.i)
|
|
391
|
+
.map(({ f }) => f);
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
findings,
|
|
395
|
+
counts: {
|
|
396
|
+
error: findings.filter((f) => f.severity === 'error').length,
|
|
397
|
+
advisory: findings.filter((f) => f.severity === 'advisory').length,
|
|
398
|
+
},
|
|
399
|
+
skipped,
|
|
400
|
+
disabled: [...disabled],
|
|
401
|
+
suppressed,
|
|
402
|
+
sheets: sheets.length,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function checkSheet(
|
|
407
|
+
sheet: SheetGeometry,
|
|
408
|
+
th: LegibilityThresholds,
|
|
409
|
+
add: (kind: LegibilityKind, sheet: string, at: { x: number; y: number }, refs: string[], detail: string) => void,
|
|
410
|
+
skipped: { family: string; reason: string }[],
|
|
411
|
+
captionNames: string[] | null,
|
|
412
|
+
): void {
|
|
413
|
+
const S = sheet.sheetName;
|
|
414
|
+
|
|
415
|
+
// --- geometry prep ---
|
|
416
|
+
const syms: SymbolGeom[] = sheet.symbols.map((sym) => {
|
|
417
|
+
// a placed unit of a multi-unit symbol draws only its own unit's body and
|
|
418
|
+
// pins; measuring it with the whole package would flag phantom collisions
|
|
419
|
+
const lib = sheet.libBounds.get(`${sym.libId}#${sym.unit}`) ?? sheet.libBounds.get(sym.libId) ?? null;
|
|
420
|
+
const pins = pinsOfUnit(sheet.libPins.get(sym.libId) ?? [], sym.unit).map((p) => pinAbsolute(sym.at, sym.mirror, p));
|
|
421
|
+
// A body-less symbol (graphics-free lib entry) falls back to its pin extent
|
|
422
|
+
// so geometric families still see it; a symbol with neither is skipped.
|
|
423
|
+
const body = lib
|
|
424
|
+
? transformBounds(sym.at, sym.mirror, lib)
|
|
425
|
+
: union(pins.map((p) => ({ minX: p.x, minY: p.y, maxX: p.x, maxY: p.y })));
|
|
426
|
+
return { sym, body, pins };
|
|
427
|
+
});
|
|
428
|
+
const realSyms = syms.filter((s) => !s.sym.isPower);
|
|
429
|
+
|
|
430
|
+
const visibleTexts: { owner: string; ownerRef: string | null; t: TextItem; box: Bounds }[] = [];
|
|
431
|
+
for (const s of sheet.symbols) {
|
|
432
|
+
for (const p of s.props) {
|
|
433
|
+
if (!p.hidden && p.text) visibleTexts.push({ owner: `${s.ref} ${p.text === s.value ? 'Value' : 'Reference'}`, ownerRef: s.ref, t: p, box: textBounds(p) });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const t of sheet.texts) {
|
|
437
|
+
if (!t.hidden && t.text) visibleTexts.push({ owner: `text "${t.text}"`, ownerRef: null, t, box: textBounds(t) });
|
|
438
|
+
}
|
|
439
|
+
const labelBoxes = sheet.labels.map((l) => ({
|
|
440
|
+
l,
|
|
441
|
+
box: labelBounds(l),
|
|
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))),
|
|
447
|
+
}));
|
|
448
|
+
|
|
449
|
+
// --- groups (design C1): sheet rectangles with a caption in the top band ---
|
|
450
|
+
const groups: Group[] = sheet.rectangles.map((rect, i) => {
|
|
451
|
+
const bounds: Bounds = {
|
|
452
|
+
minX: Math.min(rect.x1, rect.x2),
|
|
453
|
+
minY: Math.min(rect.y1, rect.y2),
|
|
454
|
+
maxX: Math.max(rect.x1, rect.x2),
|
|
455
|
+
maxY: Math.max(rect.y1, rect.y2),
|
|
456
|
+
};
|
|
457
|
+
const band = Math.min((bounds.maxY - bounds.minY) * 0.25, 10);
|
|
458
|
+
const cap = sheet.texts.find(
|
|
459
|
+
(t) => !t.hidden && t.text && pointIn(t.x, t.y, bounds) && t.y <= bounds.minY + band,
|
|
460
|
+
);
|
|
461
|
+
const caption = cap?.text ?? null;
|
|
462
|
+
return { rect, bounds, caption, label: caption ?? `group#${i + 1}` };
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// --- off-grid (reported first, design C9) ---
|
|
466
|
+
for (const { sym } of syms) {
|
|
467
|
+
if (!onGrid(sym.at.x, th.gridPitch) || !onGrid(sym.at.y, th.gridPitch)) {
|
|
468
|
+
add('off-grid', S, { x: sym.at.x, y: sym.at.y }, [sym.ref], `symbol ${sym.ref} origin (${fmt(sym.at.x)}, ${fmt(sym.at.y)}) is off the ${th.gridPitch}mm grid; move it to (${fmt(nearestGrid(sym.at.x, th.gridPitch))}, ${fmt(nearestGrid(sym.at.y, th.gridPitch))})`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const w of sheet.wires) {
|
|
472
|
+
for (const [x, y] of [[w.x1, w.y1], [w.x2, w.y2]] as const) {
|
|
473
|
+
if (!onGrid(x, th.gridPitch) || !onGrid(y, th.gridPitch)) {
|
|
474
|
+
add('off-grid', S, { x, y }, [S], `wire endpoint (${fmt(x)}, ${fmt(y)}) is off the ${th.gridPitch}mm grid; move it to (${fmt(nearestGrid(x, th.gridPitch))}, ${fmt(nearestGrid(y, th.gridPitch))})`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (const l of sheet.labels) {
|
|
479
|
+
if (!onGrid(l.x, th.gridPitch) || !onGrid(l.y, th.gridPitch)) {
|
|
480
|
+
add('off-grid', S, { x: l.x, y: l.y }, [l.name], `label "${l.name}" at (${fmt(l.x)}, ${fmt(l.y)}) is off the ${th.gridPitch}mm grid; move it to (${fmt(nearestGrid(l.x, th.gridPitch))}, ${fmt(nearestGrid(l.y, th.gridPitch))})`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// --- symbol-overlap and crowding (unordered pairs, design C5) ---
|
|
485
|
+
for (let i = 0; i < realSyms.length; i++) {
|
|
486
|
+
for (let j = i + 1; j < realSyms.length; j++) {
|
|
487
|
+
const a = realSyms[i]!;
|
|
488
|
+
const b = realSyms[j]!;
|
|
489
|
+
if (!a.body || !b.body) continue;
|
|
490
|
+
if (boundsOverlap(a.body, b.body)) {
|
|
491
|
+
add('symbol-overlap', S, center(a.body), [a.sym.ref, b.sym.ref], `symbol bodies of ${a.sym.ref} and ${b.sym.ref} overlap; separate them by at least ${th.minPitch}mm edge to edge`);
|
|
492
|
+
} else {
|
|
493
|
+
const d = edgeDistance(a.body, b.body);
|
|
494
|
+
if (d < th.minPitch - TOL) {
|
|
495
|
+
add('crowding', S, center(a.body), [a.sym.ref, b.sym.ref], `${a.sym.ref} and ${b.sym.ref} sit ${fmt(d)}mm apart edge to edge, below the readable pitch of ${th.minPitch}mm`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// --- text-collision (design C2/C3: pin text excluded, own body excluded, attached wire excluded) ---
|
|
502
|
+
const collide = (box: Bounds, ownerRef: string | null, name: string, at: { x: number; y: number }, attachedWires: WireSeg[]): void => {
|
|
503
|
+
for (const s of realSyms) {
|
|
504
|
+
if (!s.body || s.sym.ref === ownerRef) continue;
|
|
505
|
+
if (boundsOverlap(box, s.body)) {
|
|
506
|
+
add('text-collision', S, at, [name, s.sym.ref], `${name} text overlaps the body of ${s.sym.ref}; move the text clear of the symbol`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
for (const w of sheet.wires) {
|
|
511
|
+
if (attachedWires.includes(w)) continue;
|
|
512
|
+
if (segIntersectsBounds(w, box)) {
|
|
513
|
+
add('text-collision', S, at, [name], `${name} text sits on a wire; move the text off the wire`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
for (const vt of visibleTexts) collide(vt.box, vt.ownerRef, vt.owner, { x: vt.t.x, y: vt.t.y }, []);
|
|
519
|
+
for (const lb of labelBoxes) collide(lb.box, null, `label "${lb.l.name}"`, { x: lb.l.x, y: lb.l.y }, lb.attached);
|
|
520
|
+
// text-over-text, each unordered pair once
|
|
521
|
+
const allText = [
|
|
522
|
+
...visibleTexts.map((v) => ({ name: v.owner, box: v.box, at: { x: v.t.x, y: v.t.y } })),
|
|
523
|
+
...labelBoxes.map((lb) => ({ name: `label "${lb.l.name}"`, box: lb.box, at: { x: lb.l.x, y: lb.l.y } })),
|
|
524
|
+
];
|
|
525
|
+
for (let i = 0; i < allText.length; i++) {
|
|
526
|
+
for (let j = i + 1; j < allText.length; j++) {
|
|
527
|
+
if (boundsOverlap(allText[i]!.box, allText[j]!.box)) {
|
|
528
|
+
add('text-collision', S, allText[i]!.at, [allText[i]!.name, allText[j]!.name], `${allText[i]!.name} and ${allText[j]!.name} overlap; separate the two text items`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// --- wire-through-symbol ---
|
|
534
|
+
for (const w of sheet.wires) {
|
|
535
|
+
for (const s of realSyms) {
|
|
536
|
+
if (!s.body) continue;
|
|
537
|
+
const endsOnPin = s.pins.some(
|
|
538
|
+
(p) => (Math.abs(p.x - w.x1) <= TOL && Math.abs(p.y - w.y1) <= TOL) || (Math.abs(p.x - w.x2) <= TOL && Math.abs(p.y - w.y2) <= TOL),
|
|
539
|
+
);
|
|
540
|
+
if (!endsOnPin && segIntersectsBounds(w, s.body)) {
|
|
541
|
+
add('wire-through-symbol', S, center(s.body), [s.sym.ref], `a wire crosses the body of ${s.sym.ref} without terminating on one of its pins; route it around the symbol`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// --- groups: membership, captions, overlap ---
|
|
547
|
+
for (const g of groups) {
|
|
548
|
+
if (!g.caption) {
|
|
549
|
+
add('unlabeled-group', S, center(g.bounds), [g.label], `group rectangle at (${fmt(g.bounds.minX)}, ${fmt(g.bounds.minY)}) has no caption in its top band; add a text caption naming the subsystem`);
|
|
550
|
+
} else if (captionNames && !captionMatches(g.caption, captionNames)) {
|
|
551
|
+
const nearest = captionNames[0]!;
|
|
552
|
+
add('unlabeled-group', S, center(g.bounds), [g.caption], `group caption "${g.caption}" names nothing in SUBSYSTEMS.md or BOM.md; use a documented name (e.g. "${nearest}")`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
for (let i = 0; i < groups.length; i++) {
|
|
556
|
+
for (let j = i + 1; j < groups.length; j++) {
|
|
557
|
+
const a = groups[i]!;
|
|
558
|
+
const b = groups[j]!;
|
|
559
|
+
if (boundsOverlap(a.bounds, b.bounds) && !boundsContain(a.bounds, b.bounds) && !boundsContain(b.bounds, a.bounds)) {
|
|
560
|
+
add('group-overlap', S, center(a.bounds), [a.label, b.label], `group boxes "${a.label}" and "${b.label}" intersect; tile groups without overlap (full nesting is allowed)`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
for (const s of realSyms) {
|
|
565
|
+
if (!s.body) continue;
|
|
566
|
+
const containing = groups.filter((g) => boundsContain(g.bounds, s.body!));
|
|
567
|
+
if (!containing.length) {
|
|
568
|
+
const nearest = groups
|
|
569
|
+
.map((g) => ({ g, d: edgeDistance(g.bounds, s.body!) }))
|
|
570
|
+
.sort((a, b) => a.d - b.d)[0];
|
|
571
|
+
add('ungrouped-symbol', S, center(s.body), [s.sym.ref], `${s.sym.ref} sits inside no group rectangle${nearest ? `; the nearest group is "${nearest.g.label}" (${fmt(nearest.d)}mm away)` : '; draw a captioned group rectangle around its subsystem'}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// --- label-orientation: rotated where a horizontal draw would collide with nothing ---
|
|
576
|
+
for (const lb of labelBoxes) {
|
|
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;
|
|
581
|
+
const horizontal = labelBounds({ ...lb.l, rot: 0 });
|
|
582
|
+
const collides =
|
|
583
|
+
realSyms.some((s) => s.body && boundsOverlap(horizontal, s.body)) ||
|
|
584
|
+
sheet.wires.some((w) => !lb.attached.includes(w) && segIntersectsBounds(w, horizontal)) ||
|
|
585
|
+
allText.some((t) => t.name !== `label "${lb.l.name}"` && boundsOverlap(horizontal, t.box));
|
|
586
|
+
if (!collides) {
|
|
587
|
+
add('label-orientation', S, { x: lb.l.x, y: lb.l.y }, [lb.l.name], `label "${lb.l.name}" is rotated ${fmt(lb.l.rot)}° where a horizontal label would collide with nothing; draw it horizontal`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// --- page-relative checks (design C8: unknown paper skips loudly) ---
|
|
592
|
+
const paper = resolvePaper(sheet);
|
|
593
|
+
if (!paper) {
|
|
594
|
+
skipped.push({ family: 'page-checks', reason: `sheet "${S}": paper "${sheet.paper.name ?? '(none)'}" is not a recognized standard size; out-of-frame and low-utilization skipped` });
|
|
595
|
+
} else {
|
|
596
|
+
const usable: Bounds = { minX: FRAME_BORDER, minY: FRAME_BORDER, maxX: paper.w - FRAME_BORDER, maxY: paper.h - FRAME_BORDER };
|
|
597
|
+
const tbW = Math.min(TITLE_BLOCK_W, (usable.maxX - usable.minX) / 2);
|
|
598
|
+
const tbH = Math.min(TITLE_BLOCK_H, (usable.maxY - usable.minY) / 4);
|
|
599
|
+
const titleRegion: Bounds = { minX: usable.maxX - tbW, minY: usable.maxY - tbH, maxX: usable.maxX, maxY: usable.maxY };
|
|
600
|
+
|
|
601
|
+
const items: { name: string; box: Bounds }[] = [
|
|
602
|
+
...realSyms.filter((s) => s.body).map((s) => ({ name: s.sym.ref, box: s.body! })),
|
|
603
|
+
...groups.map((g) => ({ name: `group "${g.label}"`, box: g.bounds })),
|
|
604
|
+
...allText.map((t) => ({ name: t.name, box: t.box })),
|
|
605
|
+
...sheet.wires.map((w, i) => ({ name: `wire#${i + 1}`, box: { minX: Math.min(w.x1, w.x2), minY: Math.min(w.y1, w.y2), maxX: Math.max(w.x1, w.x2), maxY: Math.max(w.y1, w.y2) } })),
|
|
606
|
+
];
|
|
607
|
+
for (const item of items) {
|
|
608
|
+
if (!boundsContain(usable, item.box)) {
|
|
609
|
+
const edge = item.box.minX < usable.minX ? 'left' : item.box.maxX > usable.maxX ? 'right' : item.box.minY < usable.minY ? 'top' : 'bottom';
|
|
610
|
+
add('out-of-frame', S, center(item.box), [item.name], `${item.name} extends outside the usable drawing frame (${edge} edge); move it inside the frame`);
|
|
611
|
+
} else if (boundsOverlap(item.box, titleRegion)) {
|
|
612
|
+
add('out-of-frame', S, center(item.box), [item.name], `${item.name} overlaps the reserved title-block region; move it clear of the bottom-right corner`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const content = union(items.map((i) => i.box));
|
|
617
|
+
if (content) {
|
|
618
|
+
const frac = area(content) / (area(usable) - area(titleRegion));
|
|
619
|
+
if (frac < th.utilization - TOL) {
|
|
620
|
+
const fit = suggestSmallerPaper(content, paper);
|
|
621
|
+
add('low-utilization', S, center(content), [S], `content occupies ${(Math.round(frac * 100) / 100).toFixed(2)} of the usable frame (threshold ${th.utilization})${fit ? `; ${fit} would fit this content` : '; spread the groups to fill the frame'}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// --- cross-group-wire ---
|
|
627
|
+
for (const w of sheet.wires) {
|
|
628
|
+
const g1 = groups.find((g) => pointIn(w.x1, w.y1, g.bounds));
|
|
629
|
+
const g2 = groups.find((g) => pointIn(w.x2, w.y2, g.bounds));
|
|
630
|
+
const len = Math.hypot(w.x2 - w.x1, w.y2 - w.y1);
|
|
631
|
+
const net = sheet.labels.find((l) => pointOnSeg(l.x, l.y, w))?.name ?? '(unlabeled)';
|
|
632
|
+
if (g1 && g2 && g1 !== g2) {
|
|
633
|
+
add('cross-group-wire', S, { x: (w.x1 + w.x2) / 2, y: (w.y1 + w.y2) / 2 }, [g1.label, g2.label, net], `a wire on net ${net} runs from group "${g1.label}" into group "${g2.label}"; replace it with a net label pair`);
|
|
634
|
+
} else if (len > th.maxWireLength + TOL) {
|
|
635
|
+
add('cross-group-wire', S, { x: (w.x1 + w.x2) / 2, y: (w.y1 + w.y2) / 2 }, [g1?.label ?? S, net], `a ${fmt(len)}mm wire on net ${net} exceeds the ${th.maxWireLength}mm maximum; shorten the route or use a net label pair`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// --- empty-title-block ---
|
|
640
|
+
const tb = sheet.titleBlock;
|
|
641
|
+
const empty: string[] = [];
|
|
642
|
+
if (!tb?.title) empty.push('title');
|
|
643
|
+
if (!tb?.rev) empty.push('revision');
|
|
644
|
+
if (!tb?.date) empty.push('date');
|
|
645
|
+
if (empty.length) {
|
|
646
|
+
const at = paperAnchor(sheet);
|
|
647
|
+
add('empty-title-block', S, at, empty, `title block ${tb ? 'has empty' : 'is missing; fill in the'} ${empty.join(', ')} field(s)`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function resolvePaper(sheet: SheetGeometry): { w: number; h: number } | null {
|
|
652
|
+
if (sheet.paper.width !== null && sheet.paper.height !== null) {
|
|
653
|
+
return { w: sheet.paper.width, h: sheet.paper.height };
|
|
654
|
+
}
|
|
655
|
+
const std = sheet.paper.name ? PAPER_SIZES[sheet.paper.name] : undefined;
|
|
656
|
+
if (!std) return null;
|
|
657
|
+
return sheet.paper.portrait ? { w: std.h, h: std.w } : std;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function paperAnchor(sheet: SheetGeometry): { x: number; y: number } {
|
|
661
|
+
const p = resolvePaper(sheet);
|
|
662
|
+
return p ? { x: p.w - FRAME_BORDER, y: p.h - FRAME_BORDER } : { x: 0, y: 0 };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function suggestSmallerPaper(content: Bounds, current: { w: number; h: number }): string | null {
|
|
666
|
+
const needW = content.maxX - content.minX + 2 * FRAME_BORDER;
|
|
667
|
+
const needH = content.maxY - content.minY + 2 * FRAME_BORDER + TITLE_BLOCK_H;
|
|
668
|
+
const candidates = Object.entries(PAPER_SIZES)
|
|
669
|
+
.filter(([, s]) => s.w >= needW && s.h >= needH && s.w * s.h < current.w * current.h)
|
|
670
|
+
.sort((a, b) => a[1].w * a[1].h - b[1].w * b[1].h);
|
|
671
|
+
return candidates.length ? `paper "${candidates[0]![0]}"` : null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Numbered human rendering shared by the agent tool and `check`. */
|
|
675
|
+
export function formatLegibility(report: LegibilityReport): string {
|
|
676
|
+
if (!report.findings.length && !report.suppressed.length) {
|
|
677
|
+
const notes = [
|
|
678
|
+
...report.skipped.map((s) => `note: ${s.family} skipped (${s.reason})`),
|
|
679
|
+
...(report.disabled.length ? [`note: disabled by config: ${report.disabled.join(', ')}`] : []),
|
|
680
|
+
];
|
|
681
|
+
return [`legibility: ${report.sheets} sheet(s) checked, no findings`, ...notes].join('\n');
|
|
682
|
+
}
|
|
683
|
+
const lines: string[] = [
|
|
684
|
+
`legibility: ${report.counts.error} error, ${report.counts.advisory} advisory finding(s) across ${report.sheets} sheet(s):`,
|
|
685
|
+
];
|
|
686
|
+
report.findings.forEach((f, i) => {
|
|
687
|
+
lines.push(` ${i + 1}. [${f.severity}] ${f.kind} @ ${f.sheet} (${f.at.x}, ${f.at.y}) refs: ${f.refs.join(', ')} — ${f.detail}`);
|
|
688
|
+
});
|
|
689
|
+
for (const s of report.suppressed) {
|
|
690
|
+
lines.push(` … ${s.count} more ${s.family} finding(s) on sheet ${s.sheet} suppressed (per-family cap)`);
|
|
691
|
+
}
|
|
692
|
+
for (const s of report.skipped) lines.push(` note: ${s.family} skipped (${s.reason})`);
|
|
693
|
+
if (report.disabled.length) lines.push(` note: disabled by config: ${report.disabled.join(', ')}`);
|
|
694
|
+
return lines.join('\n');
|
|
695
|
+
}
|