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.
- package/README.md +41 -2
- 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/loop.js +34 -14
- package/dist/agent/loop.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/recovery.js +91 -14
- 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 +99 -769
- package/dist/agent/tools.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 +84 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +5 -2
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +33 -3
- 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 +18 -6
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +106 -18
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/draft/draft.js +3 -0
- package/dist/kicad/draft/draft.js.map +1 -1
- package/dist/kicad/draft/engine.js +3139 -218
- package/dist/kicad/draft/engine.js.map +1 -1
- package/dist/kicad/draft/symsource.js +24 -10
- package/dist/kicad/draft/symsource.js.map +1 -1
- package/dist/kicad/emit.js +45 -6
- package/dist/kicad/emit.js.map +1 -1
- package/dist/kicad/legibility.js +51 -4
- package/dist/kicad/legibility.js.map +1 -1
- package/dist/kicad/score.js +173 -3
- package/dist/kicad/score.js.map +1 -1
- package/dist/kicad/sexp.js +32 -6
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/mcp/server.js +485 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/memory/scaffold.js +8 -1
- package/dist/memory/scaffold.js.map +1 -1
- package/package.json +5 -2
- 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/loop.ts +45 -17
- package/src/agent/providers/claude-code.ts +22 -1
- package/src/agent/providers/codex.ts +91 -42
- package/src/agent/recovery.ts +89 -12
- 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 -816
- 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 +84 -1
- package/src/commands/create.ts +5 -2
- package/src/commands/doctor.ts +34 -3
- package/src/commands/skill.ts +127 -0
- package/src/commands/sync.ts +5 -3
- package/src/config.ts +32 -8
- package/src/kicad/cli.ts +129 -18
- package/src/kicad/draft/draft.ts +2 -0
- package/src/kicad/draft/engine.ts +3034 -226
- package/src/kicad/draft/symsource.ts +24 -10
- package/src/kicad/emit.ts +71 -7
- package/src/kicad/legibility.ts +55 -6
- package/src/kicad/score.ts +187 -8
- package/src/kicad/sexp.ts +37 -6
- package/src/mcp/server.ts +560 -0
- package/src/memory/scaffold.ts +8 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Bounds } from '../sexp.js';
|
|
2
|
-
import { knum, type PlacementModel, type EmitSymbol } from '../emit.js';
|
|
2
|
+
import { knum, CAPTION_SIZE, type PlacementModel, type EmitSymbol, type EmitLabel, type LabelShape } from '../emit.js';
|
|
3
3
|
import { powerSymbolSource, pwrFlagSource, type ResolvedSymbol, type DraftPin } from './symsource.js';
|
|
4
4
|
import type { SchematicIntent, IntentNet, IntentPart, ValidatedIntent } from './ir.js';
|
|
5
5
|
|
|
@@ -14,20 +14,139 @@ import type { SchematicIntent, IntentNet, IntentPart, ValidatedIntent } from './
|
|
|
14
14
|
const U = 1.27;
|
|
15
15
|
/** Stub length from a pin to its label/power symbol, in grid units. */
|
|
16
16
|
const STUB = 2;
|
|
17
|
-
/** Cell margin around a symbol body (room for stubs, labels, text), in units
|
|
18
|
-
|
|
17
|
+
/** Cell margin around a symbol body (room for stubs, labels, text), in units,
|
|
18
|
+
* for the first draft; a measured round shrinks it to `MEASURED_MARGIN`. */
|
|
19
|
+
const BASE_MARGIN = 4;
|
|
20
|
+
/** Cell margin once the cell is sized from what it actually drew. */
|
|
21
|
+
const MEASURED_MARGIN = 2;
|
|
22
|
+
/** Vertical cell margin, units: rows are tighter than columns because a
|
|
23
|
+
* part's texts sit beside it, not above and below; a pin that faces up or
|
|
24
|
+
* down with a rail or ground on it reserves `POWER_REACH` instead. */
|
|
25
|
+
const VMARGIN = 2;
|
|
26
|
+
const POWER_REACH = 6;
|
|
27
|
+
/** Clear grid units kept beyond a cell's measured drawn extent. */
|
|
28
|
+
const MEASURE_PAD = 1;
|
|
29
|
+
/** What a cell drew beyond its body, mm on each side, measured on a finished draft. */
|
|
30
|
+
type Overhang = { left: number; right: number; top: number; bottom: number };
|
|
31
|
+
/** Padding, grid units, a group box keeps around the label and field text it
|
|
32
|
+
* encloses, and the clearance kept between two such boxes. */
|
|
33
|
+
const BOX_PAD = 1;
|
|
34
|
+
/** Grid units between two drawn group boxes, after the boxes have grown to
|
|
35
|
+
* their text: the one distance a reader sees between blocks. Equal to
|
|
36
|
+
* GROUP_GAP, the gap the wrap fits between rects, so a fit on measured
|
|
37
|
+
* boxes is exact. */
|
|
38
|
+
const BOX_LINE_GAP = 4;
|
|
39
|
+
/** How far a group's drawn box grew past the rect the wrap was fitted on,
|
|
40
|
+
* per side, in mm: measured by one draft, fitted by the next. */
|
|
41
|
+
type Reach = { left: number; right: number; top: number; bottom: number };
|
|
42
|
+
const BOX_GAP = 4;
|
|
19
43
|
/** Vertical gap between rows and horizontal channel between columns, units. */
|
|
20
|
-
const ROW_GAP =
|
|
21
|
-
const CHANNEL =
|
|
44
|
+
const ROW_GAP = 3;
|
|
45
|
+
const CHANNEL = 4;
|
|
22
46
|
/** Gap between group boxes, units. */
|
|
23
|
-
const GROUP_GAP =
|
|
24
|
-
/** Local nets up to this many endpoints may be wired (design D2)
|
|
47
|
+
const GROUP_GAP = 4;
|
|
48
|
+
/** Local nets up to this many endpoints may be wired (design D2); a larger
|
|
49
|
+
* cluster is narrowed to the endpoints bound to one anchor before routing. */
|
|
25
50
|
const MAX_WIRED_ENDPOINTS = 4;
|
|
51
|
+
/** Subsets of one cluster the wire pass tries to route before it gives up. */
|
|
52
|
+
const ROUTE_ATTEMPTS = 64;
|
|
53
|
+
/** Nodes the masonry wrap's deal search visits before it keeps the best deal found so far. */
|
|
54
|
+
const MASONRY_NODE_BUDGET = 200_000;
|
|
26
55
|
/** Wire-span budget in mm beyond which a net becomes labels. */
|
|
27
56
|
const MAX_WIRE_SPAN = 50.8;
|
|
28
57
|
/** Label text metrics, matching the legibility checker's conservative box. */
|
|
29
58
|
const LABEL_HEIGHT = 1.27;
|
|
30
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Advance the ENGINE gives text when it spaces groups, clears labels and
|
|
61
|
+
* fields of one another, and draws the box around a group. The checker's 0.6
|
|
62
|
+
* is tuned below KiCad's stroke font on purpose (design C3: never a false
|
|
63
|
+
* collision); measured on a plotted sheet the font advances about 0.72 of
|
|
64
|
+
* its height, so an engine that cleared text at 0.6 drew sheets where a rail
|
|
65
|
+
* name ran into the label on the next pin row while the checker called them
|
|
66
|
+
* clean. The engine is the side that must be conservative: it reserves 0.8.
|
|
67
|
+
*/
|
|
68
|
+
const LABEL_ADVANCE = 0.8;
|
|
69
|
+
const TEXT_RESERVE = LABEL_ADVANCE;
|
|
70
|
+
/** Advance of KiCad's stroke font for the upper-case pin names inside a
|
|
71
|
+
* body, per height: wider than the mixed-case reserve above. */
|
|
72
|
+
const NAME_ADVANCE = 1.0;
|
|
73
|
+
/** Air between any two texts, mm. Boxes that merely do not overlap still
|
|
74
|
+
* read as one smudge on paper (esp32-amp's "100k" sat 0.6 mm above
|
|
75
|
+
* "USB_OV"); every clearance check pads the candidate by this. */
|
|
76
|
+
const TEXT_PAD = 0.8;
|
|
77
|
+
const padBox = (b: Bounds, p = TEXT_PAD): Bounds => ({ minX: b.minX - p, minY: b.minY - p, maxX: b.maxX + p, maxY: b.maxY + p });
|
|
78
|
+
/** `COPPERHEAD_DRAFT_TRACE=1` prints every placement decision the engine
|
|
79
|
+
* makes silently: what it claimed, what it skipped and why, what it refused. */
|
|
80
|
+
const trace = (msg: string): void => {
|
|
81
|
+
if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') console.error(`[draft] ${msg}`);
|
|
82
|
+
};
|
|
83
|
+
/** `labelTextBox` at the reserve advance: what the text takes on paper. */
|
|
84
|
+
const labelReserveBox = (name: string, x: number, y: number, rot: number, kind: EmitLabel['kind'] = 'local'): Bounds =>
|
|
85
|
+
labelBoxAt(name, x, y, rot, kind, TEXT_RESERVE);
|
|
86
|
+
/**
|
|
87
|
+
* Along-axis length a global label's flag adds beyond its text: the margin
|
|
88
|
+
* either side of the text plus the pointed tip. Measured from eeschema's own
|
|
89
|
+
* outline at 1.27 mm (a 12-character name draws 17.0 mm long against about
|
|
90
|
+
* 14.5 of text), and kept a little generous because the advance estimate
|
|
91
|
+
* under-reads wide glyphs.
|
|
92
|
+
*/
|
|
93
|
+
const FLAG_PAD = 2.5 * LABEL_HEIGHT;
|
|
94
|
+
/** Grid units of the caption band at the top of a group box: the bold
|
|
95
|
+
* caption at `CAPTION_SIZE` plus clearance over the tallest thing a cell can
|
|
96
|
+
* carry above its body (a rail symbol on an upward stub with its value text). */
|
|
97
|
+
const CAPTION_BAND = 7;
|
|
98
|
+
/**
|
|
99
|
+
* The box a label occupies at `advance` per character. A local label is bare
|
|
100
|
+
* text standing on its anchor (see `labelTextBox`); a global label is a flag
|
|
101
|
+
* two text heights tall, centred on the anchor line, whose length runs away
|
|
102
|
+
* from the anchor in the rotation's direction — 0 right, 90 up, 180 left,
|
|
103
|
+
* 270 down in schematic Y-down coordinates, exactly as eeschema draws it.
|
|
104
|
+
*/
|
|
105
|
+
const labelBoxAt = (name: string, x: number, y: number, rot: number, kind: EmitLabel['kind'], advance: number): Bounds => {
|
|
106
|
+
const textW = Math.max(1, name.length) * advance * LABEL_HEIGHT;
|
|
107
|
+
if (kind === 'global') {
|
|
108
|
+
const w = textW + FLAG_PAD;
|
|
109
|
+
const h = LABEL_HEIGHT;
|
|
110
|
+
const r = ((rot % 360) + 360) % 360;
|
|
111
|
+
if (r === 90) return { minX: x - h, minY: y - w, maxX: x + h, maxY: y };
|
|
112
|
+
if (r === 270) return { minX: x - h, minY: y, maxX: x + h, maxY: y + w };
|
|
113
|
+
if (r === 180) return { minX: x - w, minY: y - h, maxX: x, maxY: y + h };
|
|
114
|
+
return { minX: x, minY: y - h, maxX: x + w, maxY: y + h };
|
|
115
|
+
}
|
|
116
|
+
// A plain label STANDS on its anchor line: the checker measures it that way
|
|
117
|
+
// (`labelBounds`), and so does the engine. The union box that also reached
|
|
118
|
+
// half a height below the line dated from the checker's centred days; kept,
|
|
119
|
+
// it made every name on a row collide with that row's own continuing wire
|
|
120
|
+
// (esp32-amp's UART_TXD could stand nowhere on its 9 mm run).
|
|
121
|
+
return rot === 180
|
|
122
|
+
? { minX: x - textW, minY: y - LABEL_HEIGHT, maxX: x, maxY: y }
|
|
123
|
+
: { minX: x, minY: y - LABEL_HEIGHT, maxX: x + textW, maxY: y };
|
|
124
|
+
};
|
|
125
|
+
/** KiCad's label rotation for text running outward along a stub direction. */
|
|
126
|
+
const rotOutward = (o: { dx: number; dy: number }): number => (o.dx === -1 ? 180 : o.dy === -1 ? 90 : o.dy === 1 ? 270 : 0);
|
|
127
|
+
/**
|
|
128
|
+
* Flag shape for a global label at a pin, from the pin's electrical type:
|
|
129
|
+
* an output pin's net leaves through an output flag, an input's arrives
|
|
130
|
+
* through an input flag, a bidirectional or tri-state pin's points both
|
|
131
|
+
* ways, and everything else (passive parts, power, unspecified) gets the
|
|
132
|
+
* plain box. Cosmetic to KiCad's ERC, informative to a reader.
|
|
133
|
+
*/
|
|
134
|
+
export function flagShape(etype: string): LabelShape {
|
|
135
|
+
switch (etype) {
|
|
136
|
+
case 'output':
|
|
137
|
+
case 'open_collector':
|
|
138
|
+
case 'open_emitter':
|
|
139
|
+
case 'power_out':
|
|
140
|
+
return 'output';
|
|
141
|
+
case 'input':
|
|
142
|
+
return 'input';
|
|
143
|
+
case 'bidirectional':
|
|
144
|
+
case 'tri_state':
|
|
145
|
+
return 'bidirectional';
|
|
146
|
+
default:
|
|
147
|
+
return 'passive';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
31
150
|
/** How far a colliding label may ride its stub outward, in grid units.
|
|
32
151
|
* Deep enough to carry a bottom-pin label past the routing channel that runs
|
|
33
152
|
* under its connector (#220 phase 2); rungs stay ordered nearest-first, so a
|
|
@@ -78,6 +197,8 @@ const PAPERS: { name: string; w: number; h: number }[] = [
|
|
|
78
197
|
];
|
|
79
198
|
const FRAME = 10;
|
|
80
199
|
const TITLE_STRIP = 30;
|
|
200
|
+
/** The title block's width along the bottom edge, as the checker measures it. */
|
|
201
|
+
const TITLE_BLOCK_W = 110;
|
|
81
202
|
/** Max pin-to-pin gap, grid units, for chaining a passive bank on one trunk
|
|
82
203
|
* (#233): wide enough for two-pin parts sitting in adjacent COLUMNS (cell
|
|
83
204
|
* width plus the channel, ~23 units), tight enough that a trunk never spans
|
|
@@ -107,6 +228,28 @@ export interface SchematicDraftReport {
|
|
|
107
228
|
pwrFlags: string[];
|
|
108
229
|
noConnects: number;
|
|
109
230
|
paper: string;
|
|
231
|
+
/**
|
|
232
|
+
* The paper pass's verdict, for callers that gate on it rather than parse
|
|
233
|
+
* the notes: which sheet, how much of it the placed cells ink, and whether
|
|
234
|
+
* compaction ran, succeeded, or failed (with each smaller sheet's closest
|
|
235
|
+
* miss when it did). `pinned` is a paper hint; `overflow` means nothing
|
|
236
|
+
* holds the content and the frame will be crossed; `banded` means no sheet
|
|
237
|
+
* held the natural ribbon and columns were wrapped to fit the smallest one.
|
|
238
|
+
*/
|
|
239
|
+
sheetFit: {
|
|
240
|
+
paper: string;
|
|
241
|
+
inkUtilization: number;
|
|
242
|
+
compaction: 'not-needed' | 'compacted' | 'banded' | 'failed' | 'pinned' | 'overflow';
|
|
243
|
+
misses: string[];
|
|
244
|
+
/** The squeeze: cells re-sized from what they drew, round by round. */
|
|
245
|
+
squeeze?: { rounds: number; boxAreaBefore: number; boxAreaAfter: number };
|
|
246
|
+
/**
|
|
247
|
+
* The look: a wrapped sheet drafted again with each group's label reach
|
|
248
|
+
* measured from the first draft instead of estimated. `paperBefore` is
|
|
249
|
+
* the first draft's sheet, `paperAfter` the sheet kept.
|
|
250
|
+
*/
|
|
251
|
+
look?: { kept: boolean; paperBefore: string; paperAfter: string };
|
|
252
|
+
};
|
|
110
253
|
notes: string[];
|
|
111
254
|
/**
|
|
112
255
|
* Points where labels of two or more distinct nets landed together, merging
|
|
@@ -324,8 +467,45 @@ interface Placed {
|
|
|
324
467
|
body: Bounds; // schematic space, absolute
|
|
325
468
|
cellW: number; // units
|
|
326
469
|
cellH: number; // units
|
|
470
|
+
/** Placement rotation, degrees CCW in symbol space (KiCad's `(at x y rot)`); `sym` is already rotated. */
|
|
471
|
+
rot: number;
|
|
472
|
+
/** KiCad `(mirror y)`: the symbol flipped left-for-right before rotation (a
|
|
473
|
+
* transistor whose base must face the pin that drives it); `sym` is already mirrored. */
|
|
474
|
+
mirror?: 'y';
|
|
327
475
|
}
|
|
328
476
|
|
|
477
|
+
/** `sym` mirrored left-for-right (KiCad's `(mirror y)`), then turned by `rot`. */
|
|
478
|
+
const transformSym = (sym: ResolvedSymbol, rot: number, mirror?: 'y'): ResolvedSymbol => {
|
|
479
|
+
if (!mirror) return rotatedSym(sym, rot);
|
|
480
|
+
const pins = sym.pins.map((p) => ({ ...p, x: -p.x, angle: (((180 - p.angle) % 360) + 360) % 360 }));
|
|
481
|
+
const b = sym.body ? bodyBoundsOf(sym) : null;
|
|
482
|
+
const body = b ? { minX: -b.maxX, maxX: -b.minX, minY: b.minY, maxY: b.maxY } : null;
|
|
483
|
+
return rotatedSym({ ...sym, pins, body }, rot);
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The symbol as KiCad draws it at rotation `rot` (0, 90, 180, 270): pins,
|
|
488
|
+
* their direction angles and the body box all turned in symbol space, so
|
|
489
|
+
* every downstream helper (pinAt, outward, bodyBoundsOf) reads the drawn
|
|
490
|
+
* geometry without knowing the part was turned. Matches `pinAbsolute`:
|
|
491
|
+
* rx = x cos − y sin, ry = x sin + y cos.
|
|
492
|
+
*/
|
|
493
|
+
const rotatedSym = (sym: ResolvedSymbol, rot: number): ResolvedSymbol => {
|
|
494
|
+
const r = ((rot % 360) + 360) % 360;
|
|
495
|
+
if (r === 0) return sym;
|
|
496
|
+
const turn = (x: number, y: number): { x: number; y: number } =>
|
|
497
|
+
r === 90 ? { x: -y, y: x } : r === 180 ? { x: -x, y: -y } : { x: y, y: -x };
|
|
498
|
+
const pins = sym.pins.map((p) => ({ ...p, ...turn(p.x, p.y), angle: (p.angle + r) % 360 }));
|
|
499
|
+
const b = sym.body ? bodyBoundsOf(sym) : null;
|
|
500
|
+
let body: Bounds | null = null;
|
|
501
|
+
if (b) {
|
|
502
|
+
const c1 = turn(b.minX, b.minY);
|
|
503
|
+
const c2 = turn(b.maxX, b.maxY);
|
|
504
|
+
body = { minX: Math.min(c1.x, c2.x), maxX: Math.max(c1.x, c2.x), minY: Math.min(c1.y, c2.y), maxY: Math.max(c1.y, c2.y) };
|
|
505
|
+
}
|
|
506
|
+
return { ...sym, pins, body };
|
|
507
|
+
};
|
|
508
|
+
|
|
329
509
|
/**
|
|
330
510
|
* One placeable thing. A single-unit part is one instance whose key IS its
|
|
331
511
|
* refdes. A multi-unit part (an opamp, a gate pack) becomes one instance per
|
|
@@ -351,6 +531,46 @@ const bodyBoundsOf = (sym: ResolvedSymbol): Bounds => {
|
|
|
351
531
|
return { minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys) };
|
|
352
532
|
};
|
|
353
533
|
|
|
534
|
+
/**
|
|
535
|
+
* Wire and label colours by function: rails one colour, grounds another,
|
|
536
|
+
* and every FAMILY of signal nets — the nets sharing a prefix before the
|
|
537
|
+
* first underscore, when there are at least two of them (I2S_BCLK, I2S_DIN,
|
|
538
|
+
* I2S_LRCLK; SPI_…; BTN_…) — a colour of its own from a fixed palette, in
|
|
539
|
+
* family-name order so the same design colours the same way every time. A
|
|
540
|
+
* lone signal keeps the theme's default: colouring everything in a block
|
|
541
|
+
* one colour would say nothing.
|
|
542
|
+
*/
|
|
543
|
+
const FAMILY_PALETTE: [number, number, number][] = [
|
|
544
|
+
[30, 90, 200],
|
|
545
|
+
[130, 50, 170],
|
|
546
|
+
[0, 130, 120],
|
|
547
|
+
[210, 120, 0],
|
|
548
|
+
[180, 30, 140],
|
|
549
|
+
[110, 130, 0],
|
|
550
|
+
[140, 80, 40],
|
|
551
|
+
[40, 60, 140],
|
|
552
|
+
];
|
|
553
|
+
const RAIL_COLOR: [number, number, number] = [170, 30, 30];
|
|
554
|
+
const GROUND_COLOR: [number, number, number] = [70, 70, 70];
|
|
555
|
+
function netColorsOf(nets: IntentNet[], classes: Map<string, { cls: NetClass }>): Record<string, [number, number, number]> {
|
|
556
|
+
const out: Record<string, [number, number, number]> = {};
|
|
557
|
+
const families = new Map<string, string[]>();
|
|
558
|
+
for (const n of nets) {
|
|
559
|
+
const cls = classes.get(n.name)?.cls ?? 'signal';
|
|
560
|
+
if (cls === 'rail') out[n.name] = RAIL_COLOR;
|
|
561
|
+
else if (cls === 'ground') out[n.name] = GROUND_COLOR;
|
|
562
|
+
else {
|
|
563
|
+
const m = /^([A-Za-z0-9]+)_/.exec(n.name);
|
|
564
|
+
if (m) families.set(m[1]!, [...(families.get(m[1]!) ?? []), n.name]);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const named = [...families.entries()].filter(([, members]) => members.length >= 2).sort((a, b) => a[0].localeCompare(b[0]));
|
|
568
|
+
named.forEach(([, members], i) => {
|
|
569
|
+
for (const name of members) out[name] = FAMILY_PALETTE[i % FAMILY_PALETTE.length]!;
|
|
570
|
+
});
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
|
|
354
574
|
/** Pin connection point in schematic space for a part placed at (x, y), rot 0. */
|
|
355
575
|
const pinAt = (p: Placed, pin: DraftPin): { x: number; y: number } => ({ x: p.x + pin.x, y: p.y - pin.y });
|
|
356
576
|
|
|
@@ -410,13 +630,58 @@ const boundsOverlap = (a: Bounds, b: Bounds): boolean =>
|
|
|
410
630
|
* conservative metrics. Shared by the de-collision pass and the overlap report
|
|
411
631
|
* so "clear" means one thing in the engine.
|
|
412
632
|
*/
|
|
413
|
-
const labelTextBox = (name: string, x: number, y: number, rot: number): Bounds =>
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
633
|
+
const labelTextBox = (name: string, x: number, y: number, rot: number, kind: EmitLabel['kind'] = 'local'): Bounds =>
|
|
634
|
+
// A local label is bottom-justified on its anchor: on paper the text stands
|
|
635
|
+
// on the wire line and rises one full height above it, and the checker's
|
|
636
|
+
// `labelBounds` measures exactly that. A global label's flag is measured as
|
|
637
|
+
// eeschema draws it (`labelBoxAt`).
|
|
638
|
+
labelBoxAt(name, x, y, rot, kind, LABEL_ADVANCE);
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Where a wired run's name may sit when it must be a global flag, in
|
|
642
|
+
* preference order: standing up from the top of a vertical trunk (the flag on
|
|
643
|
+
* a pole a reviewer expects), then every other wire end continuing its
|
|
644
|
+
* segment outward, then the interior grid points of each segment with the
|
|
645
|
+
* flag perpendicular to the wire, upward before downward. Every candidate
|
|
646
|
+
* lies on a wire of the run, so the name always attaches.
|
|
647
|
+
*/
|
|
648
|
+
export function wiredFlagCandidates(segs: Seg[]): { x: number; y: number; rot: number }[] {
|
|
649
|
+
const out: { x: number; y: number; rot: number }[] = [];
|
|
650
|
+
const seen = new Set<string>();
|
|
651
|
+
const push = (x: number, y: number, rot: number): void => {
|
|
652
|
+
const k = `${pointKey(x, y)}/${rot}`;
|
|
653
|
+
if (seen.has(k)) return;
|
|
654
|
+
seen.add(k);
|
|
655
|
+
out.push({ x, y, rot });
|
|
656
|
+
};
|
|
657
|
+
const vertical = segs.filter((s) => sameCoord(s.x1, s.x2));
|
|
658
|
+
const horizontal = segs.filter((s) => !sameCoord(s.x1, s.x2));
|
|
659
|
+
// Horizontal first: a name reads along the row it names (the drafting
|
|
660
|
+
// standard keeps text horizontal), so the ends of horizontal segments
|
|
661
|
+
// continuing outward come before a flag standing up from a trunk top, and
|
|
662
|
+
// both before a flag perpendicular to a wire's interior.
|
|
663
|
+
const ends = segs.flatMap((s) => [{ x: s.x1, y: s.y1 }, { x: s.x2, y: s.y2 }]).sort((a, b) => a.y - b.y || a.x - b.x);
|
|
664
|
+
for (const p of ends) {
|
|
665
|
+
for (const s of horizontal) {
|
|
666
|
+
if (!sameCoord(s.y1, p.y)) continue;
|
|
667
|
+
if (sameCoord(p.x, Math.max(s.x1, s.x2))) push(p.x, p.y, 0);
|
|
668
|
+
if (sameCoord(p.x, Math.min(s.x1, s.x2))) push(p.x, p.y, 180);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const tops = vertical.map((s) => ({ x: s.x1, y: Math.min(s.y1, s.y2) })).sort((a, b) => a.y - b.y || a.x - b.x);
|
|
672
|
+
for (const t of tops) push(t.x, t.y, 90);
|
|
673
|
+
for (const p of ends) {
|
|
674
|
+
for (const s of vertical) {
|
|
675
|
+
if (sameCoord(s.x1, p.x) && sameCoord(p.y, Math.max(s.y1, s.y2))) push(p.x, p.y, 270);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
for (const s of segs) {
|
|
679
|
+
for (const rot of sameCoord(s.x1, s.x2) ? [0, 180] : [90, 270]) {
|
|
680
|
+
for (const p of interiorGridPoints([s], U)) push(p.x, p.y, rot);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return out;
|
|
684
|
+
}
|
|
420
685
|
|
|
421
686
|
/**
|
|
422
687
|
* Pairs of labels naming DIFFERENT nets whose text boxes overlap.
|
|
@@ -427,10 +692,10 @@ const labelTextBox = (name: string, x: number, y: number, rot: number): Bounds =
|
|
|
427
692
|
* colliding label position, nets sorted, so the same pair is not listed twice.
|
|
428
693
|
*/
|
|
429
694
|
export function findLabelOverlaps(
|
|
430
|
-
labels: { name: string; x: number; y: number; rot: number }[],
|
|
695
|
+
labels: { name: string; x: number; y: number; rot: number; kind?: EmitLabel['kind'] }[],
|
|
431
696
|
): { x: number; y: number; nets: string[] }[] {
|
|
432
697
|
const out = new Map<string, { x: number; y: number; nets: Set<string> }>();
|
|
433
|
-
const boxes = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot));
|
|
698
|
+
const boxes = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
|
|
434
699
|
for (let i = 0; i < labels.length; i++) {
|
|
435
700
|
for (let j = i + 1; j < labels.length; j++) {
|
|
436
701
|
const a = labels[i]!;
|
|
@@ -461,7 +726,182 @@ const segCrossesBody = (x1: number, y1: number, x2: number, y2: number, b: Bound
|
|
|
461
726
|
return inX && inY; // conservative for diagonals (the engine never draws them)
|
|
462
727
|
};
|
|
463
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Draft, then check the drawn group boxes against one another: a box grows
|
|
731
|
+
* past its tiling estimate when the text it must hold (a flag on a hung
|
|
732
|
+
* part, a rail name at the edge) reaches further than the pin labels the
|
|
733
|
+
* estimate counted, and two boxes then intersect (usb-atmega-node's Status
|
|
734
|
+
* over IO Headers). Each overlap widens the right-hand group's left reserve
|
|
735
|
+
* by the overlap and the sheet is drafted again; placement is a pure
|
|
736
|
+
* function of the intent and these reserves, so the result stays
|
|
737
|
+
* deterministic. Three rounds bound the cost.
|
|
738
|
+
*/
|
|
464
739
|
export function draftSchematicPlacement(validated: ValidatedIntent, projectName: string, today: string): { model: PlacementModel; report: SchematicDraftReport } {
|
|
740
|
+
const reserves = new Map<string, { left: number; right: number }>();
|
|
741
|
+
let frameSlack = 0;
|
|
742
|
+
let wrapGap = 0;
|
|
743
|
+
// The squeeze. The first draft sizes every cell by rule (a margin a side, a
|
|
744
|
+
// power symbol's worth under every hung part, a label beside every pin) and
|
|
745
|
+
// draws into that room; most of the room stays empty. Each further round
|
|
746
|
+
// sizes the cells from what the previous round actually drew — body,
|
|
747
|
+
// wires, labels, power symbols, field text of the cell and of everything
|
|
748
|
+
// hung on it — plus a pad, and draws again. Rounds continue while the
|
|
749
|
+
// group boxes shrink and the engine's own gates hold (no merged nets, the
|
|
750
|
+
// label-overlap budget kept); the smallest passing sheet is the result.
|
|
751
|
+
// This is the step a drafter does by eye after placing: look, then pull
|
|
752
|
+
// things together.
|
|
753
|
+
let measured: Map<string, Overhang> | undefined;
|
|
754
|
+
let best: { model: PlacementModel; report: SchematicDraftReport; area: number; refusals: number; paperIdx: number } | null = null;
|
|
755
|
+
let squeezeRounds = 0;
|
|
756
|
+
let areaBefore = 0;
|
|
757
|
+
const boxArea = (rects: { x1: number; y1: number; x2: number; y2: number }[]): number => rects.reduce((a, r) => a + (r.x2 - r.x1) * (r.y2 - r.y1), 0);
|
|
758
|
+
let retries = 0;
|
|
759
|
+
// The look. Before any wrap is fitted the engine can only estimate how far
|
|
760
|
+
// a group's labels reach past its cells, and it estimates generously: a
|
|
761
|
+
// label's width beside every pin that might carry one. Those reserves
|
|
762
|
+
// decide how many groups share a row or column, so a wrapped sheet is
|
|
763
|
+
// drafted once more with the reach every box was actually measured to
|
|
764
|
+
// take, and that draft is kept when it fits the same or a smaller sheet
|
|
765
|
+
// with the gates still holding.
|
|
766
|
+
let reachMeasured: Map<string, Reach> | undefined;
|
|
767
|
+
let look: 'not-yet' | 'drafting' | 'done' = 'not-yet';
|
|
768
|
+
let lookVerdict: { kept: boolean; paperBefore: string; paperAfter: string } | undefined;
|
|
769
|
+
for (let round = 0; round < 16; round++) {
|
|
770
|
+
const { model, report, rects, measured: nextMeasured, reach, wrapped } = draftOnce(validated, projectName, today, reserves, frameSlack, measured, wrapGap, reachMeasured);
|
|
771
|
+
let widened = false;
|
|
772
|
+
const stillWrong: string[] = [];
|
|
773
|
+
// A row or column wrapped to the full usable width leaves no room for the
|
|
774
|
+
// text its boxes grow to hold afterwards, and a box then crosses the
|
|
775
|
+
// frame (usb-atmega-node's IO Headers on A3). Any box past the frame
|
|
776
|
+
// shrinks the usable frame by the overflow and the sheet is drafted again.
|
|
777
|
+
const paper = PAPERS.find((p) => p.name === report.sheetFit.paper);
|
|
778
|
+
if (paper) {
|
|
779
|
+
const over = Math.max(0, ...rects.flatMap((r) => [FRAME - r.x1, r.x2 - (paper.w - FRAME), FRAME - r.y1, r.y2 - (paper.h - FRAME)]));
|
|
780
|
+
if (over > 0.01) {
|
|
781
|
+
frameSlack += over + U;
|
|
782
|
+
widened = true;
|
|
783
|
+
stillWrong.push(`a box crosses the frame by ${over.toFixed(1)} mm`);
|
|
784
|
+
trace(`a group box crosses the frame by ${over.toFixed(2)} mm; the usable frame shrinks by ${frameSlack.toFixed(2)} mm and the sheet is drafted again`);
|
|
785
|
+
}
|
|
786
|
+
// A sheet fitted into the title strip was judged on its group rects
|
|
787
|
+
// before the boxes grew to their text; a box that grew into the title
|
|
788
|
+
// block's own corner (as the checker reserves it, narrowed on small
|
|
789
|
+
// pages) overflows the usable height by how far it reaches in.
|
|
790
|
+
const cornerX = paper.w - FRAME - Math.min(TITLE_BLOCK_W, (paper.w - 2 * FRAME) / 2);
|
|
791
|
+
const cornerY = paper.h - FRAME - Math.min(TITLE_STRIP, (paper.h - 2 * FRAME) / 4);
|
|
792
|
+
const into = Math.max(0, ...rects.map((r) => (r.x2 > cornerX + 0.01 && r.y2 > cornerY + 0.01 ? r.y2 - cornerY : 0)));
|
|
793
|
+
if (over <= 0.01 && into > 0.01) {
|
|
794
|
+
frameSlack += into + U;
|
|
795
|
+
widened = true;
|
|
796
|
+
stillWrong.push(`a box enters the title block's corner by ${into.toFixed(1)} mm`);
|
|
797
|
+
trace(`a group box enters the title block's corner by ${into.toFixed(2)} mm; the usable frame shrinks by ${frameSlack.toFixed(2)} mm and the sheet is drafted again`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
for (let i = 0; i < rects.length; i++) {
|
|
801
|
+
for (let j = i + 1; j < rects.length; j++) {
|
|
802
|
+
const a = rects[i]!;
|
|
803
|
+
const b = rects[j]!;
|
|
804
|
+
const ox = Math.min(a.x2, b.x2) - Math.max(a.x1, b.x1);
|
|
805
|
+
const oy = Math.min(a.y2, b.y2) - Math.max(a.y1, b.y1);
|
|
806
|
+
if (ox <= 0.01 || oy <= 0.01) continue;
|
|
807
|
+
widened = true;
|
|
808
|
+
stillWrong.push(`"${a.name}" and "${b.name}" intersect`);
|
|
809
|
+
if (oy < ox) {
|
|
810
|
+
// boxes in different rows (or columns) touching along the wrap
|
|
811
|
+
// gap: widen the gap between wrapped rows and columns
|
|
812
|
+
wrapGap += oy + U;
|
|
813
|
+
trace(`group boxes "${a.name}" and "${b.name}" overlap by ${oy.toFixed(2)} mm across the wrap; the wrap gap grows to ${wrapGap.toFixed(2)} mm and the sheet is drafted again`);
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const right = a.x1 < b.x1 ? b : a;
|
|
817
|
+
const r = reserves.get(right.name) ?? { left: 0, right: 0 };
|
|
818
|
+
r.left += ox + U;
|
|
819
|
+
reserves.set(right.name, r);
|
|
820
|
+
trace(`group boxes "${a.name}" and "${b.name}" overlap by ${ox.toFixed(2)} mm; "${right.name}" reserves ${r.left.toFixed(2)} mm more on its left and the sheet is drafted again`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (widened && retries < 3) {
|
|
824
|
+
retries++;
|
|
825
|
+
continue; // the same cells, re-tiled: not a squeeze round
|
|
826
|
+
}
|
|
827
|
+
const area = boxArea(rects);
|
|
828
|
+
const refusals = report.notes.filter((n) => /refused/.test(n)).length;
|
|
829
|
+
const paperIdx = PAPERS.findIndex((p) => p.name === report.paper);
|
|
830
|
+
// a round whose boxes still overlap or cross the frame after three
|
|
831
|
+
// re-tilings is not a sheet to keep; nor one that lost a hung part, or
|
|
832
|
+
// needs a larger sheet, or shrank by less than three percent
|
|
833
|
+
const passes = !widened && report.mergedNets.length === 0 && !report.labelOverlapBudgetExceeded;
|
|
834
|
+
if (widened) report.notes.push(`group boxes still wrong after ${retries} re-tilings (${stillWrong.join('; ')}); the sheet is drawn as it stands`);
|
|
835
|
+
retries = 0;
|
|
836
|
+
reserves.clear();
|
|
837
|
+
frameSlack = 0;
|
|
838
|
+
wrapGap = 0;
|
|
839
|
+
if (!best) {
|
|
840
|
+
areaBefore = area;
|
|
841
|
+
best = { model, report, area, refusals, paperIdx };
|
|
842
|
+
if (look === 'not-yet' && wrapped && passes) {
|
|
843
|
+
look = 'drafting';
|
|
844
|
+
reachMeasured = reach;
|
|
845
|
+
trace(`box reach measured: ${[...reach].map(([g, r]) => `${g.slice(0, 2).trim()} ${r.left.toFixed(1)}/${r.right.toFixed(1)} ${r.top.toFixed(1)}/${r.bottom.toFixed(1)}`).join(', ')}; the wrap is fitted again on it`);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
} else if (look === 'drafting') {
|
|
849
|
+
look = 'done';
|
|
850
|
+
if (passes && refusals <= best.refusals && (paperIdx < best.paperIdx || (paperIdx === best.paperIdx && area <= best.area))) {
|
|
851
|
+
trace(`measured-reach draft kept: ${report.paper}, boxes ${area.toFixed(0)} mm² (was ${best.report.paper}, ${best.area.toFixed(0)} mm²)`);
|
|
852
|
+
lookVerdict = { kept: true, paperBefore: best.report.paper, paperAfter: report.paper };
|
|
853
|
+
if (paperIdx < best.paperIdx) report.notes.push(`label reach measured: the wrap fitted again takes ${report.paper}, not ${best.report.paper}`);
|
|
854
|
+
areaBefore = area;
|
|
855
|
+
best = { model, report, area, refusals, paperIdx };
|
|
856
|
+
} else {
|
|
857
|
+
trace(`measured-reach draft not kept: ${passes ? `${report.paper}, boxes ${area.toFixed(0)} mm²` : 'gates failed'} against ${best.report.paper}, ${best.area.toFixed(0)} mm²`);
|
|
858
|
+
lookVerdict = { kept: false, paperBefore: best.report.paper, paperAfter: best.report.paper };
|
|
859
|
+
reachMeasured = undefined;
|
|
860
|
+
}
|
|
861
|
+
} else if (passes && refusals <= best.refusals && paperIdx <= best.paperIdx && area < best.area * 0.97) {
|
|
862
|
+
best = { model, report, area, refusals, paperIdx };
|
|
863
|
+
} else {
|
|
864
|
+
trace(`squeeze round ${squeezeRounds}: ${passes ? `boxes ${area.toFixed(0)} mm² did not shrink` : 'gates failed'}; keeping the previous round`);
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
// Rounds beyond the first run only when asked for: on the boards drafted
|
|
868
|
+
// so far the rules were within a few percent of the drawing, and the
|
|
869
|
+
// extra drafts cost time and re-pin every fixture. The measurement and
|
|
870
|
+
// the report are always made.
|
|
871
|
+
if (squeezeRounds >= 3 || process.env['COPPERHEAD_DRAFT_SQUEEZE'] !== '1') break;
|
|
872
|
+
measured = nextMeasured;
|
|
873
|
+
squeezeRounds++;
|
|
874
|
+
trace(`squeeze round ${squeezeRounds}: cells measured, group boxes ${area.toFixed(0)} mm²; drafting again`);
|
|
875
|
+
}
|
|
876
|
+
const out = best!;
|
|
877
|
+
out.report.sheetFit.squeeze = { rounds: squeezeRounds, boxAreaBefore: Math.round(areaBefore), boxAreaAfter: Math.round(out.area) };
|
|
878
|
+
if (lookVerdict) out.report.sheetFit.look = lookVerdict;
|
|
879
|
+
if (squeezeRounds > 0) {
|
|
880
|
+
out.report.notes.push(`cells measured: group boxes ${Math.round(areaBefore / 1000)} k mm² to ${Math.round(out.area / 1000)} k mm² over ${squeezeRounds} round(s) on ${out.report.paper}`);
|
|
881
|
+
}
|
|
882
|
+
return { model: out.model, report: out.report };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function draftOnce(
|
|
886
|
+
validated: ValidatedIntent,
|
|
887
|
+
projectName: string,
|
|
888
|
+
today: string,
|
|
889
|
+
reserves: Map<string, { left: number; right: number }>,
|
|
890
|
+
frameSlack = 0,
|
|
891
|
+
measured?: Map<string, Overhang>,
|
|
892
|
+
wrapGap = 0,
|
|
893
|
+
reachMeasured?: Map<string, Reach>,
|
|
894
|
+
): {
|
|
895
|
+
model: PlacementModel;
|
|
896
|
+
report: SchematicDraftReport;
|
|
897
|
+
rects: { name: string; x1: number; y1: number; x2: number; y2: number }[];
|
|
898
|
+
measured: Map<string, Overhang>;
|
|
899
|
+
reach: Map<string, Reach>;
|
|
900
|
+
wrapped: boolean;
|
|
901
|
+
} {
|
|
902
|
+
// a measured round keeps only a small margin: the measured overhang says
|
|
903
|
+
// where the drawing actually reaches
|
|
904
|
+
const MARGIN = measured ? MEASURED_MARGIN : BASE_MARGIN;
|
|
465
905
|
const { intent, symbols, docGroups } = validated;
|
|
466
906
|
const notes: string[] = [];
|
|
467
907
|
|
|
@@ -501,6 +941,17 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
501
941
|
}));
|
|
502
942
|
});
|
|
503
943
|
const instByKey = new Map(instances.map((i) => [i.key, i]));
|
|
944
|
+
/** The pin that controls a transistor (base or gate), or null for anything else. */
|
|
945
|
+
const controlPinOf = (key: string): DraftPin | null => {
|
|
946
|
+
const inst = instByKey.get(key);
|
|
947
|
+
if (!inst || inst.sym.pins.length !== 3) return null;
|
|
948
|
+
if (!/^Transistor|^Device:Q_|^Q\d/.test(inst.part.libId) && !/^Q\d/.test(inst.ref)) return null;
|
|
949
|
+
return inst.sym.pins.find((p) => /^(B|G|Base|Gate)$/i.test(p.name)) ?? null;
|
|
950
|
+
};
|
|
951
|
+
const isTransistor = (key: string): boolean => controlPinOf(key) !== null;
|
|
952
|
+
/** Switches are the anchors of a button block: the pull-up, the debounce
|
|
953
|
+
* cap and the ESD diode all hang on the switch's signal pin. */
|
|
954
|
+
const isSwitch = (key: string): boolean => /^Switch[:_]|^Device:SW_|^Button/i.test(instByKey.get(key)?.part.libId ?? '');
|
|
504
955
|
/** Placed instances per refdes, for expanding a common pin's endpoint. */
|
|
505
956
|
const instancesOfRef = new Map<string, Instance[]>();
|
|
506
957
|
for (const inst of instances) instancesOfRef.set(inst.ref, [...(instancesOfRef.get(inst.ref) ?? []), inst]);
|
|
@@ -595,12 +1046,41 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
595
1046
|
let right = 0;
|
|
596
1047
|
for (const key of keys) {
|
|
597
1048
|
const inst = instByKey.get(key);
|
|
1049
|
+
if (!inst) continue;
|
|
1050
|
+
// a part with pins only on its top and bottom (a vertical R or C)
|
|
1051
|
+
// carries its reference and value beside it on the right
|
|
1052
|
+
if (inst.sym.pins.every((pin) => outward(pin).dx === 0)) {
|
|
1053
|
+
const fieldW = Math.max(inst.ref.length + 1, inst.part.value.length) * TEXT_RESERVE * LABEL_HEIGHT;
|
|
1054
|
+
right = Math.max(right, 1.27 + fieldW + 1.27);
|
|
1055
|
+
}
|
|
598
1056
|
for (const pin of inst?.sym.pins ?? []) {
|
|
599
|
-
const net = signalNetOfPin.get(`${inst!.ref}.${pin.number}`);
|
|
600
|
-
if (!net) continue;
|
|
601
1057
|
const o = outward(pin);
|
|
602
1058
|
if (o.dx === 0) continue;
|
|
603
|
-
const
|
|
1059
|
+
const ep = `${inst!.ref}.${pin.number}`;
|
|
1060
|
+
const signal = signalNetOfPin.get(ep);
|
|
1061
|
+
const power = signal ? undefined : netByEndpoint.get(ep);
|
|
1062
|
+
if (!signal && !power) continue;
|
|
1063
|
+
// a small signal net whose every endpoint is in this group is wired,
|
|
1064
|
+
// not labelled: no flag reaches out from it (counting one for every
|
|
1065
|
+
// pin put 27 mm between groups whose facing sides carry no label). A
|
|
1066
|
+
// larger in-group net may still end in stub labels, so it keeps its
|
|
1067
|
+
// reserve.
|
|
1068
|
+
if (signal) {
|
|
1069
|
+
const net = netByEndpoint.get(ep);
|
|
1070
|
+
const others = (net?.pins ?? []).filter((other) => other !== ep).map((other) => /^([^.]+)\./.exec(other)?.[1] ?? '');
|
|
1071
|
+
const leaves = !net || net.pins.length > MAX_WIRED_ENDPOINTS || others.some((ref) => partByRef.get(ref)?.group !== inst!.part.group);
|
|
1072
|
+
// only when every other endpoint is a two-lead part (or a test
|
|
1073
|
+
// point): those hang on the pin and are wired by construction; two
|
|
1074
|
+
// ICs of one group may still sit past wire span and take labels
|
|
1075
|
+
const hangs = others.every((ref) => (symbols.get(ref)?.pins.length ?? 3) <= 2);
|
|
1076
|
+
if (!leaves && hangs) continue;
|
|
1077
|
+
}
|
|
1078
|
+
// a signal: stub plus the label's flag (text, margins and tip); a
|
|
1079
|
+
// rail or ground on a sideways pin: the longer power stub, the bar,
|
|
1080
|
+
// and the name drawn outward in line
|
|
1081
|
+
const extent = signal
|
|
1082
|
+
? STUB * U + Math.max(1, signal.length) * TEXT_RESERVE * LABEL_HEIGHT + FLAG_PAD
|
|
1083
|
+
: (STUB + 2) * U + 1.905 + Math.max(1, power!.name.length) * TEXT_RESERVE * LABEL_HEIGHT;
|
|
604
1084
|
if (o.dx === -1) left = Math.max(left, extent);
|
|
605
1085
|
else right = Math.max(right, extent);
|
|
606
1086
|
}
|
|
@@ -608,9 +1088,10 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
608
1088
|
return { left, right };
|
|
609
1089
|
};
|
|
610
1090
|
/** Extra gap units so facing label text fits a boundary whose body-to-body
|
|
611
|
-
* clearance is `baseUnits
|
|
1091
|
+
* clearance is `baseUnits`: the two texts, the box padding each group draws
|
|
1092
|
+
* around its own text (`BOX_PAD` a side), and the gap between the boxes. */
|
|
612
1093
|
const widenBy = (rightOfPrev: number, leftOfNext: number, baseUnits: number): number =>
|
|
613
|
-
Math.max(0, ceilU(rightOfPrev + leftOfNext) +
|
|
1094
|
+
Math.max(0, ceilU(rightOfPrev + leftOfNext) + 2 * BOX_PAD + BOX_GAP - baseUnits);
|
|
614
1095
|
|
|
615
1096
|
// ---------- group ordering: hints, then SUBSYSTEMS.md order, then name ----------
|
|
616
1097
|
const groupNames = [...new Set(intent.parts.filter((p) => !symbols.get(p.ref)!.isPower).map((p) => p.group))];
|
|
@@ -627,12 +1108,20 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
627
1108
|
const placed = new Map<string, Placed>();
|
|
628
1109
|
const groupRects: { name: string; x1: number; y1: number; x2: number; y2: number }[] = [];
|
|
629
1110
|
const groupOf = new Map<string, string>();
|
|
1111
|
+
/** Part key -> the anchor it was hung on or laid beside. A bound part is
|
|
1112
|
+
* wired to its anchor whatever the distance: the shelf placed it there
|
|
1113
|
+
* deliberately, and a lane past the wire span still belongs to its pin. */
|
|
1114
|
+
const boundTo = new Map<string, string>();
|
|
630
1115
|
const groupExtents = new Map<string, { left: number; right: number }>();
|
|
631
1116
|
for (const gname of groupNames) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
1117
|
+
// The estimate reserves a label's width beside every pin that might take
|
|
1118
|
+
// one; the drawing seldom uses a tenth of it. A round that has seen the
|
|
1119
|
+
// drawing reserves what its box actually grew past its cells, plus a
|
|
1120
|
+
// unit.
|
|
1121
|
+
const m = reachMeasured?.get(gname);
|
|
1122
|
+
const e = m ? { left: m.left + U, right: m.right + U } : labelExtents(instances.filter((i) => i.part.group === gname && !i.sym.isPower).map((i) => i.key));
|
|
1123
|
+
const r = reserves.get(gname);
|
|
1124
|
+
groupExtents.set(gname, { left: e.left + (r?.left ?? 0), right: e.right + (r?.right ?? 0) });
|
|
636
1125
|
}
|
|
637
1126
|
|
|
638
1127
|
/**
|
|
@@ -684,23 +1173,77 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
684
1173
|
}
|
|
685
1174
|
}
|
|
686
1175
|
}
|
|
687
|
-
|
|
688
|
-
|
|
1176
|
+
// The IC leads its group. Edges above are stored refdes-ordered, and
|
|
1177
|
+
// C, D, J, L, Q and R all sort before U, so plain propagation pushed
|
|
1178
|
+
// every IC to the deepest column on the right with its passives strung
|
|
1179
|
+
// out to its left — a reader looked for the part the group is about and
|
|
1180
|
+
// found it last. When a group has ICs (three or more pins, not a
|
|
1181
|
+
// connector), they anchor the first column after the connectors and
|
|
1182
|
+
// every other member sits at its signal-net hop distance from the
|
|
1183
|
+
// nearest IC; a member no signal path reaches stays beside the ICs.
|
|
1184
|
+
// A group with no IC keeps the edge propagation, where the connector
|
|
1185
|
+
// column is the only anchor there is.
|
|
1186
|
+
// A transistor is a three-pin part, but it is not what a group is
|
|
1187
|
+
// about: it belongs at the end of the run that drives its base, the way
|
|
1188
|
+
// a hand-drawn sheet puts a level shifter or a switch right on the pin.
|
|
1189
|
+
const groupIC = (key: string): boolean => instByKey.get(key)!.sym.pins.length >= 3 && !isConnector(instByKey.get(key)!.part) && !isTransistor(key);
|
|
1190
|
+
const anchors = members.filter((m) => groupIC(m.key)).map((m) => m.key);
|
|
1191
|
+
if (anchors.length) {
|
|
1192
|
+
// IC to IC, the signal flow still propagates (a chain of forty
|
|
1193
|
+
// stages is forty columns, not one), refdes-ordered as before
|
|
1194
|
+
const icSet = new Set(anchors);
|
|
1195
|
+
for (let iter = 0; iter < anchors.length; iter++) {
|
|
1196
|
+
let changed = false;
|
|
1197
|
+
for (const e of edges) {
|
|
1198
|
+
if (!icSet.has(e.from) || !icSet.has(e.to)) continue;
|
|
1199
|
+
const want = depth.get(e.from)! + 1;
|
|
1200
|
+
if (depth.get(e.to)! < want) {
|
|
1201
|
+
depth.set(e.to, want);
|
|
1202
|
+
changed = true;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (!changed) break;
|
|
1206
|
+
}
|
|
1207
|
+
// then every other member at its hop distance past the nearest IC,
|
|
1208
|
+
// never routing through a connector or another IC
|
|
1209
|
+
const adj = new Map<string, string[]>();
|
|
689
1210
|
for (const e of edges) {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1211
|
+
adj.set(e.from, [...(adj.get(e.from) ?? []), e.to]);
|
|
1212
|
+
adj.set(e.to, [...(adj.get(e.to) ?? []), e.from]);
|
|
1213
|
+
}
|
|
1214
|
+
const dist = new Map<string, number>(anchors.map((k) => [k, depth.get(k)!]));
|
|
1215
|
+
const queue = [...anchors].sort((a, b) => dist.get(a)! - dist.get(b)!);
|
|
1216
|
+
while (queue.length) {
|
|
1217
|
+
const k = queue.shift()!;
|
|
1218
|
+
for (const o of adj.get(k) ?? []) {
|
|
1219
|
+
if (dist.has(o) || isConnector(instByKey.get(o)!.part)) continue;
|
|
1220
|
+
dist.set(o, dist.get(k)! + 1);
|
|
1221
|
+
queue.push(o);
|
|
694
1222
|
}
|
|
695
1223
|
}
|
|
696
|
-
if (!
|
|
1224
|
+
for (const [k, d] of dist) if (!icSet.has(k)) depth.set(k, d);
|
|
1225
|
+
} else {
|
|
1226
|
+
for (let iter = 0; iter < members.length; iter++) {
|
|
1227
|
+
let changed = false;
|
|
1228
|
+
for (const e of edges) {
|
|
1229
|
+
const want = (depth.get(e.from) ?? 0) + 1;
|
|
1230
|
+
if ((depth.get(e.to) ?? 0) < want && want <= members.length) {
|
|
1231
|
+
depth.set(e.to, want);
|
|
1232
|
+
changed = true;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (!changed) break;
|
|
1236
|
+
}
|
|
697
1237
|
}
|
|
698
1238
|
const depths = [...new Set([...depth.values()])].sort((a, b) => a - b);
|
|
699
1239
|
const columns: string[][] = depths.map((d) => members.filter((m) => depth.get(m.key) === d).map((m) => m.key));
|
|
700
1240
|
|
|
701
|
-
// barycenter row ordering (two sweeps), refdes as the deterministic
|
|
1241
|
+
// barycenter row ordering (two sweeps), refdes as the deterministic
|
|
1242
|
+
// tie; an IC stays at the top of its column so the group reads from
|
|
1243
|
+
// its top-left corner
|
|
702
1244
|
const rowOf = new Map<string, number>();
|
|
703
|
-
|
|
1245
|
+
const lead = (ref: string): number => (groupIC(ref) ? 0 : 1);
|
|
1246
|
+
columns.forEach((col) => col.sort((a, b) => lead(a) - lead(b) || a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i)));
|
|
704
1247
|
for (let sweep = 0; sweep < 2; sweep++) {
|
|
705
1248
|
for (let ci = 1; ci < columns.length; ci++) {
|
|
706
1249
|
const col = columns[ci]!;
|
|
@@ -712,59 +1255,857 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
712
1255
|
if (!neigh.length) return rowOf.get(ref)!;
|
|
713
1256
|
return neigh.reduce((s, o) => s + rowOf.get(o)!, 0) / neigh.length;
|
|
714
1257
|
};
|
|
715
|
-
col.sort((a, b) => bary(a) - bary(b) || a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i));
|
|
1258
|
+
col.sort((a, b) => lead(a) - lead(b) || bary(a) - bary(b) || a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i));
|
|
716
1259
|
}
|
|
717
1260
|
}
|
|
718
1261
|
|
|
719
1262
|
// cells: sized from body plus margins, positions snapped to the grid
|
|
720
|
-
const cellDims = new Map<string, { w: number; h: number; body: Bounds }>();
|
|
1263
|
+
const cellDims = new Map<string, { w: number; h: number; body: Bounds; shelfL: number; shelfR: number; topPad: number; usedL?: number; usedR?: number; reachL?: number; reachR?: number }>();
|
|
721
1264
|
for (const m of members) {
|
|
722
1265
|
const b = bodyBoundsOf(m.sym);
|
|
1266
|
+
const mo = measured?.get(m.key);
|
|
1267
|
+
if (mo) {
|
|
1268
|
+
// the cell is what the previous round drew around this body, a pad
|
|
1269
|
+
// past it on every side: the body sits at shelfL + MARGIN from the
|
|
1270
|
+
// cell's left edge and topPad + MARGIN from its top
|
|
1271
|
+
const shelfL = Math.max(0, ceilU(mo.left) + MEASURE_PAD - MARGIN);
|
|
1272
|
+
const shelfR = Math.max(0, ceilU(mo.right) + MEASURE_PAD - MARGIN);
|
|
1273
|
+
const topPad = Math.max(0, ceilU(mo.top) + MEASURE_PAD - VMARGIN);
|
|
1274
|
+
const below = Math.max(VMARGIN, ceilU(mo.bottom) + MEASURE_PAD);
|
|
1275
|
+
cellDims.set(m.key, {
|
|
1276
|
+
w: shelfL + MARGIN + ceilU(b.maxX - b.minX) + MARGIN + shelfR,
|
|
1277
|
+
h: topPad + VMARGIN + ceilU(b.maxY - b.minY) + below,
|
|
1278
|
+
body: b,
|
|
1279
|
+
shelfL,
|
|
1280
|
+
shelfR,
|
|
1281
|
+
topPad,
|
|
1282
|
+
});
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
// a rail or ground on a pin facing up or down draws a stub, a bar and
|
|
1286
|
+
// a name past the body: that pin's side reserves the symbol's reach
|
|
1287
|
+
const powered = (dy: number): boolean =>
|
|
1288
|
+
m.sym.pins.some((p) => outward(p).dy === dy && netByEndpoint.has(`${m.ref}.${p.number}`) && (netClasses.get(netByEndpoint.get(`${m.ref}.${p.number}`)!.name)?.cls ?? 'signal') !== 'signal');
|
|
1289
|
+
const topReach = powered(-1) ? POWER_REACH : 0;
|
|
1290
|
+
const botReach = powered(1) ? POWER_REACH : 0;
|
|
723
1291
|
cellDims.set(m.key, {
|
|
724
1292
|
w: ceilU(b.maxX - b.minX) + 2 * MARGIN,
|
|
725
|
-
h: ceilU(b.maxY - b.minY) +
|
|
1293
|
+
h: Math.max(0, topReach - VMARGIN) + VMARGIN + ceilU(b.maxY - b.minY) + VMARGIN + Math.max(0, botReach - VMARGIN),
|
|
726
1294
|
body: b,
|
|
1295
|
+
shelfL: 0,
|
|
1296
|
+
shelfR: 0,
|
|
1297
|
+
topPad: Math.max(0, topReach - VMARGIN),
|
|
727
1298
|
});
|
|
728
1299
|
}
|
|
1300
|
+
|
|
1301
|
+
// ---------- pin-anchored hangs (#220 phase 3) ----------
|
|
1302
|
+
// A drafter puts a part on the pin it serves: the compensation network
|
|
1303
|
+
// hangs off COMP, the bootstrap cap sits on BOOT, a pull-down drops from
|
|
1304
|
+
// its pin. Column placement put every such part in a column of its own
|
|
1305
|
+
// and let the wire pass decide later, so most of them ended as labelled
|
|
1306
|
+
// islands. Here, before any column is packed, each IC's side pins are
|
|
1307
|
+
// read in order and the chain of vertical two-lead parts a pin's net
|
|
1308
|
+
// leads into is claimed as a HANG: it will be drawn below the pin's
|
|
1309
|
+
// row in a shelf beside the IC, at the first slot whose rows are free,
|
|
1310
|
+
// and the IC's cell grows by the shelf so the columns start past it.
|
|
1311
|
+
// Hung parts leave the columns now; the chain pass below places them
|
|
1312
|
+
// once the IC has coordinates, with the same clearance check every
|
|
1313
|
+
// idiom uses, and a hang that cannot be placed cleanly falls back to a
|
|
1314
|
+
// column at the group's right edge.
|
|
1315
|
+
type Hang = { ic: string; pin: DraftPin; dx: -1 | 1; dir: -1 | 1; chain: string[]; slot: number; straight: boolean; w: number; h: number; ends: 'power' | 'open' };
|
|
1316
|
+
/** A run of two-lead parts laid ALONG the pin's row, away from the IC. */
|
|
1317
|
+
type Inline = { ic: string; pin: DraftPin; dx: -1 | 1; chain: { key: string; nearPin: string; gap: number }[]; len: number; farNet: string | null; offset: number };
|
|
1318
|
+
const hangs: Hang[] = [];
|
|
1319
|
+
const inlines: Inline[] = [];
|
|
1320
|
+
const hungKeys = new Set<string>();
|
|
1321
|
+
for (const [k, v] of boundTo) if (groupOf.get(k) === gname) boundTo.delete(k), void v;
|
|
1322
|
+
/**
|
|
1323
|
+
* The rotation (0, 90, 180, 270) that turns two-lead part `key` so that
|
|
1324
|
+
* pin `nearPin` faces direction `want` (schematic dx/dy) and its other
|
|
1325
|
+
* lead faces the opposite way; null when the part is not a two-lead
|
|
1326
|
+
* part with opposed leads. This is what lets a diode, LED, fuse or
|
|
1327
|
+
* switch (horizontal leads in the library) hang like a resistor, and a
|
|
1328
|
+
* resistor lie along a row like a diode.
|
|
1329
|
+
*/
|
|
1330
|
+
/** A test point: one pin, probing the net it sits on. It hangs beside that net's pin. */
|
|
1331
|
+
const isTestPoint = (key: string): boolean => {
|
|
1332
|
+
const inst = instByKey.get(key);
|
|
1333
|
+
return !!inst && inst.sym.pins.length === 1 && (/TestPoint/i.test(inst.part.libId) || /^TP\d/.test(inst.ref));
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
const orientFor = (key: string, nearPin: string, want: { dx: number; dy: number }): number | null => {
|
|
1337
|
+
const pins = instByKey.get(key)?.sym.pins ?? [];
|
|
1338
|
+
if (pins.length !== 2 && !isTestPoint(key)) return null;
|
|
1339
|
+
for (const rot of [0, 90, 180, 270]) {
|
|
1340
|
+
const rs = rotatedSym(instByKey.get(key)!.sym, rot);
|
|
1341
|
+
const near = rs.pins.find((p) => p.number === nearPin);
|
|
1342
|
+
if (!near) return null;
|
|
1343
|
+
const on = outward(near);
|
|
1344
|
+
if (on.dx !== want.dx || on.dy !== want.dy) continue;
|
|
1345
|
+
if (pins.length === 1) return rot;
|
|
1346
|
+
const other = rs.pins.find((p) => p.number !== nearPin)!;
|
|
1347
|
+
const oo = outward(other);
|
|
1348
|
+
if (oo.dx === -want.dx && oo.dy === -want.dy) return rot;
|
|
1349
|
+
}
|
|
1350
|
+
return null;
|
|
1351
|
+
};
|
|
1352
|
+
/** Both leads of a two-lead part, un-rotated; a test point's one pin twice. */
|
|
1353
|
+
const leadsOf = (key: string): { a: DraftPin; b: DraftPin } | null => {
|
|
1354
|
+
const pins = instByKey.get(key)?.sym.pins ?? [];
|
|
1355
|
+
if (isTestPoint(key)) return { a: pins[0]!, b: pins[0]! };
|
|
1356
|
+
if (pins.length !== 2) return null;
|
|
1357
|
+
const oa = outward(pins[0]!);
|
|
1358
|
+
const ob = outward(pins[1]!);
|
|
1359
|
+
if (oa.dx !== -ob.dx || oa.dy !== -ob.dy) return null; // leads must oppose
|
|
1360
|
+
return { a: pins[0]!, b: pins[1]! };
|
|
1361
|
+
};
|
|
1362
|
+
const hangable = (key: string): boolean =>
|
|
1363
|
+
memberKeySet.has(key) &&
|
|
1364
|
+
!decapOwner.has(key) &&
|
|
1365
|
+
!hungKeys.has(key) &&
|
|
1366
|
+
!isConnector(instByKey.get(key)!.part) &&
|
|
1367
|
+
!/crystal|reson/i.test(instByKey.get(key)?.part.libId ?? '') &&
|
|
1368
|
+
leadsOf(key) !== null;
|
|
1369
|
+
/** Lead span along the hang or run, mm: connection point to connection
|
|
1370
|
+
* point, or for a test point the body's reach past its one pin. */
|
|
1371
|
+
const spanOf = (key: string, rot = 0): number => {
|
|
1372
|
+
const ctl = controlPinOf(key);
|
|
1373
|
+
if (ctl) {
|
|
1374
|
+
const b = bodyBoundsOf(instByKey.get(key)!.sym);
|
|
1375
|
+
return Math.max(b.maxX - ctl.x, ctl.x - b.minX);
|
|
1376
|
+
}
|
|
1377
|
+
const l = leadsOf(key)!;
|
|
1378
|
+
if (isTestPoint(key)) {
|
|
1379
|
+
const rs = rotatedSym(instByKey.get(key)!.sym, rot);
|
|
1380
|
+
const pin = rs.pins[0]!;
|
|
1381
|
+
const b = bodyBoundsOf(rs);
|
|
1382
|
+
const o = outward(pin);
|
|
1383
|
+
// the body lies opposite the pin's outward direction
|
|
1384
|
+
return o.dy !== 0 ? Math.max(0, o.dy === 1 ? b.maxY - pin.y : pin.y - b.minY) : Math.max(0, o.dx === 1 ? pin.x - b.minX : b.maxX - pin.x);
|
|
1385
|
+
}
|
|
1386
|
+
return Math.hypot(l.a.x - l.b.x, l.a.y - l.b.y);
|
|
1387
|
+
};
|
|
1388
|
+
const INLINE_GAP = 4 * U;
|
|
1389
|
+
/**
|
|
1390
|
+
* Claim a hang from IC pin `pin` starting at `first` (an endpoint of the
|
|
1391
|
+
* pin's net). `dir` 1 hangs down from the row, -1 rises above it; the
|
|
1392
|
+
* chain continues through two-endpoint links to a rail, a ground or a
|
|
1393
|
+
* tapped node. The part is turned so its near lead faces the row.
|
|
1394
|
+
*/
|
|
1395
|
+
const claimHang = (icKey: string, pin: DraftPin, dx: -1 | 1, first: { key: string; pin: string }, dir: -1 | 1): void => {
|
|
1396
|
+
const chain = [first.key];
|
|
1397
|
+
const nearPins = [first.pin];
|
|
1398
|
+
hungKeys.add(first.key);
|
|
1399
|
+
boundTo.set(first.key, icKey);
|
|
1400
|
+
let ends: 'power' | 'open' = 'open';
|
|
1401
|
+
for (;;) {
|
|
1402
|
+
const cur = chain[chain.length - 1]!;
|
|
1403
|
+
if (isTestPoint(cur)) break;
|
|
1404
|
+
const l = leadsOf(cur)!;
|
|
1405
|
+
const near = nearPins[nearPins.length - 1]!;
|
|
1406
|
+
const far = l.a.number === near ? l.b : l.a;
|
|
1407
|
+
const beyond = netByEndpoint.get(`${instByKey.get(cur)!.ref}.${far.number}`);
|
|
1408
|
+
if (!beyond) break;
|
|
1409
|
+
if ((netClasses.get(beyond.name)?.cls ?? 'signal') !== 'signal') {
|
|
1410
|
+
ends = 'power';
|
|
1411
|
+
break;
|
|
1412
|
+
}
|
|
1413
|
+
if (beyond.pins.length !== 2) break;
|
|
1414
|
+
const next = beyond.pins.map(epKey).find((e) => e !== null && e.key !== cur);
|
|
1415
|
+
if (!next || !hangable(next.key) || chain.includes(next.key)) break;
|
|
1416
|
+
chain.push(next.key);
|
|
1417
|
+
nearPins.push(next.pin);
|
|
1418
|
+
hungKeys.add(next.key);
|
|
1419
|
+
boundTo.set(next.key, icKey);
|
|
1420
|
+
}
|
|
1421
|
+
let h = 0;
|
|
1422
|
+
let w = 0;
|
|
1423
|
+
for (const key of chain) {
|
|
1424
|
+
const inst = instByKey.get(key)!;
|
|
1425
|
+
// turned to hang: the body's extent across the axis and its span along it
|
|
1426
|
+
const rotH = orientFor(key, nearPins[chain.indexOf(key)]!, { dx: 0, dy: -dir }) ?? 0;
|
|
1427
|
+
const rs = rotatedSym(inst.sym, rotH);
|
|
1428
|
+
const b = bodyBoundsOf(rs);
|
|
1429
|
+
h += spanOf(key, rotH) + HANG_GAP;
|
|
1430
|
+
// a hung part is narrow: its body, the reference and value
|
|
1431
|
+
// beside it, and a unit of air — not the column cell's margins
|
|
1432
|
+
const fieldW = Math.max(inst.ref.length + 1, inst.part.value.length) * TEXT_RESERVE * LABEL_HEIGHT + 1.27;
|
|
1433
|
+
w = Math.max(w, b.maxX - b.minX + fieldW + U);
|
|
1434
|
+
}
|
|
1435
|
+
if (ends === 'power') h += HANG_POWER_END;
|
|
1436
|
+
hangs.push({ ic: icKey, pin, dx, dir, chain, slot: 0, straight: false, w, h, ends });
|
|
1437
|
+
hangNearPins.set(chain.join('+'), nearPins);
|
|
1438
|
+
};
|
|
1439
|
+
const hangNearPins = new Map<string, string[]>();
|
|
1440
|
+
const placedOffset = new Set<Inline>();
|
|
1441
|
+
/**
|
|
1442
|
+
* Claim a series run along the row from IC pin `pin`: `first` lies on
|
|
1443
|
+
* the row with its near lead toward the IC, and the run continues
|
|
1444
|
+
* through two-endpoint links while the next part is a two-lead part.
|
|
1445
|
+
* The far end gets whatever the wire pass gives it: a wire to a
|
|
1446
|
+
* connector on the row, or a label.
|
|
1447
|
+
*/
|
|
1448
|
+
/** Gap before a part on a row: room for the wired net's name above the wire, at least one plain gap. */
|
|
1449
|
+
const gapFor = (netName: string): number => Math.max(INLINE_GAP, grid(ceilU(Math.max(1, netName.length) * TEXT_RESERVE * LABEL_HEIGHT + 2 * U) ));
|
|
1450
|
+
const claimInline = (icKey: string, pin: DraftPin, dx: -1 | 1, first: { key: string; pin: string }): void => {
|
|
1451
|
+
const pinNet = netByEndpoint.get(`${instByKey.get(icKey)!.ref}.${pin.number}`)?.name ?? '';
|
|
1452
|
+
const chain: { key: string; nearPin: string; gap: number }[] = [{ key: first.key, nearPin: first.pin, gap: gapFor(pinNet) }];
|
|
1453
|
+
hungKeys.add(first.key);
|
|
1454
|
+
boundTo.set(first.key, icKey);
|
|
1455
|
+
let farNet: string | null = null;
|
|
1456
|
+
for (let n = 0; n < 3; n++) {
|
|
1457
|
+
const cur = chain[chain.length - 1]!;
|
|
1458
|
+
if (isTransistor(cur.key)) {
|
|
1459
|
+
farNet = null; // the run ends in the transistor; its other pins take what the wire pass gives
|
|
1460
|
+
break;
|
|
1461
|
+
}
|
|
1462
|
+
const l = leadsOf(cur.key)!;
|
|
1463
|
+
const far = l.a.number === cur.nearPin ? l.b : l.a;
|
|
1464
|
+
const beyond = netByEndpoint.get(`${instByKey.get(cur.key)!.ref}.${far.number}`);
|
|
1465
|
+
farNet = beyond?.name ?? null;
|
|
1466
|
+
if (!beyond || beyond.pins.length !== 2) break;
|
|
1467
|
+
const next = beyond.pins.map(epKey).find((e) => e !== null && e.key !== cur.key);
|
|
1468
|
+
if (!next || chain.some((c) => c.key === next.key)) break;
|
|
1469
|
+
// a series resistor into a base or gate: the transistor ends the run,
|
|
1470
|
+
// turned so that pin faces the part that drives it
|
|
1471
|
+
if (isTransistor(next.key)) {
|
|
1472
|
+
if (!memberKeySet.has(next.key) || hungKeys.has(next.key) || controlPinOf(next.key)!.number !== next.pin) break;
|
|
1473
|
+
chain.push({ key: next.key, nearPin: next.pin, gap: gapFor(beyond.name) });
|
|
1474
|
+
hungKeys.add(next.key);
|
|
1475
|
+
boundTo.set(next.key, icKey);
|
|
1476
|
+
farNet = null;
|
|
1477
|
+
break;
|
|
1478
|
+
}
|
|
1479
|
+
if (!hangable(next.key)) break;
|
|
1480
|
+
chain.push({ key: next.key, nearPin: next.pin, gap: gapFor(beyond.name) });
|
|
1481
|
+
hungKeys.add(next.key);
|
|
1482
|
+
boundTo.set(next.key, icKey);
|
|
1483
|
+
}
|
|
1484
|
+
let len = 0;
|
|
1485
|
+
for (const c of chain) len += c.gap + spanOf(c.key);
|
|
1486
|
+
// room for the label the far end may carry
|
|
1487
|
+
if (farNet) len += STUB * U + Math.max(1, farNet.length) * TEXT_RESERVE * LABEL_HEIGHT;
|
|
1488
|
+
inlines.push({ ic: icKey, pin, dx, chain, len, farNet, offset: 0 });
|
|
1489
|
+
};
|
|
1490
|
+
const epKey = (ep: string): { key: string; pin: string } | null => {
|
|
1491
|
+
const m = /^([^.]+)\.(.+)$/.exec(ep);
|
|
1492
|
+
return m ? { key: instKeyOf(m[1]!, m[2]!), pin: m[2]! } : null;
|
|
1493
|
+
};
|
|
1494
|
+
// Three units, not four: a hung part's near stub ends one unit past
|
|
1495
|
+
// the gap, and pins sit two units apart, so an even gap parks that
|
|
1496
|
+
// stub end exactly on the neighbouring pin's row, where any run along
|
|
1497
|
+
// that row would join it (every single pull-up beside a pull-down on
|
|
1498
|
+
// the next pin was refused for this). An odd gap keeps it between rows.
|
|
1499
|
+
const HANG_GAP = 3 * U;
|
|
1500
|
+
/**
|
|
1501
|
+
* How far the labels on one side of an anchor actually reach, given
|
|
1502
|
+
* what has been claimed on it: a pin whose parts hang carries no stub
|
|
1503
|
+
* label any more, only its run's name (and only when the net has
|
|
1504
|
+
* endpoints elsewhere), so the lanes may start that much closer to the
|
|
1505
|
+
* IC. `labelExtents` assumes a stub label on every pin and put the
|
|
1506
|
+
* lanes 15 mm further out than the drawing needed.
|
|
1507
|
+
*/
|
|
1508
|
+
const sideReach = (icKey: string, dx: -1 | 1): number => {
|
|
1509
|
+
const ic = instByKey.get(icKey)!;
|
|
1510
|
+
let reach = 0;
|
|
1511
|
+
for (const pin of ic.sym.pins) {
|
|
1512
|
+
if (outward(pin).dx !== dx) continue;
|
|
1513
|
+
const net = netByEndpoint.get(`${ic.ref}.${pin.number}`);
|
|
1514
|
+
if (!net) continue;
|
|
1515
|
+
const nameW = Math.max(1, net.name.length) * TEXT_RESERVE * LABEL_HEIGHT;
|
|
1516
|
+
if ((netClasses.get(net.name)?.cls ?? 'signal') !== 'signal') {
|
|
1517
|
+
reach = Math.max(reach, (STUB + 2) * U + 1.905 + nameW);
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
const eps = net.pins.map(epKey);
|
|
1521
|
+
const claimed = eps.some((e) => e !== null && boundTo.get(e.key) === icKey);
|
|
1522
|
+
const elsewhere = eps.some((e) => e === null || (e.key !== icKey && !memberKeySet.has(e.key) && !decapOwner.has(e.key)));
|
|
1523
|
+
if (claimed) reach = Math.max(reach, elsewhere ? nameW + FLAG_PAD + U : STUB * U);
|
|
1524
|
+
else reach = Math.max(reach, STUB * U + nameW + FLAG_PAD);
|
|
1525
|
+
}
|
|
1526
|
+
return reach;
|
|
1527
|
+
};
|
|
1528
|
+
const HANG_POWER_END = 5 * U;
|
|
1529
|
+
/** Rows a power symbol and its name need past a pin: stub, bar, text. */
|
|
1530
|
+
const POWER_CLEAR_ROWS = 6;
|
|
1531
|
+
// Parts hang on ICs first, then on connectors and switches: a fuse
|
|
1532
|
+
// rises from the jack's pin, a button's pull-up, debounce cap and ESD
|
|
1533
|
+
// diode hang on the switch's signal pin. A switch already hung on an
|
|
1534
|
+
// IC pin (a reset button dropping from EN) anchors nothing itself.
|
|
1535
|
+
const claimAnchors = [
|
|
1536
|
+
...anchors,
|
|
1537
|
+
...members.filter((m) => !anchors.includes(m.key) && (isConnector(m.part) || isSwitch(m.key))).map((m) => m.key),
|
|
1538
|
+
];
|
|
1539
|
+
for (const icKey of claimAnchors) {
|
|
1540
|
+
if (hungKeys.has(icKey)) continue;
|
|
1541
|
+
const ic = instByKey.get(icKey)!;
|
|
1542
|
+
const sidePins = ic.sym.pins
|
|
1543
|
+
.filter((p) => outward(p).dx !== 0)
|
|
1544
|
+
.sort((a, b) => b.y - a.y || a.x - b.x); // top row first (symbol y is up)
|
|
1545
|
+
for (const pin of sidePins) {
|
|
1546
|
+
const dx = outward(pin).dx as -1 | 1;
|
|
1547
|
+
const net = netByEndpoint.get(`${ic.ref}.${pin.number}`);
|
|
1548
|
+
if (!net || (netClasses.get(net.name)?.cls ?? 'signal') !== 'signal') continue;
|
|
1549
|
+
// the wire pass draws this net only if it stays in the group with
|
|
1550
|
+
// few endpoints; a net that will be labelled anyway gives no hang
|
|
1551
|
+
const eps = net.pins.map(epKey).filter((e): e is { key: string; pin: string } => e !== null);
|
|
1552
|
+
// A net that leaves the group or carries more endpoints than the
|
|
1553
|
+
// wire pass joins still hangs its local parts: the pull-up on a
|
|
1554
|
+
// fault line that also goes to the MCU, the RC on a button the
|
|
1555
|
+
// MCU reads. The wire pass joins the pin and its hung parts as
|
|
1556
|
+
// one local run and labels it once for the rest of the net
|
|
1557
|
+
// (esp32-amp shipped fourteen such parts as labelled islands).
|
|
1558
|
+
if (eps.length > MAX_WIRED_ENDPOINTS) trace(`${ic.ref}.${pin.number} ${net.name}: ${eps.length} endpoints, local parts still hang`);
|
|
1559
|
+
// a crystal's load caps belong to the flanking idiom below
|
|
1560
|
+
if (eps.some((e) => /crystal|reson/i.test(instByKey.get(e.key)?.part.libId ?? ''))) continue;
|
|
1561
|
+
// the chain enters the first part through whichever lead is on the
|
|
1562
|
+
// pin's net: through the top lead it hangs DOWN from the row,
|
|
1563
|
+
// through the bottom lead it rises above it (the pull-up shape)
|
|
1564
|
+
// every hangable endpoint of the pin's net hangs, each as its own
|
|
1565
|
+
// chain: a compensation network is a series RC AND a shunt C on
|
|
1566
|
+
// one pin, and leaving the shunt in a far column keeps the whole
|
|
1567
|
+
// net past wire span, labelled
|
|
1568
|
+
/** The net on the lead of `key` that is NOT `nearPin`. */
|
|
1569
|
+
const farNetOf = (key: string, nearPin: string): IntentNet | undefined => {
|
|
1570
|
+
const l = leadsOf(key)!;
|
|
1571
|
+
const far = l.a.number === nearPin ? l.b : l.a;
|
|
1572
|
+
return netByEndpoint.get(`${instByKey.get(key)!.ref}.${far.number}`);
|
|
1573
|
+
};
|
|
1574
|
+
/** Bridges from this pin to another pin of the IC, resolved after the pin's other parts. */
|
|
1575
|
+
const pinBridges: { first: { key: string; pin: string }; otherPin: DraftPin; othersOnPin: boolean }[] = [];
|
|
1576
|
+
for (const first of eps) {
|
|
1577
|
+
if (first.key === icKey) continue;
|
|
1578
|
+
// a transistor driven straight from the pin lies on the row, base to the pin
|
|
1579
|
+
if (isTransistor(first.key) && memberKeySet.has(first.key) && !hungKeys.has(first.key) && controlPinOf(first.key)!.number === first.pin) {
|
|
1580
|
+
trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key)!.ref} lies on the row (transistor)`);
|
|
1581
|
+
claimInline(icKey, pin, dx, first);
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
if (!hangable(first.key)) {
|
|
1585
|
+
const why = !memberKeySet.has(first.key) ? 'not a group member' : decapOwner.has(first.key) ? 'a decoupling cap' : hungKeys.has(first.key) ? 'already claimed' : isConnector(instByKey.get(first.key)!.part) ? 'a connector' : leadsOf(first.key) === null ? 'not a two-lead part' : 'a crystal';
|
|
1586
|
+
trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key)!.ref} not hangable (${why})`);
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
// a test point rises above the row of the pin it probes
|
|
1590
|
+
if (isTestPoint(first.key)) {
|
|
1591
|
+
claimHang(icKey, pin, dx, first, -1);
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
// Look to the END of the run this part starts, through two-endpoint
|
|
1595
|
+
// links: a run that ends on a rail or ground is a SHUNT and hangs
|
|
1596
|
+
// from the row — rising to a rail (a pull-up), dropping to ground
|
|
1597
|
+
// (a pull-down, a filter cap, a series RC to ground). A run whose
|
|
1598
|
+
// far net returns to a pin of this IC BRIDGES the two pins and
|
|
1599
|
+
// hangs toward the other pin's row so that net stays within wire
|
|
1600
|
+
// span (a bootstrap cap between BST and OUT). Everything else is
|
|
1601
|
+
// a SERIES element — a 100 Ω in an I2S line, an output inductor —
|
|
1602
|
+
// and lies along the row, away from the IC, the way the reference
|
|
1603
|
+
// sheet draws it.
|
|
1604
|
+
let cur = first;
|
|
1605
|
+
let farNet = farNetOf(cur.key, cur.pin);
|
|
1606
|
+
const seen = new Set([first.key]);
|
|
1607
|
+
for (let n = 0; n < 4 && farNet && (netClasses.get(farNet.name)?.cls ?? 'signal') === 'signal' && farNet.pins.length === 2; n++) {
|
|
1608
|
+
const next = farNet.pins.map(epKey).find((e) => e !== null && e.key !== cur.key);
|
|
1609
|
+
if (!next || !hangable(next.key) || seen.has(next.key)) break;
|
|
1610
|
+
seen.add(next.key);
|
|
1611
|
+
cur = next;
|
|
1612
|
+
farNet = farNetOf(cur.key, cur.pin);
|
|
1613
|
+
}
|
|
1614
|
+
const farCls = farNet ? (netClasses.get(farNet.name)?.cls ?? 'signal') : null;
|
|
1615
|
+
// A bridge (the far net returns to another pin of this IC) lies
|
|
1616
|
+
// on the row of the pin that has NOTHING ELSE on it: claimed
|
|
1617
|
+
// from the busier pin it blocked that pin's row, and the series
|
|
1618
|
+
// part behind it could not be wired (esp32-amp's C28 on OUT_LN
|
|
1619
|
+
// left L3 a labelled island while C27 on BST_AP let L2 wire).
|
|
1620
|
+
const bridgeEp = cur.key === first.key && farNet && farCls === 'signal' && farNet.pins.length === 2
|
|
1621
|
+
? farNet.pins.find((ep) => ep.startsWith(`${ic.ref}.`) && ep !== `${ic.ref}.${pin.number}`)
|
|
1622
|
+
: undefined;
|
|
1623
|
+
const otherPin = bridgeEp ? ic.sym.pins.find((p) => `${ic.ref}.${p.number}` === bridgeEp) : undefined;
|
|
1624
|
+
if (otherPin) {
|
|
1625
|
+
const others = eps.filter((e) => e.key !== icKey && e.key !== first.key && (hangable(e.key) || boundTo.get(e.key) === icKey));
|
|
1626
|
+
pinBridges.push({ first, otherPin, othersOnPin: others.length > 0 });
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
// A part between two pins of this IC (a bootstrap cap between
|
|
1630
|
+
// BST and OUT) lies along its row too: hung, its far lead
|
|
1631
|
+
// landed rows away from the other pin and that pin's branch ran
|
|
1632
|
+
// along a third row under a label. On the row, the far net
|
|
1633
|
+
// reaches the other pin's row through the router's trunk.
|
|
1634
|
+
// A bridge to another pin of this IC (a bootstrap cap between BST
|
|
1635
|
+
// and SW) hangs in a lane toward that pin's row, its far lead
|
|
1636
|
+
// joined to that row by one short jog: laid along the row it sat
|
|
1637
|
+
// past every lane and every series part, and the bank under the
|
|
1638
|
+
// group followed the row out (rails 112 mm wide, 91 by hand).
|
|
1639
|
+
if (farCls === 'ground') {
|
|
1640
|
+
trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key)!.ref} hangs down (to ${farNet!.name})`);
|
|
1641
|
+
claimHang(icKey, pin, dx, first, 1);
|
|
1642
|
+
} else if (farCls === 'rail') {
|
|
1643
|
+
trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key)!.ref} rises (to ${farNet!.name})`);
|
|
1644
|
+
claimHang(icKey, pin, dx, first, -1);
|
|
1645
|
+
} else {
|
|
1646
|
+
trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(first.key)!.ref} lies on the row (far net ${farNet?.name ?? 'none'})`);
|
|
1647
|
+
claimInline(icKey, pin, dx, first);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
// A bridge cap (BST to SW) hangs in the lane this pin already has,
|
|
1651
|
+
// toward the other pin's row, when the pin has a hang to share the
|
|
1652
|
+
// lane with (the buck's L1 rising from SW): one axis, one T, and the
|
|
1653
|
+
// other row reaches the cap's far lead with a single jog. With no
|
|
1654
|
+
// lane of its own to join it stays on the row (hung alone it cost
|
|
1655
|
+
// four attachments on the amplifier's four bootstrap pairs); and it
|
|
1656
|
+
// is left to the other pin when this pin carries series parts and
|
|
1657
|
+
// that pin carries nothing else.
|
|
1658
|
+
const pinHasHang = hangs.some((hg) => hg.ic === icKey && hg.pin.number === pin.number);
|
|
1659
|
+
for (const b of pinBridges) {
|
|
1660
|
+
if (hungKeys.has(b.first.key)) continue;
|
|
1661
|
+
if (pinHasHang) {
|
|
1662
|
+
const dir: -1 | 1 = b.otherPin.y < pin.y ? 1 : -1; // symbol y is up: a lower pin means dropping
|
|
1663
|
+
trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(b.first.key)!.ref} bridges to ${ic.ref}.${b.otherPin.number}; shares the pin's lane, ${dir === 1 ? 'down' : 'up'}`);
|
|
1664
|
+
claimHang(icKey, pin, dx, b.first, dir);
|
|
1665
|
+
} else if (b.othersOnPin) {
|
|
1666
|
+
trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(b.first.key)!.ref} bridges to ${ic.ref}.${b.otherPin.number}; left for that pin`);
|
|
1667
|
+
} else {
|
|
1668
|
+
trace(`${ic.ref}.${pin.number} ${net.name}: ${instByKey.get(b.first.key)!.ref} bridges to ${ic.ref}.${b.otherPin.number}; lies on the row`);
|
|
1669
|
+
claimInline(icKey, pin, dx, b.first);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
// Slots per side. A hang whose rows are free of every other pin on
|
|
1674
|
+
// that side stays dead straight on its stub axis (AC-16.31: the wire
|
|
1675
|
+
// from the pin continues into the chain with no bend); the rest take
|
|
1676
|
+
// the first shelf slot whose rows are free. Rows are relative to the
|
|
1677
|
+
// IC origin, y down; symbol pin y is up.
|
|
1678
|
+
for (const dx of [-1, 1] as const) {
|
|
1679
|
+
const side = hangs.filter((hg) => hg.ic === icKey && hg.dx === dx);
|
|
1680
|
+
const inlineSide = inlines.filter((il) => il.ic === icKey && il.dx === dx);
|
|
1681
|
+
// Two horizontal parts on rows one pitch apart cannot share an x
|
|
1682
|
+
// range: a run on a row next to an earlier run starts past that
|
|
1683
|
+
// run's end (the reference sheet's output filter alternates the
|
|
1684
|
+
// inductor and the cap across its rows for the same reason).
|
|
1685
|
+
// a part lying on a row carries its reference above and its value
|
|
1686
|
+
// below, so its texts reach the neighbouring rows: runs within
|
|
1687
|
+
// three rows of each other stagger (two left C27's value under L3's
|
|
1688
|
+
// reference)
|
|
1689
|
+
const ROWS_NEAR = 3 * 2.54 + 0.01;
|
|
1690
|
+
for (const il of [...inlineSide].sort((a, b) => b.pin.y - a.pin.y)) {
|
|
1691
|
+
let offset = 0;
|
|
1692
|
+
for (;;) {
|
|
1693
|
+
const clash = inlineSide.find(
|
|
1694
|
+
(o) => o !== il && o.offset !== undefined && placedOffset.has(o) && Math.abs(o.pin.y - il.pin.y) <= ROWS_NEAR && offset < o.offset + o.len && offset + il.len > o.offset,
|
|
1695
|
+
);
|
|
1696
|
+
if (!clash) break;
|
|
1697
|
+
offset = clash.offset + clash.len + INLINE_GAP;
|
|
1698
|
+
}
|
|
1699
|
+
il.offset = offset;
|
|
1700
|
+
placedOffset.add(il);
|
|
1701
|
+
}
|
|
1702
|
+
if (!side.length && !inlineSide.length) continue;
|
|
1703
|
+
// any connected neighbour blocks the straight axis: a signal pin's
|
|
1704
|
+
// stub end sits on it, and a rail or ground pin's longer stub
|
|
1705
|
+
// crosses it — legal, but a wire through the IC's supply stubs is
|
|
1706
|
+
// exactly the crossing a drafter moves a part to avoid (the Tier C
|
|
1707
|
+
// divider drew two of them). A no-connect neighbour blocks nothing.
|
|
1708
|
+
const sidePinYs = ic.sym.pins
|
|
1709
|
+
.filter((p) => outward(p).dx === dx && netByEndpoint.has(`${ic.ref}.${p.number}`))
|
|
1710
|
+
.map((p) => -p.y);
|
|
1711
|
+
const pitch = Math.max(0, ...side.map((hg) => hg.w)) + U;
|
|
1712
|
+
const slots: { top: number; bot: number }[][] = [];
|
|
1713
|
+
let anyShelved = false;
|
|
1714
|
+
// Slot order matters: a lower pin's branch runs along its row from
|
|
1715
|
+
// the stub to its chain's axis, and a chain hanging down from a
|
|
1716
|
+
// higher pin must not cross that row inside the branch's span. So
|
|
1717
|
+
// down-hangs take slots from the lowest pin upward and up-hangs from
|
|
1718
|
+
// the highest pin downward: the chain that passes other pins' rows
|
|
1719
|
+
// is always the outer one, and every branch stays clear of every
|
|
1720
|
+
// sibling's stub end (esp32-amp's RT branch ran into the COMP
|
|
1721
|
+
// hang's stub end the first time).
|
|
1722
|
+
// Slots go to PINS, not hangs: a part rising and a part dropping
|
|
1723
|
+
// from one pin share one axis — the divider on its pin — and meet
|
|
1724
|
+
// the row in a single T. A slot's occupied range runs from the pin
|
|
1725
|
+
// ROW to the chain end, because the branch along the row belongs
|
|
1726
|
+
// to it: measured from the first part instead, a chain rising from
|
|
1727
|
+
// a lower pin shared its slot with the higher pin's drop and the
|
|
1728
|
+
// two axes met on that pin's row (esp32-amp's USB_OV divider was
|
|
1729
|
+
// refused for exactly that, four hangs on two adjacent pins).
|
|
1730
|
+
// One axis carries at most one chain up and one chain down; a
|
|
1731
|
+
// third chain on the same pin (the NTC divider's cap beside its
|
|
1732
|
+
// thermistor) takes the next lane out, and each lane is slotted as
|
|
1733
|
+
// its own group.
|
|
1734
|
+
const byPin = new Map<string, Hang[]>();
|
|
1735
|
+
for (const hg of side) byPin.set(hg.pin.number, [...(byPin.get(hg.pin.number) ?? []), hg]);
|
|
1736
|
+
const pinRow = (g: Hang[]): number => -g[0]!.pin.y;
|
|
1737
|
+
const pinGroups: Hang[][] = [];
|
|
1738
|
+
for (const hgs of byPin.values()) {
|
|
1739
|
+
const ups = hgs.filter((hg) => hg.dir === -1);
|
|
1740
|
+
const downs = hgs.filter((hg) => hg.dir === 1);
|
|
1741
|
+
for (let i = 0; i < Math.max(ups.length, downs.length); i++) {
|
|
1742
|
+
const lane = [ups[i], downs[i]].filter((hg): hg is Hang => hg !== undefined);
|
|
1743
|
+
pinGroups.push(lane);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
const ordered = [
|
|
1747
|
+
...pinGroups.filter((g) => g.some((hg) => hg.dir === 1)).sort((a, b) => pinRow(b) - pinRow(a)),
|
|
1748
|
+
...pinGroups.filter((g) => g.every((hg) => hg.dir === -1)).sort((a, b) => pinRow(a) - pinRow(b)),
|
|
1749
|
+
];
|
|
1750
|
+
const lowRowAll = Math.max(...side.map((hg) => -hg.pin.y));
|
|
1751
|
+
const highRowAll = Math.min(...side.map((hg) => -hg.pin.y));
|
|
1752
|
+
for (const g of ordered) {
|
|
1753
|
+
const rowY = pinRow(g);
|
|
1754
|
+
const top = Math.min(rowY, ...g.map((hg) => (hg.dir === -1 ? highRowAll - HANG_GAP - hg.h : rowY)));
|
|
1755
|
+
const bot = Math.max(rowY, ...g.map((hg) => (hg.dir === 1 ? lowRowAll + HANG_GAP + hg.h : rowY)));
|
|
1756
|
+
// the clearance check pads the axis range by four units past the
|
|
1757
|
+
// pin row and the chain end, so a neighbour that close blocks it
|
|
1758
|
+
// the same five units (four of pad plus one) the clearance check
|
|
1759
|
+
// itself applies, else a neighbour exactly at the edge passes here
|
|
1760
|
+
// and refuses there (J1's fuse against the CC1 pin two rows down)
|
|
1761
|
+
const straight = !sidePinYs.some((y) => y !== rowY && y > top - 5 * U && y < bot + 5 * U);
|
|
1762
|
+
for (const hg of g) hg.straight = straight;
|
|
1763
|
+
if (straight) continue;
|
|
1764
|
+
anyShelved = true;
|
|
1765
|
+
let k = 0;
|
|
1766
|
+
while ((slots[k] ?? []).some((iv) => top < iv.bot + 2 * U && bot > iv.top - 2 * U)) k++;
|
|
1767
|
+
(slots[k] ??= []).push({ top, bot });
|
|
1768
|
+
for (const hg of g) hg.slot = k;
|
|
1769
|
+
}
|
|
1770
|
+
const dims = cellDims.get(icKey)!;
|
|
1771
|
+
const ext = labelExtents([icKey]);
|
|
1772
|
+
// Lanes come first, past the side's labels; the inline runs start
|
|
1773
|
+
// beyond the lanes. A part lying on the row between the stub and
|
|
1774
|
+
// the lanes blocked the row for the pin's own hung parts (Q1 on
|
|
1775
|
+
// VBUS_FET_EN left R17 unreachable), and a run on another row
|
|
1776
|
+
// starting inside the lanes crossed their bodies.
|
|
1777
|
+
const laneReach = sideReach(icKey, dx);
|
|
1778
|
+
void ext;
|
|
1779
|
+
const lanesW = anyShelved ? slots.length * pitch : 0;
|
|
1780
|
+
for (const il of inlineSide) il.offset += anyShelved ? ceilU(laneReach + 2 * U) * U + lanesW : 0;
|
|
1781
|
+
const inlineW = Math.max(0, ...inlineSide.map((il) => il.offset + il.len));
|
|
1782
|
+
// shelf: the IC's own labels, the stub, the lanes, then the runs. A
|
|
1783
|
+
// straight hang sits within the stub and the cell's own margin and
|
|
1784
|
+
// channel, so a side whose hangs are all straight and carries no
|
|
1785
|
+
// inline run reserves nothing (a five-part board went from A5 to A4
|
|
1786
|
+
// when it did).
|
|
1787
|
+
const shelf = anyShelved ? ceilU(laneReach + STUB * U) + 2 + Math.ceil(lanesW / U) + (inlineSide.length ? ceilU(inlineW - lanesW - laneReach) : 0) : inlineSide.length ? ceilU(inlineW + STUB * U) + 2 : 0;
|
|
1788
|
+
if (!measured?.has(icKey)) {
|
|
1789
|
+
if (dx === 1) dims.shelfR = shelf;
|
|
1790
|
+
else dims.shelfL = shelf;
|
|
1791
|
+
dims.w += shelf;
|
|
1792
|
+
}
|
|
1793
|
+
// y-down relative to the IC origin: the body spans -maxY..-minY
|
|
1794
|
+
const lowRow = Math.max(...side.map((hg) => -hg.pin.y));
|
|
1795
|
+
const highRow = Math.min(...side.map((hg) => -hg.pin.y));
|
|
1796
|
+
const deepest = Math.max(-dims.body.minY, ...side.map((hg) => (hg.dir === 1 ? (hg.straight ? -hg.pin.y : lowRow) + HANG_GAP + hg.h : -hg.pin.y)), ...inlineSide.map((il) => -il.pin.y + 3 * U));
|
|
1797
|
+
// what this side uses below the body's top, and how far its labels
|
|
1798
|
+
// reach: the shelf below that is free for the infill pass
|
|
1799
|
+
const laneDepth = Math.max(-dims.body.maxY, ...side.map((hg) => (hg.dir === 1 ? (hg.straight ? -hg.pin.y : lowRow) + HANG_GAP + hg.h : -hg.pin.y)), ...inlineSide.map((il) => -il.pin.y + 3 * U));
|
|
1800
|
+
const usedBelow = ceilU(laneDepth - -dims.body.maxY) + 2;
|
|
1801
|
+
if (dx === 1) {
|
|
1802
|
+
dims.usedR = usedBelow;
|
|
1803
|
+
dims.reachR = ceilU(laneReach + 2 * U);
|
|
1804
|
+
} else {
|
|
1805
|
+
dims.usedL = usedBelow;
|
|
1806
|
+
dims.reachL = ceilU(laneReach + 2 * U);
|
|
1807
|
+
}
|
|
1808
|
+
const highest = Math.min(-dims.body.maxY, ...side.map((hg) => (hg.dir === -1 ? (hg.straight ? -hg.pin.y : highRow) - HANG_GAP - hg.h : -hg.pin.y)));
|
|
1809
|
+
// an inline run lies on its row; a transistor at its end reaches two
|
|
1810
|
+
// rows above and below it (collector and emitter stubs)
|
|
1811
|
+
for (const il of inlineSide) {
|
|
1812
|
+
for (const c of il.chain) {
|
|
1813
|
+
if (!controlPinOf(c.key)) continue;
|
|
1814
|
+
const b = bodyBoundsOf(instByKey.get(c.key)!.sym);
|
|
1815
|
+
const reach = Math.max(b.maxY, -b.minY) + STUB * U + POWER_CLEAR_ROWS * U;
|
|
1816
|
+
if (measured?.has(icKey)) continue;
|
|
1817
|
+
dims.topPad = Math.max(dims.topPad, ceilU(-dims.body.maxY - (-il.pin.y - reach)));
|
|
1818
|
+
dims.h = Math.max(dims.h, ceilU(Math.max(deepest, -il.pin.y + reach) - Math.min(highest, -il.pin.y - reach)) + VMARGIN + 2);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
if (!measured?.has(icKey)) {
|
|
1822
|
+
dims.topPad = Math.max(dims.topPad, ceilU(-dims.body.maxY - highest));
|
|
1823
|
+
// the chain's end already carries the symbol's own room; two
|
|
1824
|
+
// units of air below it, not a full margin (a button block's cell
|
|
1825
|
+
// was 39 mm tall where a drafter draws it on a 25 mm pitch)
|
|
1826
|
+
dims.h = Math.max(dims.h, ceilU(deepest - highest) + VMARGIN + 2);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
// Node hangs: the far end of a series run is a node too. An RC filter's
|
|
1831
|
+
// cap, a button's debounce cap and ESD diode sit on the net PAST the
|
|
1832
|
+
// series resistor, so they hang from the run's last part, dropping or
|
|
1833
|
+
// rising from the row just beyond its far lead, the first straight down
|
|
1834
|
+
// from the row's end and the rest on lanes past it.
|
|
1835
|
+
const nodeAnchors = new Set<string>();
|
|
1836
|
+
for (const il of [...inlines]) {
|
|
1837
|
+
const last = il.chain[il.chain.length - 1]!;
|
|
1838
|
+
if (isTransistor(last.key)) continue;
|
|
1839
|
+
const l = leadsOf(last.key)!;
|
|
1840
|
+
const farPin = l.a.number === last.nearPin ? l.b : l.a;
|
|
1841
|
+
const farNet = netByEndpoint.get(`${instByKey.get(last.key)!.ref}.${farPin.number}`);
|
|
1842
|
+
if (!farNet || (netClasses.get(farNet.name)?.cls ?? 'signal') !== 'signal') continue;
|
|
1843
|
+
const eps = farNet.pins.map(epKey).filter((e): e is { key: string; pin: string } => e !== null);
|
|
1844
|
+
let lanes = 0;
|
|
1845
|
+
for (const first of eps) {
|
|
1846
|
+
if (first.key === last.key || !hangable(first.key)) continue;
|
|
1847
|
+
const fl = leadsOf(first.key)!;
|
|
1848
|
+
const far = fl.a.number === first.pin ? fl.b : fl.a;
|
|
1849
|
+
const beyond = netByEndpoint.get(`${instByKey.get(first.key)!.ref}.${far.number}`);
|
|
1850
|
+
const cls = beyond ? (netClasses.get(beyond.name)?.cls ?? 'signal') : null;
|
|
1851
|
+
if (cls !== 'ground' && cls !== 'rail') continue; // only shunts; a second series part is a network
|
|
1852
|
+
trace(`${instByKey.get(last.key)!.ref}.${farPin.number} ${farNet.name} (node): ${instByKey.get(first.key)!.ref} ${cls === 'ground' ? 'hangs down' : 'rises'}`);
|
|
1853
|
+
claimHang(last.key, farPin, il.dx, first, cls === 'ground' ? 1 : -1);
|
|
1854
|
+
const hg = hangs[hangs.length - 1]!;
|
|
1855
|
+
hg.slot = lanes;
|
|
1856
|
+
hg.straight = lanes === 0;
|
|
1857
|
+
lanes++;
|
|
1858
|
+
nodeAnchors.add(last.key);
|
|
1859
|
+
}
|
|
1860
|
+
if (!lanes) continue;
|
|
1861
|
+
// the run's owner reserves the lanes and the drop below them
|
|
1862
|
+
const nodeHangs = hangs.filter((hg) => hg.ic === last.key);
|
|
1863
|
+
const pitch = Math.max(0, ...nodeHangs.map((hg) => hg.w)) + U;
|
|
1864
|
+
const add = (lanes - 1) * pitch + 2 * U + Math.max(0, ...nodeHangs.map((hg) => hg.w)) / 2;
|
|
1865
|
+
il.len += add;
|
|
1866
|
+
const dims = cellDims.get(il.ic)!;
|
|
1867
|
+
if (measured?.has(il.ic)) continue;
|
|
1868
|
+
if (il.dx === 1) dims.shelfR += ceilU(add);
|
|
1869
|
+
else dims.shelfL += ceilU(add);
|
|
1870
|
+
dims.w += ceilU(add);
|
|
1871
|
+
const rowRel = -il.pin.y;
|
|
1872
|
+
const hDown = Math.max(0, ...nodeHangs.filter((hg) => hg.dir === 1).map((hg) => hg.h));
|
|
1873
|
+
const hUp = Math.max(0, ...nodeHangs.filter((hg) => hg.dir === -1).map((hg) => hg.h));
|
|
1874
|
+
const bottom = Math.max(-dims.body.minY, rowRel + HANG_GAP + hDown);
|
|
1875
|
+
const top = Math.min(-dims.body.maxY, rowRel - HANG_GAP - hUp);
|
|
1876
|
+
dims.topPad = Math.max(dims.topPad, ceilU(-dims.body.maxY - top));
|
|
1877
|
+
dims.h = Math.max(dims.h, ceilU(bottom - top) + VMARGIN + 2);
|
|
1878
|
+
}
|
|
1879
|
+
void nodeAnchors;
|
|
1880
|
+
for (const col of columns) {
|
|
1881
|
+
for (let i = col.length - 1; i >= 0; i--) if (hungKeys.has(col[i]!)) col.splice(i, 1);
|
|
1882
|
+
}
|
|
1883
|
+
for (let i = columns.length - 1; i >= 0; i--) if (!columns[i]!.length) columns.splice(i, 1);
|
|
1884
|
+
// The budget must leave room for the label TEXT facing the sheet edges:
|
|
1885
|
+
// a band filled to the full usable width puts the leftmost column's
|
|
1886
|
+
// left-facing labels outside the frame (#220 phase 1), and no later
|
|
1887
|
+
// shift can fix both edges at once.
|
|
1888
|
+
const ext = groupExtents.get(gname)!;
|
|
1889
|
+
const bandW = Math.max(1, bandBudgetW - ceilU(ext.left) - ceilU(ext.right));
|
|
1890
|
+
|
|
1891
|
+
// Height the decoupling bank adds UNDER the columns once they span
|
|
1892
|
+
// `blockW` units: the same row-wrap the bank placement below performs.
|
|
1893
|
+
const bankHeight = (blockW: number): number => {
|
|
1894
|
+
if (!caps.length) return 0;
|
|
1895
|
+
const capBudget = Math.min(bandW, Math.max(64, blockW));
|
|
1896
|
+
let rows = 1;
|
|
1897
|
+
let x = 0;
|
|
1898
|
+
for (const c of caps) {
|
|
1899
|
+
const b = bodyBoundsOf(c.sym);
|
|
1900
|
+
const w = ceilU(b.maxX - b.minX) + 2 * MARGIN;
|
|
1901
|
+
if (x > 0 && x + w > capBudget) {
|
|
1902
|
+
rows++;
|
|
1903
|
+
x = 0;
|
|
1904
|
+
}
|
|
1905
|
+
x += w;
|
|
1906
|
+
}
|
|
1907
|
+
return MARGIN + 4 + rows * (2 * MARGIN + 6);
|
|
1908
|
+
};
|
|
729
1909
|
// Column height budget (#220 phase 4): a depth whose parts stack taller
|
|
730
1910
|
// than the budget splits into several side-by-side columns, in row
|
|
731
1911
|
// order, so a 24-part board stops drafting as one full-height strip on
|
|
732
1912
|
// a sheet two sizes too large. Cells never shrink; only the arrangement
|
|
733
1913
|
// changes, so readability is untouched.
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1914
|
+
//
|
|
1915
|
+
// The bank sits under the columns and counts against the SAME budget:
|
|
1916
|
+
// a group whose columns already fit the budget got no shorter under the
|
|
1917
|
+
// compaction retries while its bulk-cap rows pushed the group past the
|
|
1918
|
+
// sheet (a 30-part power-input group missed A1 by 8 mm that way). The
|
|
1919
|
+
// bank's rows are estimated at the columns' pre-split width — a lower
|
|
1920
|
+
// bound on the width they end up with, so an upper bound on rows.
|
|
1921
|
+
const colsW =
|
|
1922
|
+
columns.reduce((s, col) => s + Math.max(...col.map((r) => cellDims.get(r)!.w)), 0) +
|
|
1923
|
+
CHANNEL * Math.max(0, columns.length - 1);
|
|
1924
|
+
const sheetBudget = colBudgetH === Infinity ? Infinity : Math.max(1, colBudgetH - bankHeight(colsW));
|
|
1925
|
+
|
|
1926
|
+
// ---------- in-group column balance ----------
|
|
1927
|
+
// A depth whose parts stack into one strip draws the group as a tall
|
|
1928
|
+
// thin column beside a short IC with the rest of the box empty: an
|
|
1929
|
+
// amplifier group put 20 filter caps in one 500 mm column next to a
|
|
1930
|
+
// 120 mm IC, and the box was two-thirds air. A drafter runs such parts
|
|
1931
|
+
// in several columns no taller than the IC they serve. So every column
|
|
1932
|
+
// is budgeted at the taller of the group's tallest IC cell and the side
|
|
1933
|
+
// of the square the group's cells would fill, and one taller than that
|
|
1934
|
+
// splits into equal-height side-by-side columns. The IC's CELL, not its
|
|
1935
|
+
// column: a transistor or an eFuse sharing a column with eight
|
|
1936
|
+
// resistors must not make that strip the reference height. Row order
|
|
1937
|
+
// is preserved chunk by chunk, exactly as the sheet budget splits, so
|
|
1938
|
+
// barycenter ordering and the trunk idioms are untouched.
|
|
1939
|
+
const colH = (col: string[]): number => col.reduce((s, r, i) => s + cellDims.get(r)!.h + (i ? ROW_GAP : 0), 0);
|
|
1940
|
+
const isIC = (key: string): boolean => instByKey.get(key)!.sym.pins.length >= 3;
|
|
1941
|
+
const anchorH = Math.max(0, ...members.filter((m) => isIC(m.key)).map((m) => cellDims.get(m.key)!.h));
|
|
1942
|
+
const cellArea = columns.reduce((s, c) => s + colH(c) * Math.max(...c.map((r) => cellDims.get(r)!.w)), 0);
|
|
1943
|
+
const squareSide = Math.ceil(Math.sqrt(cellArea));
|
|
1944
|
+
// Under a band budget the group is shaped to FILL the band's width, not
|
|
1945
|
+
// to be square: a column of eight test points re-rows into two of four
|
|
1946
|
+
// and the button blocks stack three deep, the grid a person draws when
|
|
1947
|
+
// the group must sit in a 250 mm column (esp32-amp's UI group went
|
|
1948
|
+
// from 364 × 150 to the width of its neighbours).
|
|
1949
|
+
// A group with no IC to lead it (buttons, test points, LEDs) has no
|
|
1950
|
+
// natural shape, and its columns line up as a strip five blocks wide;
|
|
1951
|
+
// shape it no wider than two and a half times its square side, the grid
|
|
1952
|
+
// a person draws. Groups led by an IC keep the IC's own proportions.
|
|
1953
|
+
const shapeToAspect = anchors.length === 0;
|
|
1954
|
+
const aspectBand = Math.ceil(2.5 * squareSide);
|
|
1955
|
+
const searchBand = shapeToAspect ? Math.min(aspectBand, bandW) : bandW;
|
|
1956
|
+
const targetH = shapeToAspect
|
|
1957
|
+
? Math.max(Math.ceil(cellArea / searchBand), Math.ceil(squareSide / 2))
|
|
1958
|
+
: bandBudgetW === Infinity
|
|
1959
|
+
? squareSide
|
|
1960
|
+
: Math.max(Math.ceil(cellArea / bandBudgetW), Math.ceil(squareSide / 2));
|
|
1961
|
+
const balanceH = Math.max(anchorH, targetH);
|
|
1962
|
+
|
|
1963
|
+
/** Split `col` into the fewest equal-height chunks that each fit
|
|
1964
|
+
* `budget`; a single cell never splits, and a column shorter than
|
|
1965
|
+
* `minCells` is left alone (a drafter does not re-row three parts). */
|
|
1966
|
+
const splitColumn = (col: string[], budget: number, minCells = 1): string[][] => {
|
|
1967
|
+
const total = colH(col);
|
|
1968
|
+
if (total <= budget || col.length < minCells) return [col];
|
|
1969
|
+
const k = Math.ceil(total / budget);
|
|
1970
|
+
const fill = (cap: number): string[][] => {
|
|
1971
|
+
const chunks: string[][] = [];
|
|
1972
|
+
let cur: string[] = [];
|
|
1973
|
+
let h = 0;
|
|
1974
|
+
for (const ref of col) {
|
|
1975
|
+
const add = cellDims.get(ref)!.h + (cur.length ? ROW_GAP : 0);
|
|
1976
|
+
if (cur.length && h + add > cap) {
|
|
1977
|
+
chunks.push(cur);
|
|
1978
|
+
cur = [ref];
|
|
1979
|
+
h = cellDims.get(ref)!.h;
|
|
1980
|
+
} else {
|
|
1981
|
+
cur.push(ref);
|
|
1982
|
+
h += add;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
if (cur.length) chunks.push(cur);
|
|
1986
|
+
return chunks;
|
|
1987
|
+
};
|
|
1988
|
+
// Balanced chunks first, for the look of level columns: the split of
|
|
1989
|
+
// the column into k consecutive chunks that minimises the tallest
|
|
1990
|
+
// chunk (a greedy fill to an equal target fell 7-1 on eight test
|
|
1991
|
+
// points of two heights, and 2-2-1 on five equal cells). If even
|
|
1992
|
+
// that tallest chunk is over the budget, fill to the budget instead.
|
|
1993
|
+
const hs = col.map((ref) => cellDims.get(ref)!.h);
|
|
1994
|
+
const n = hs.length;
|
|
1995
|
+
const runH = (i: number, j: number): number => hs.slice(i, j + 1).reduce((a, b) => a + b, 0) + ROW_GAP * (j - i);
|
|
1996
|
+
const bestMax: number[][] = Array.from({ length: k + 1 }, () => new Array(n + 1).fill(Infinity));
|
|
1997
|
+
const cutAt: number[][] = Array.from({ length: k + 1 }, () => new Array(n + 1).fill(0));
|
|
1998
|
+
bestMax[0]![0] = 0;
|
|
1999
|
+
for (let parts = 1; parts <= k; parts++) {
|
|
2000
|
+
for (let j = 1; j <= n; j++) {
|
|
2001
|
+
for (let i = parts - 1; i < j; i++) {
|
|
2002
|
+
const v = Math.max(bestMax[parts - 1]![i]!, runH(i, j - 1));
|
|
2003
|
+
if (v < bestMax[parts]![j]!) {
|
|
2004
|
+
bestMax[parts]![j] = v;
|
|
2005
|
+
cutAt[parts]![j] = i;
|
|
751
2006
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
if (bestMax[k]![n]! > budget) return fill(budget);
|
|
2011
|
+
const chunks: string[][] = [];
|
|
2012
|
+
for (let parts = k, j = n; parts >= 1; parts--) {
|
|
2013
|
+
const i = cutAt[parts]![j]!;
|
|
2014
|
+
chunks.unshift(col.slice(i, j));
|
|
2015
|
+
j = i;
|
|
2016
|
+
}
|
|
2017
|
+
return chunks;
|
|
2018
|
+
};
|
|
2019
|
+
const BALANCE_MIN_CELLS = 4;
|
|
2020
|
+
// Under a band budget, the lowest column height at which the group's
|
|
2021
|
+
// columns fit the band side by side in ONE band wins: shorter columns
|
|
2022
|
+
// mean more of them and a group that wraps onto bands anyway, taller
|
|
2023
|
+
// ones a group that fills the band's width in one pass (five button
|
|
2024
|
+
// blocks as two columns of three beside two columns of test points).
|
|
2025
|
+
let chosenH = balanceH;
|
|
2026
|
+
if (bandBudgetW !== Infinity || shapeToAspect) {
|
|
2027
|
+
const widthAt = (h: number): number => {
|
|
2028
|
+
const cols = columns.flatMap((col) => splitColumn(col, h, BALANCE_MIN_CELLS));
|
|
2029
|
+
// the same channel and facing-label widening the placement below applies
|
|
2030
|
+
return cols.reduce((sum, c, i) => {
|
|
2031
|
+
const next = cols[i + 1];
|
|
2032
|
+
return sum + Math.max(...c.map((r) => cellDims.get(r)!.w)) + (next ? CHANNEL + widenBy(labelExtents(c).right, labelExtents(next).left, 2 * MARGIN + CHANNEL) : 0);
|
|
2033
|
+
}, 0);
|
|
2034
|
+
};
|
|
2035
|
+
// against the band the columns actually get (the budget less the
|
|
2036
|
+
// group's own label reserves), not the raw budget
|
|
2037
|
+
const ceiling = Math.max(balanceH, ...columns.map(colH));
|
|
2038
|
+
for (let h = balanceH; h <= ceiling; h += 2) {
|
|
2039
|
+
if (widthAt(h) <= searchBand) {
|
|
2040
|
+
chosenH = h;
|
|
2041
|
+
break;
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
if (widthAt(chosenH) > searchBand) chosenH = shapeToAspect ? balanceH : ceiling;
|
|
2045
|
+
}
|
|
2046
|
+
let columnsToPlace: string[][] = columns.flatMap((col) =>
|
|
2047
|
+
splitColumn(col, chosenH, BALANCE_MIN_CELLS).flatMap((c) => (sheetBudget === Infinity ? [c] : splitColumn(c, sheetBudget))),
|
|
2048
|
+
);
|
|
2049
|
+
// Shelf infill. An IC's side shelf is as deep as its tallest lane, but
|
|
2050
|
+
// the lanes hang from the top pins and the shelf below them is empty;
|
|
2051
|
+
// small cells from later columns move into that room (the USB ESD
|
|
2052
|
+
// array under the UART bridge's transistor runs, where the hand-laid
|
|
2053
|
+
// sheet keeps it), and the column they leave narrows or disappears.
|
|
2054
|
+
const infill = new Map<string, { key: string; side: -1 | 1; relY: number }[]>();
|
|
2055
|
+
for (let ci = 0; ci < columnsToPlace.length; ci++) {
|
|
2056
|
+
for (const host of columnsToPlace[ci]!) {
|
|
2057
|
+
const d = cellDims.get(host)!;
|
|
2058
|
+
for (const side of [1, -1] as const) {
|
|
2059
|
+
const shelf = side === 1 ? d.shelfR : d.shelfL;
|
|
2060
|
+
const used = side === 1 ? d.usedR : d.usedL;
|
|
2061
|
+
const reach = side === 1 ? d.reachR ?? 0 : d.reachL ?? 0;
|
|
2062
|
+
if (!shelf || used === undefined) continue;
|
|
2063
|
+
const freeW = shelf - reach - 1;
|
|
2064
|
+
let top = d.topPad + VMARGIN + used;
|
|
2065
|
+
let freeH = d.h - top - 1;
|
|
2066
|
+
trace(`infill room: ${instByKey.get(host)!.ref} ${side === 1 ? 'right' : 'left'} shelf ${shelf}u reach ${reach}u used ${used}u -> free ${freeW}x${freeH}u`);
|
|
2067
|
+
if (freeW < 8 || freeH < 8) continue;
|
|
2068
|
+
// candidates from every other column, later ones first; a cell
|
|
2069
|
+
// leaving the host's own column shortens it
|
|
2070
|
+
const order = [...columnsToPlace.keys()].sort((a, b) => (a > ci ? 0 : a === ci ? 1 : 2) - (b > ci ? 0 : b === ci ? 1 : 2) || a - b);
|
|
2071
|
+
for (const cj of order) {
|
|
2072
|
+
if (freeH < 8) break;
|
|
2073
|
+
const col = columnsToPlace[cj]!;
|
|
2074
|
+
for (let k = col.length - 1; k >= 0 && freeH >= 8; k--) {
|
|
2075
|
+
const cand = col[k]!;
|
|
2076
|
+
if (cand === host) continue;
|
|
2077
|
+
// connectors keep the left column (the reader looks for them
|
|
2078
|
+
// there); anchors and anything with lanes of its own stay put
|
|
2079
|
+
// only a substantial part (four pins or more: an ESD array, a
|
|
2080
|
+
// level shifter) is worth tucking away; scattering test points
|
|
2081
|
+
// and LEDs into shelves reads as clutter, not economy
|
|
2082
|
+
const cinst = instByKey.get(cand)!;
|
|
2083
|
+
if (isConnector(cinst.part) || cinst.sym.pins.length < 4) continue;
|
|
2084
|
+
const cd = cellDims.get(cand)!;
|
|
2085
|
+
if (cd.shelfL || cd.shelfR || cd.w > freeW || cd.h > freeH) continue;
|
|
2086
|
+
infill.set(host, [...(infill.get(host) ?? []), { key: cand, side, relY: top }]);
|
|
2087
|
+
col.splice(k, 1);
|
|
2088
|
+
trace(`infill: ${instByKey.get(cand)!.ref} moves under the ${side === 1 ? 'right' : 'left'} shelf of ${instByKey.get(host)!.ref}`);
|
|
2089
|
+
top += cd.h + ROW_GAP;
|
|
2090
|
+
freeH -= cd.h + ROW_GAP;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
columnsToPlace = columnsToPlace.filter((c) => c.length);
|
|
2097
|
+
const hostGeom = new Map<string, { colX: number; colW: number; colShelfR: number; rowY: number }>();
|
|
2098
|
+
trace(`group "${gname}" columns (band ${bandBudgetW === Infinity ? 'inf' : bandBudgetW}u, col budget ${colBudgetH === Infinity ? 'inf' : colBudgetH}u, balance ${balanceH}u chosen ${chosenH}u): ${columnsToPlace.map((c) => `[${c.map((k) => `${instByKey.get(k)!.ref}:${cellDims.get(k)!.w}x${cellDims.get(k)!.h}`).join(' ')}]`).join(' ')}`);
|
|
755
2099
|
let colX = groupX;
|
|
756
2100
|
let groupMaxY = 0;
|
|
757
2101
|
let bandTop = 0; // y origin (units) of the current band of columns
|
|
758
2102
|
let bandCount = 1;
|
|
759
|
-
// The budget must leave room for the label TEXT facing the sheet edges:
|
|
760
|
-
// a band filled to the full usable width puts the leftmost column's
|
|
761
|
-
// left-facing labels outside the frame (#220 phase 1), and no later
|
|
762
|
-
// shift can fix both edges at once.
|
|
763
|
-
const ext = groupExtents.get(gname)!;
|
|
764
|
-
const bandW = Math.max(1, bandBudgetW - ceilU(ext.left) - ceilU(ext.right));
|
|
765
2103
|
for (let ci = 0; ci < columnsToPlace.length; ci++) {
|
|
766
2104
|
const col = columnsToPlace[ci]!;
|
|
767
2105
|
const colW = Math.max(...col.map((r) => cellDims.get(r)!.w));
|
|
2106
|
+
const colShelfL = Math.max(...col.map((r) => cellDims.get(r)!.shelfL));
|
|
2107
|
+
const colShelfR = Math.max(...col.map((r) => cellDims.get(r)!.shelfR));
|
|
2108
|
+
const colCoreW = Math.max(...col.map((r) => cellDims.get(r)!.w - cellDims.get(r)!.shelfL - cellDims.get(r)!.shelfR));
|
|
768
2109
|
// Banding (#219): a column that would tile past the width budget starts
|
|
769
2110
|
// a new band of columns below everything placed so far, the way the
|
|
770
2111
|
// shelf-wrap below re-rows whole groups. Never before the first column
|
|
@@ -778,9 +2119,14 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
778
2119
|
let rowY = bandTop;
|
|
779
2120
|
for (const ref of col) {
|
|
780
2121
|
const dims = cellDims.get(ref)!;
|
|
781
|
-
|
|
782
|
-
|
|
2122
|
+
// shared column axis (units): the core span between the shelves a
|
|
2123
|
+
// hung IC reserves beside itself
|
|
2124
|
+
const cx = colX + colShelfL + Math.floor((colW - colShelfL - colShelfR - Math.max(0, colW - colShelfL - colShelfR - colCoreW)) / 2);
|
|
783
2125
|
const b = dims.body;
|
|
2126
|
+
// the body sits at the top of its cell, under the room any up-hang
|
|
2127
|
+
// needs; a cell with nothing hung is body plus margins, so this is
|
|
2128
|
+
// its centre as before
|
|
2129
|
+
const cy = rowY + dims.topPad + VMARGIN + Math.floor(ceilU(b.maxY - b.minY) / 2);
|
|
784
2130
|
// origin so the body centers on the cell center, snapped to grid
|
|
785
2131
|
const ox = grid(cx - Math.round((b.minX + b.maxX) / 2 / U));
|
|
786
2132
|
const oy = grid(cy + Math.round((b.minY + b.maxY) / 2 / U));
|
|
@@ -795,7 +2141,9 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
795
2141
|
body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
|
|
796
2142
|
cellW: dims.w,
|
|
797
2143
|
cellH: dims.h,
|
|
2144
|
+
rot: 0,
|
|
798
2145
|
});
|
|
2146
|
+
if (infill.has(ref)) hostGeom.set(ref, { colX, colW, colShelfR, rowY });
|
|
799
2147
|
rowY += dims.h + ROW_GAP;
|
|
800
2148
|
}
|
|
801
2149
|
groupMaxY = Math.max(groupMaxY, rowY - ROW_GAP);
|
|
@@ -804,7 +2152,19 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
804
2152
|
}
|
|
805
2153
|
|
|
806
2154
|
// decoupling rows: caps in a uniform row under their owner (or the group)
|
|
807
|
-
|
|
2155
|
+
// by rail first, then by reference: caps of one rail sit side by side
|
|
2156
|
+
// and share one trunk and one pair of symbols (sorted by reference
|
|
2157
|
+
// alone, a +5V cap numbered after the +3V3 caps stood apart with its
|
|
2158
|
+
// own two symbols)
|
|
2159
|
+
const railOf = (key: string): string => {
|
|
2160
|
+
const inst = instByKey.get(key)!;
|
|
2161
|
+
for (const p of inst.sym.pins) {
|
|
2162
|
+
const n = netByEndpoint.get(`${inst.ref}.${p.number}`);
|
|
2163
|
+
if (n && (netClasses.get(n.name)?.cls ?? 'signal') === 'rail') return n.name;
|
|
2164
|
+
}
|
|
2165
|
+
return '';
|
|
2166
|
+
};
|
|
2167
|
+
const capRefs = caps.map((c) => c.key).sort((a, b) => railOf(a).localeCompare(railOf(b)) || a.localeCompare(b, undefined, { numeric: true }));
|
|
808
2168
|
if (capRefs.length) {
|
|
809
2169
|
// The bank stacks under the circuit at the circuit's own width, the
|
|
810
2170
|
// way a hand-drawn sheet does — a 45-cap ribbon run out to the band
|
|
@@ -812,34 +2172,97 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
812
2172
|
// it deserved (#233). The floor keeps a short bank (four typical cap
|
|
813
2173
|
// cells) on one row even when the circuit above it is narrower.
|
|
814
2174
|
const blockW = Math.max(64, colX - groupX);
|
|
815
|
-
|
|
2175
|
+
let capBudget = Math.min(bandW, blockW);
|
|
2176
|
+
// Under a height budget the bank is part of the group's height too:
|
|
2177
|
+
// when its rows at the circuit's width would run past the budget,
|
|
2178
|
+
// widen the bank — only as far as the rows need, never past the
|
|
2179
|
+
// band — instead of letting it decide the sheet on its own.
|
|
2180
|
+
if (colBudgetH !== Infinity) {
|
|
2181
|
+
const capWidths = capRefs.map((ref) => {
|
|
2182
|
+
const b = bodyBoundsOf(instByKey.get(ref)!.sym);
|
|
2183
|
+
return ceilU(b.maxX - b.minX) + 2 * MARGIN;
|
|
2184
|
+
});
|
|
2185
|
+
const rowsAt = (budget: number): number => {
|
|
2186
|
+
let rows = 1;
|
|
2187
|
+
let x = 0;
|
|
2188
|
+
for (const w of capWidths) {
|
|
2189
|
+
if (x > 0 && x + w > budget) {
|
|
2190
|
+
rows++;
|
|
2191
|
+
x = 0;
|
|
2192
|
+
}
|
|
2193
|
+
x += w;
|
|
2194
|
+
}
|
|
2195
|
+
return rows;
|
|
2196
|
+
};
|
|
2197
|
+
const rowsAllowed = Math.max(1, Math.floor((colBudgetH - groupMaxY - (MARGIN + 4)) / (2 * MARGIN + 6)));
|
|
2198
|
+
if (rowsAt(capBudget) > rowsAllowed) {
|
|
2199
|
+
const total = capWidths.reduce((a, b) => a + b, 0);
|
|
2200
|
+
capBudget = Math.min(bandW, Math.max(capBudget, Math.ceil(total / rowsAllowed) + Math.max(...capWidths)));
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
816
2203
|
let capX = groupX;
|
|
817
2204
|
let capY = groupMaxY + MARGIN + 4;
|
|
2205
|
+
let bankRowH = 2 * MARGIN + 6;
|
|
2206
|
+
// A bank member with horizontal leads (a TVS diode drawn lying down)
|
|
2207
|
+
// stands up like the caps beside it, its rail lead on top; lying in
|
|
2208
|
+
// the row it carried its ground symbol sideways under the next cap's
|
|
2209
|
+
// name. Each member is turned, and measured, by its own leads.
|
|
2210
|
+
const bankRotOf = (ref: string): number => {
|
|
2211
|
+
const inst = instByKey.get(ref)!;
|
|
2212
|
+
if (inst.sym.pins.length !== 2 || !inst.sym.pins.every((p) => outward(p).dx !== 0)) return 0;
|
|
2213
|
+
const railPin = inst.sym.pins.find((p) => (netClasses.get(netByEndpoint.get(`${inst.ref}.${p.number}`)?.name ?? '')?.cls ?? 'signal') === 'rail') ?? inst.sym.pins[0]!;
|
|
2214
|
+
return orientFor(ref, railPin.number, { dx: 0, dy: -1 }) ?? 90;
|
|
2215
|
+
};
|
|
818
2216
|
for (const ref of capRefs) {
|
|
819
2217
|
const inst = instByKey.get(ref)!;
|
|
820
|
-
const
|
|
2218
|
+
const rot = bankRotOf(ref);
|
|
2219
|
+
const sym = rotatedSym(inst.sym, rot);
|
|
2220
|
+
const b = bodyBoundsOf(sym);
|
|
2221
|
+
// a measured cap takes the room its drawing needed (its own fields,
|
|
2222
|
+
// the rail bar and name above, the ground below), plus the pad
|
|
2223
|
+
const mo = measured?.get(ref);
|
|
2224
|
+
const capW = mo ? ceilU(mo.left) + ceilU(b.maxX - b.minX) + ceilU(mo.right) + 2 * MEASURE_PAD : ceilU(b.maxX - b.minX) + 2 * MARGIN;
|
|
2225
|
+
const capLeft = mo ? ceilU(mo.left) + MEASURE_PAD : MARGIN;
|
|
2226
|
+
const capTop = mo ? ceilU(mo.top) + MEASURE_PAD : MARGIN;
|
|
2227
|
+
const capRowH = mo ? capTop + ceilU(b.maxY - b.minY) + ceilU(mo.bottom) + MEASURE_PAD : 2 * MARGIN + 6;
|
|
2228
|
+
bankRowH = Math.max(bankRowH, capRowH);
|
|
821
2229
|
// Banding (#219): a decoupling bank wider than the budget wraps onto
|
|
822
|
-
// another uniform row rather than running past the frame
|
|
823
|
-
|
|
2230
|
+
// another uniform row rather than running past the frame; and it
|
|
2231
|
+
// wraps BEFORE a rail whose caps would not all fit the row, so one
|
|
2232
|
+
// rail's caps stay on one trunk (PVDD's pair split across two rows
|
|
2233
|
+
// left the second cap with its own two symbols).
|
|
2234
|
+
const railStart = capRefs.indexOf(ref) === 0 || railOf(capRefs[capRefs.indexOf(ref) - 1]!) !== railOf(ref);
|
|
2235
|
+
let railRunW = 0;
|
|
2236
|
+
if (railStart) {
|
|
2237
|
+
for (let k = capRefs.indexOf(ref); k < capRefs.length && railOf(capRefs[k]!) === railOf(ref); k++) {
|
|
2238
|
+
const kb = bodyBoundsOf(rotatedSym(instByKey.get(capRefs[k]!)!.sym, bankRotOf(capRefs[k]!)));
|
|
2239
|
+
const km = measured?.get(capRefs[k]!);
|
|
2240
|
+
railRunW += km ? ceilU(km.left) + ceilU(kb.maxX - kb.minX) + ceilU(km.right) + 2 * MEASURE_PAD : ceilU(kb.maxX - kb.minX) + 2 * MARGIN;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
const needW = railStart && railRunW <= capBudget ? railRunW : capW;
|
|
2244
|
+
if (capX > groupX && capX + needW - groupX > capBudget) {
|
|
824
2245
|
capX = groupX;
|
|
825
|
-
capY +=
|
|
2246
|
+
capY += bankRowH;
|
|
2247
|
+
bankRowH = capRowH;
|
|
826
2248
|
}
|
|
827
|
-
const ox = grid(capX + MARGIN);
|
|
828
|
-
const oy = grid(capY + MARGIN);
|
|
2249
|
+
const ox = mo ? grid(capX + capLeft - Math.floor(b.minX / U)) : grid(capX + MARGIN);
|
|
2250
|
+
const oy = mo ? grid(capY + capTop + Math.ceil(b.maxY / U)) : grid(capY + MARGIN);
|
|
829
2251
|
placed.set(ref, {
|
|
830
2252
|
part: inst.part,
|
|
831
2253
|
refDes: inst.ref,
|
|
832
2254
|
unit: inst.unit,
|
|
833
|
-
sym
|
|
2255
|
+
sym,
|
|
834
2256
|
x: ox,
|
|
835
2257
|
y: oy,
|
|
836
2258
|
body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
|
|
837
|
-
cellW:
|
|
838
|
-
cellH:
|
|
2259
|
+
cellW: capW,
|
|
2260
|
+
cellH: capRowH,
|
|
2261
|
+
rot,
|
|
839
2262
|
});
|
|
840
|
-
capX +=
|
|
2263
|
+
capX += capW;
|
|
841
2264
|
}
|
|
842
|
-
groupMaxY = capY +
|
|
2265
|
+
groupMaxY = capY + bankRowH;
|
|
843
2266
|
}
|
|
844
2267
|
|
|
845
2268
|
// ---------- idiom micro-templates and the alignment pass (7.5/7.5a) ----------
|
|
@@ -892,6 +2315,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
892
2315
|
body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
|
|
893
2316
|
cellW: prev.cellW,
|
|
894
2317
|
cellH: prev.cellH,
|
|
2318
|
+
rot: prev.rot,
|
|
895
2319
|
};
|
|
896
2320
|
};
|
|
897
2321
|
/** Apply candidate moves unless any moved body lands within a grid unit of
|
|
@@ -946,7 +2370,10 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
946
2370
|
const end = { x: p.x + o.dx * len * U, y: p.y + o.dy * len * U };
|
|
947
2371
|
if (!ownEps.has(ep)) {
|
|
948
2372
|
for (const q of [p, end]) {
|
|
949
|
-
if (sameCoord(q.x, axisX) && q.y > yMin - U && q.y < yMax + U)
|
|
2373
|
+
if (sameCoord(q.x, axisX) && q.y > yMin - U && q.y < yMax + U) {
|
|
2374
|
+
trace(`axis x=${axisX} blocked by ${ep} (${net.name}) at y=${q.y}`);
|
|
2375
|
+
return false;
|
|
2376
|
+
}
|
|
950
2377
|
}
|
|
951
2378
|
}
|
|
952
2379
|
// A horizontal stub SEGMENT crossing the axis exactly at a chain
|
|
@@ -964,7 +2391,10 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
964
2391
|
if (row && row.net !== net.name) {
|
|
965
2392
|
const lo = Math.min(p.x, end.x) - 0.001;
|
|
966
2393
|
const hi = Math.max(p.x, end.x) + 0.001;
|
|
967
|
-
if (axisX >= lo && axisX <= hi)
|
|
2394
|
+
if (axisX >= lo && axisX <= hi) {
|
|
2395
|
+
trace(`axis x=${axisX} crossed by the stub of ${ep} (${net.name}) on a ${row.net} row`);
|
|
2396
|
+
return false;
|
|
2397
|
+
}
|
|
968
2398
|
}
|
|
969
2399
|
}
|
|
970
2400
|
}
|
|
@@ -972,7 +2402,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
972
2402
|
return true;
|
|
973
2403
|
};
|
|
974
2404
|
/** The room a rail/ground end grows past its pin: stub, bar, value text. */
|
|
975
|
-
const POWER_CLEAR =
|
|
2405
|
+
const POWER_CLEAR = 5 * U;
|
|
976
2406
|
const powerEndBox = (axisX: number, pinY: number, dir: -1 | 1): Bounds => ({
|
|
977
2407
|
minX: axisX - 3 * U,
|
|
978
2408
|
maxX: axisX + 3 * U,
|
|
@@ -986,8 +2416,9 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
986
2416
|
segments: { axisX: number; ys: number[]; conn?: { y: number; net: string }[] }[],
|
|
987
2417
|
moves: Map<string, Placed>,
|
|
988
2418
|
clearBoxes: Bounds[] = [],
|
|
2419
|
+
ownOverride?: Set<string>,
|
|
989
2420
|
): boolean => {
|
|
990
|
-
const own = ownEndpoints(moves.keys());
|
|
2421
|
+
const own = ownOverride ?? ownEndpoints(moves.keys());
|
|
991
2422
|
const movedRefs = new Set(moves.keys());
|
|
992
2423
|
for (const seg of segments) {
|
|
993
2424
|
// the pad covers the power stub and symbol a rail/ground end grows
|
|
@@ -999,7 +2430,10 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
999
2430
|
for (const box of clearBoxes) {
|
|
1000
2431
|
for (const [oref, op] of placed) {
|
|
1001
2432
|
if (moves.has(oref)) continue;
|
|
1002
|
-
if (padOverlap(box, op.body, 0))
|
|
2433
|
+
if (padOverlap(box, op.body, 0)) {
|
|
2434
|
+
trace(`power end at x=${(box.minX + box.maxX) / 2} would sit on ${oref}`);
|
|
2435
|
+
return false;
|
|
2436
|
+
}
|
|
1003
2437
|
}
|
|
1004
2438
|
}
|
|
1005
2439
|
if (!applyMoves(moves)) return false;
|
|
@@ -1046,6 +2480,206 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1046
2480
|
if (moves.size) finalizeMoves(segments, moves, clearBoxes);
|
|
1047
2481
|
}
|
|
1048
2482
|
|
|
2483
|
+
// Pin-anchored hangs claimed above: each chain drops from its pin's row
|
|
2484
|
+
// into its shelf slot. Slot 0 sits past the IC's labels on that side,
|
|
2485
|
+
// so the wire pass draws pin, stub, a short branch and the chain's
|
|
2486
|
+
// stub as one local route; deeper slots stagger outward. A hang the
|
|
2487
|
+
// clearance check refuses goes to the leftover column at the group's
|
|
2488
|
+
// right edge, never on top of something.
|
|
2489
|
+
const leftovers: string[] = [];
|
|
2490
|
+
const placeInfill = (): void => {
|
|
2491
|
+
for (const [host, cells] of infill) {
|
|
2492
|
+
const g = hostGeom.get(host);
|
|
2493
|
+
const d = cellDims.get(host)!;
|
|
2494
|
+
if (!g) continue;
|
|
2495
|
+
for (const f of cells) {
|
|
2496
|
+
const cd = cellDims.get(f.key)!;
|
|
2497
|
+
const fb = cd.body;
|
|
2498
|
+
const x0 = f.side === 1 ? g.colX + g.colW - g.colShelfR + (d.reachR ?? 0) : g.colX + 1;
|
|
2499
|
+
const fcx = x0 + Math.floor(cd.w / 2);
|
|
2500
|
+
const fcy = g.rowY + f.relY + Math.floor(cd.h / 2);
|
|
2501
|
+
const fox = grid(fcx - Math.round((fb.minX + fb.maxX) / 2 / U));
|
|
2502
|
+
const foy = grid(fcy + Math.round((fb.minY + fb.maxY) / 2 / U));
|
|
2503
|
+
placed.set(f.key, placeCell(f.key, fox, foy));
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
};
|
|
2507
|
+
const placeCell = (key: string, ox: number, oy: number, rot = 0, mirror?: 'y'): Placed => {
|
|
2508
|
+
const inst = instByKey.get(key)!;
|
|
2509
|
+
const dims = cellDims.get(key)!;
|
|
2510
|
+
const sym = transformSym(inst.sym, rot, mirror);
|
|
2511
|
+
const b = bodyBoundsOf(sym);
|
|
2512
|
+
return {
|
|
2513
|
+
part: inst.part,
|
|
2514
|
+
refDes: inst.ref,
|
|
2515
|
+
unit: inst.unit,
|
|
2516
|
+
sym,
|
|
2517
|
+
x: ox,
|
|
2518
|
+
y: oy,
|
|
2519
|
+
body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
|
|
2520
|
+
cellW: dims.w,
|
|
2521
|
+
cellH: dims.h,
|
|
2522
|
+
rot,
|
|
2523
|
+
...(mirror ? { mirror } : {}),
|
|
2524
|
+
};
|
|
2525
|
+
};
|
|
2526
|
+
placeInfill();
|
|
2527
|
+
// Inline runs: each part lies on its pin's row with its near lead
|
|
2528
|
+
// toward the IC, one gap past the stub, the next part one gap on. The
|
|
2529
|
+
// wire pass then draws pin, stub, gap and lead as one straight line.
|
|
2530
|
+
for (const il of inlines) {
|
|
2531
|
+
const icPl = placed.get(il.ic);
|
|
2532
|
+
if (!icPl) {
|
|
2533
|
+
leftovers.push(...il.chain.map((c) => c.key));
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
const at = pinAt(icPl, il.pin);
|
|
2537
|
+
const s = { x: at.x + il.dx * STUB * U, y: at.y };
|
|
2538
|
+
const moves = new Map<string, Placed>();
|
|
2539
|
+
let cursor = s.x + il.dx * il.offset; // x of the last connection point on the row
|
|
2540
|
+
let ok = true;
|
|
2541
|
+
for (const c of il.chain) {
|
|
2542
|
+
const ctl = controlPinOf(c.key);
|
|
2543
|
+
// a transistor keeps collector up and emitter down; it is mirrored,
|
|
2544
|
+
// never turned, so its base faces the part that drives it
|
|
2545
|
+
const mirror: 'y' | undefined = ctl ? (outward(ctl).dx === -il.dx ? undefined : 'y') : undefined;
|
|
2546
|
+
const rot = ctl ? 0 : orientFor(c.key, c.nearPin, { dx: -il.dx, dy: 0 });
|
|
2547
|
+
if (rot === null) {
|
|
2548
|
+
ok = false;
|
|
2549
|
+
break;
|
|
2550
|
+
}
|
|
2551
|
+
cursor = cursor + il.dx * c.gap; // the next near lead
|
|
2552
|
+
const rs = transformSym(instByKey.get(c.key)!.sym, rot, mirror);
|
|
2553
|
+
const near = rs.pins.find((pn) => pn.number === c.nearPin)!;
|
|
2554
|
+
const ox = grid(Math.round((cursor - near.x) / U));
|
|
2555
|
+
const oy = grid(Math.round((s.y + near.y) / U));
|
|
2556
|
+
moves.set(c.key, placeCell(c.key, ox, oy, rot, mirror));
|
|
2557
|
+
cursor = cursor + il.dx * spanOf(c.key);
|
|
2558
|
+
}
|
|
2559
|
+
if (ok) {
|
|
2560
|
+
for (const [key, cand] of moves) placed.set(key, cand);
|
|
2561
|
+
// no axis to clear: the run's wires are the row itself, which the
|
|
2562
|
+
// wire pass checks segment by segment; bodies must still fit
|
|
2563
|
+
if (!finalizeMoves([], moves, [], new Set())) {
|
|
2564
|
+
for (const key of moves.keys()) placed.delete(key);
|
|
2565
|
+
ok = false;
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
if (!ok) {
|
|
2569
|
+
leftovers.push(...il.chain.map((c) => c.key));
|
|
2570
|
+
for (const c of il.chain) boundTo.delete(c.key);
|
|
2571
|
+
const refs = il.chain.map((c) => instByKey.get(c.key)!.ref).join(', ');
|
|
2572
|
+
const note = `inline refused: ${refs} could not lie on ${instByKey.get(il.ic)!.ref}.${il.pin.number}'s row in "${gname}"; drawn in a column instead`;
|
|
2573
|
+
trace(note);
|
|
2574
|
+
if (!notes.includes(note)) notes.push(note);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
for (const hg of hangs) {
|
|
2578
|
+
const icPl = placed.get(hg.ic);
|
|
2579
|
+
if (!icPl) {
|
|
2580
|
+
leftovers.push(...hg.chain);
|
|
2581
|
+
continue;
|
|
2582
|
+
}
|
|
2583
|
+
// a node anchor is a hung part, placed turned: read its pin as drawn
|
|
2584
|
+
const pinNow = icPl.sym.pins.find((p) => p.number === hg.pin.number) ?? hg.pin;
|
|
2585
|
+
const at = pinAt(icPl, pinNow);
|
|
2586
|
+
const s = { x: at.x + hg.dx * STUB * U, y: at.y };
|
|
2587
|
+
const side = hangs.filter((o) => o.ic === hg.ic && o.dx === hg.dx);
|
|
2588
|
+
const pitch = Math.max(0, ...side.map((o) => o.w)) + U;
|
|
2589
|
+
const ext = labelExtents([hg.ic]);
|
|
2590
|
+
const reach = sideReach(hg.ic, hg.dx);
|
|
2591
|
+
void ext;
|
|
2592
|
+
const firstSlot = grid(Math.round((s.x + hg.dx * (reach + 2 * U + pitch / 2)) / U));
|
|
2593
|
+
const slotX = grid(Math.round((firstSlot + hg.dx * hg.slot * pitch) / U));
|
|
2594
|
+
trace(`hang ${hg.chain.map((k) => instByKey.get(k)!.ref).join('+')} on ${instByKey.get(hg.ic)!.ref}.${hg.pin.number}: stub (${s.x}, ${s.y}) reach ${reach.toFixed(2)} pitch ${pitch.toFixed(2)} slot ${hg.slot} -> x ${hg.straight ? s.x : slotX}${hg.straight ? ' (straight)' : ''}`);
|
|
2595
|
+
const netOfHang = (key: string, pinN: string): string => netByEndpoint.get(`${instByKey.get(key)!.ref}.${pinN}`)?.name ?? '';
|
|
2596
|
+
let placedOk = false;
|
|
2597
|
+
// dead straight on the stub axis when the pin-neighbour test found
|
|
2598
|
+
// the rows free (AC-16.31), else the reserved shelf slot. Trying the
|
|
2599
|
+
// axis regardless passed the clearance check — a wire may legally
|
|
2600
|
+
// cross a neighbouring rail stub — and drew exactly that crossing
|
|
2601
|
+
// on the Tier C divider, twice.
|
|
2602
|
+
const axes = hg.straight ? [s.x] : [slotX];
|
|
2603
|
+
// Chains on one side start level: drops below the side's lowest hung
|
|
2604
|
+
// pin row, rises above its highest. Started at its own row, a chain
|
|
2605
|
+
// from one pin put its body across the next pin's row, and that
|
|
2606
|
+
// pin's run out to its lane hit the body (U1's FLT pull-up against
|
|
2607
|
+
// the OVLO divider). Level bases turn that into a wire crossing at
|
|
2608
|
+
// worst, and the resistors of a comb line up the way a drafter
|
|
2609
|
+
// lines them up. A straight hang keeps its own row.
|
|
2610
|
+
const sidePinYsAbs = side.map((o) => pinAt(icPl, icPl.sym.pins.find((p) => p.number === o.pin.number) ?? o.pin).y);
|
|
2611
|
+
const baseY = hg.straight ? s.y : hg.dir === 1 ? Math.max(...sidePinYsAbs) : Math.min(...sidePinYsAbs);
|
|
2612
|
+
for (const axisX of axes) {
|
|
2613
|
+
for (let lift = 0; lift < 3 && !placedOk; lift++) {
|
|
2614
|
+
// the near lead of the first part sits one gap from the base row,
|
|
2615
|
+
// and each further part one gap on, away from the IC
|
|
2616
|
+
let cursor = grid(Math.round((baseY + hg.dir * HANG_GAP) / U)) + hg.dir * lift * 2 * U;
|
|
2617
|
+
const moves = new Map<string, Placed>();
|
|
2618
|
+
const conn: { y: number; net: string }[] = [{ y: s.y, net: netOfHang(hg.ic, hg.pin.number) }];
|
|
2619
|
+
const startY = cursor;
|
|
2620
|
+
const nearPins = hangNearPins.get(hg.chain.join('+'))!;
|
|
2621
|
+
for (const [i, key] of hg.chain.entries()) {
|
|
2622
|
+
// turned so the near lead faces the row (up for a hang that
|
|
2623
|
+
// drops, down for one that rises) and the far lead points away
|
|
2624
|
+
const rot = orientFor(key, nearPins[i]!, { dx: 0, dy: -hg.dir }) ?? 0;
|
|
2625
|
+
const rs = rotatedSym(instByKey.get(key)!.sym, rot);
|
|
2626
|
+
const near = rs.pins.find((pn) => pn.number === nearPins[i])!;
|
|
2627
|
+
const farLead = rs.pins.find((pn) => pn.number !== nearPins[i]) ?? near;
|
|
2628
|
+
const span = spanOf(key, rot);
|
|
2629
|
+
moves.set(key, placeCell(key, axisX - near.x, cursor + near.y, rot));
|
|
2630
|
+
conn.push({ y: cursor, net: netOfHang(key, near.number) }, { y: cursor + hg.dir * span, net: netOfHang(key, farLead.number) });
|
|
2631
|
+
cursor = cursor + hg.dir * (span + HANG_GAP);
|
|
2632
|
+
}
|
|
2633
|
+
const endY = cursor - hg.dir * HANG_GAP;
|
|
2634
|
+
const clearBoxes: Bounds[] = hg.ends === 'power' ? [powerEndBox(axisX, endY, hg.dir)] : [];
|
|
2635
|
+
const ys = [Math.min(startY, endY, s.y), Math.max(startY, endY, s.y)];
|
|
2636
|
+
// Only the nets that run ALONG the axis are the chain's own: the
|
|
2637
|
+
// anchor net and each link between chain parts. The rail or ground
|
|
2638
|
+
// at the far end is not — a pull-up's rail is also the IC's supply
|
|
2639
|
+
// pin, and counting it as own let an axis run straight through
|
|
2640
|
+
// that pin (esp32-amp's BUCK_EN divider on U4's PVDD pin; the
|
|
2641
|
+
// router refused the wire, so the net shipped labelled).
|
|
2642
|
+
const axisNets = new Set(conn.map((c) => c.net));
|
|
2643
|
+
const own = new Set<string>();
|
|
2644
|
+
for (const net of intent.nets) if (axisNets.has(net.name)) for (const ep of net.pins) own.add(ep);
|
|
2645
|
+
// the clearance check reads a chain's OWN connection points from the
|
|
2646
|
+
// placed map; hung parts have no column position, so they go in as
|
|
2647
|
+
// their candidates first and come out again if the check refuses
|
|
2648
|
+
for (const [key, cand] of moves) placed.set(key, cand);
|
|
2649
|
+
if (finalizeMoves([{ axisX, ys, conn }], moves, clearBoxes, own)) placedOk = true;
|
|
2650
|
+
else for (const key of moves.keys()) placed.delete(key);
|
|
2651
|
+
}
|
|
2652
|
+
if (placedOk) break;
|
|
2653
|
+
}
|
|
2654
|
+
if (!placedOk) {
|
|
2655
|
+
leftovers.push(...hg.chain);
|
|
2656
|
+
for (const k of hg.chain) boundTo.delete(k);
|
|
2657
|
+
const refs = hg.chain.map((k) => instByKey.get(k)!.ref).join(', ');
|
|
2658
|
+
const note = `hang refused: ${refs} could not be placed cleanly on ${instByKey.get(hg.ic)!.ref}.${hg.pin.number} in "${gname}"; drawn in a column instead`;
|
|
2659
|
+
trace(note);
|
|
2660
|
+
if (!notes.includes(note)) notes.push(note);
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
if (leftovers.length) {
|
|
2664
|
+
// a column of their own past everything the group placed so far
|
|
2665
|
+
let x = Math.max(groupX, ...[...placed.values()].filter((p) => groupOf.get([...placed.entries()].find(([, q]) => q === p)?.[0] ?? '') === gname).map((p) => Math.ceil(p.body.maxX / U) + MARGIN)) + CHANNEL;
|
|
2666
|
+
const colW = Math.max(...leftovers.map((k) => cellDims.get(k)!.w));
|
|
2667
|
+
let rowY = bandTop;
|
|
2668
|
+
for (const key of leftovers) {
|
|
2669
|
+
const dims = cellDims.get(key)!;
|
|
2670
|
+
const b = dims.body;
|
|
2671
|
+
const cx = x + Math.floor(colW / 2);
|
|
2672
|
+
const cy = rowY + Math.floor(dims.h / 2);
|
|
2673
|
+
const ox = grid(cx - Math.round((b.minX + b.maxX) / 2 / U));
|
|
2674
|
+
const oy = grid(cy + Math.round((b.minY + b.maxY) / 2 / U));
|
|
2675
|
+
placed.set(key, placeCell(key, ox, oy));
|
|
2676
|
+
rowY += dims.h + ROW_GAP;
|
|
2677
|
+
}
|
|
2678
|
+
groupMaxY = Math.max(groupMaxY, rowY - ROW_GAP);
|
|
2679
|
+
colX = x + colW + CHANNEL;
|
|
2680
|
+
void colX;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
1049
2683
|
// Drop chains: a maximal run of two-lead vertical parts linked pin-to-pin
|
|
1050
2684
|
// by two-endpoint signal nets, ended on each side by an anchor pin (any
|
|
1051
2685
|
// other part) or a power-class net. The run is restacked as one straight
|
|
@@ -1064,11 +2698,18 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1064
2698
|
const net = netByEndpoint.get(epOf(current, pinN));
|
|
1065
2699
|
if (!net) return { kind: 'open' }; // declared no-connect or unused
|
|
1066
2700
|
if (classOf(net.name) !== 'signal') return { kind: 'power' };
|
|
1067
|
-
|
|
2701
|
+
// a tapped node is not a series link, but it is a fine END: the chain
|
|
2702
|
+
// stops there and the label that names the node sits on it (R18 over
|
|
2703
|
+
// Q1's drain, its other end on the four-way USB_EN)
|
|
2704
|
+
if (net.pins.length !== 2) return { kind: 'open' };
|
|
1068
2705
|
const otherEp = net.pins.map(parseEp).find((e) => e !== null && e.key !== current);
|
|
1069
2706
|
if (!otherEp) return { kind: 'invalid' };
|
|
1070
2707
|
const opl = placed.get(otherEp.key);
|
|
1071
|
-
if (!opl
|
|
2708
|
+
if (!opl) return { kind: 'invalid' };
|
|
2709
|
+
// the other end lives in another group: this end is open, and the
|
|
2710
|
+
// label that names the net across the sheet sits on it (an LED's
|
|
2711
|
+
// resistor driven from the MCU group stacks over its LED here)
|
|
2712
|
+
if (groupOf.get(otherEp.key) !== gname) return { kind: 'open' };
|
|
1072
2713
|
if (chainable(otherEp.key) && !chain.includes(otherEp.key)) {
|
|
1073
2714
|
const ov = vertPins(otherEp.key)!;
|
|
1074
2715
|
// the link must enter through the lead facing the chain, or the
|
|
@@ -1088,14 +2729,36 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1088
2729
|
for (const m of members) {
|
|
1089
2730
|
if (!chainable(m.key) || chained.has(m.key)) continue;
|
|
1090
2731
|
const chain = [m.key];
|
|
1091
|
-
|
|
1092
|
-
|
|
2732
|
+
let topEnd = walk(m.key, 'up', chain);
|
|
2733
|
+
let bottomEnd = walk(chain[chain.length - 1]!, 'down', chain);
|
|
1093
2734
|
for (const ref of chain) chained.add(ref);
|
|
2735
|
+
trace(`chain ${chain.map((k) => instByKey.get(k)!.ref).join('+')}: top ${topEnd.kind} bottom ${bottomEnd.kind}`);
|
|
1094
2736
|
if (topEnd.kind === 'invalid' || bottomEnd.kind === 'invalid') continue;
|
|
2737
|
+
// The anchor sits BELOW the chain but meets its top lead (R18's pin 1
|
|
2738
|
+
// on Q1's drain, which faces up): turn every part half a turn so the
|
|
2739
|
+
// lead that meets the anchor is the bottom one, and stack upward.
|
|
2740
|
+
// The reverse case (an anchor above, met by a bottom lead) mirrors it.
|
|
2741
|
+
// A turned chain that is then abandoned below gets its column's
|
|
2742
|
+
// orientation back: `abandon` restores every part the turn replaced.
|
|
2743
|
+
const unturned = new Map<string, Placed>();
|
|
2744
|
+
const flip = (): void => {
|
|
2745
|
+
for (const key of chain) {
|
|
2746
|
+
const pl = placed.get(key)!;
|
|
2747
|
+
unturned.set(key, pl);
|
|
2748
|
+
placed.set(key, placeCell(key, pl.x, pl.y, (pl.rot + 180) % 360, pl.mirror));
|
|
2749
|
+
}
|
|
2750
|
+
[topEnd, bottomEnd] = [bottomEnd, topEnd];
|
|
2751
|
+
chain.reverse();
|
|
2752
|
+
};
|
|
2753
|
+
const abandon = (): void => {
|
|
2754
|
+
for (const [key, pl] of unturned) placed.set(key, pl);
|
|
2755
|
+
};
|
|
1095
2756
|
if (chain.length > 4) continue; // beyond four parts this is a network, not an idiom
|
|
1096
2757
|
const anchors = [topEnd, bottomEnd].filter((e): e is Extract<ChainEnd, { kind: 'anchor' }> => e.kind === 'anchor');
|
|
1097
2758
|
if (anchors.length === 0 && chain.length < 2) continue; // a lone floating part has nothing to align to
|
|
1098
2759
|
if (topEnd.kind === 'open' && bottomEnd.kind === 'open') continue;
|
|
2760
|
+
if (topEnd.kind === 'anchor' && outward(topEnd.pin).dy === -1 && bottomEnd.kind !== 'anchor') flip();
|
|
2761
|
+
else if (bottomEnd.kind === 'anchor' && outward(bottomEnd.pin).dy === 1 && topEnd.kind !== 'anchor') flip();
|
|
1099
2762
|
|
|
1100
2763
|
const stubEndOf = (a: Extract<ChainEnd, { kind: 'anchor' }>): { x: number; y: number; o: { dx: number; dy: number } } => {
|
|
1101
2764
|
const at = pinAt(placed.get(a.ref)!, a.pin);
|
|
@@ -1107,25 +2770,35 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1107
2770
|
let order = chain;
|
|
1108
2771
|
if (topEnd.kind === 'anchor') {
|
|
1109
2772
|
const s = stubEndOf(topEnd);
|
|
1110
|
-
if (s.o.dy === -1)
|
|
2773
|
+
if (s.o.dy === -1) {
|
|
2774
|
+
abandon(); // an up-facing pin cannot feed a downward run
|
|
2775
|
+
continue;
|
|
2776
|
+
}
|
|
1111
2777
|
if (bottomEnd.kind === 'anchor') {
|
|
1112
2778
|
const b = stubEndOf(bottomEnd);
|
|
1113
2779
|
// both ends must sit on one axis with the second anchor below and
|
|
1114
2780
|
// able to receive from above, else leave the columns alone
|
|
1115
|
-
if (!sameCoord(s.x, b.x) || b.y <= s.y || b.o.dy === 1)
|
|
2781
|
+
if (!sameCoord(s.x, b.x) || b.y <= s.y || b.o.dy === 1) {
|
|
2782
|
+
abandon();
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
1116
2785
|
}
|
|
1117
2786
|
axisX = s.x;
|
|
1118
2787
|
cursor = s.y + CHAIN_GAP;
|
|
1119
2788
|
} else if (bottomEnd.kind === 'anchor') {
|
|
1120
2789
|
// rail above, anchor below (a pull-up): stack upward from the anchor
|
|
1121
2790
|
const s = stubEndOf(bottomEnd);
|
|
1122
|
-
if (s.o.dy === 1)
|
|
2791
|
+
if (s.o.dy === 1) {
|
|
2792
|
+
abandon(); // a down-facing pin cannot feed an upward run
|
|
2793
|
+
continue;
|
|
2794
|
+
}
|
|
1123
2795
|
axisX = s.x;
|
|
1124
2796
|
order = [...chain].reverse();
|
|
1125
2797
|
// Bounded lift: when a connection row would sit on a foreign stub's
|
|
1126
2798
|
// crossing (axisClear's segment check), raise the whole stack a grid
|
|
1127
2799
|
// row at a time rather than shipping the contact or losing the idiom.
|
|
1128
2800
|
const netOf = (key: string, pinN: string): string => netByEndpoint.get(epOf(key, pinN))?.name ?? '';
|
|
2801
|
+
let lifted = false;
|
|
1129
2802
|
for (let lift = 0; lift < 3; lift++) {
|
|
1130
2803
|
let up = s.y - CHAIN_GAP - lift * 2 * U;
|
|
1131
2804
|
const moves = new Map<string, Placed>();
|
|
@@ -1145,8 +2818,12 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1145
2818
|
moves,
|
|
1146
2819
|
topEnd.kind === 'power' ? [powerEndBox(axisX, topY, -1)] : [],
|
|
1147
2820
|
);
|
|
1148
|
-
if (done)
|
|
2821
|
+
if (done) {
|
|
2822
|
+
lifted = true;
|
|
2823
|
+
break;
|
|
2824
|
+
}
|
|
1149
2825
|
}
|
|
2826
|
+
if (!lifted) abandon();
|
|
1150
2827
|
continue;
|
|
1151
2828
|
} else {
|
|
1152
2829
|
// both ends are rails: a divider — straighten in place on its own axis
|
|
@@ -1158,6 +2835,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1158
2835
|
}
|
|
1159
2836
|
const cursor0 = cursor;
|
|
1160
2837
|
const netOf2 = (key: string, pinN: string): string => netByEndpoint.get(epOf(key, pinN))?.name ?? '';
|
|
2838
|
+
let stacked = false;
|
|
1161
2839
|
for (let lift = 0; lift < 3; lift++) {
|
|
1162
2840
|
cursor = cursor0 + lift * 2 * U;
|
|
1163
2841
|
const moves = new Map<string, Placed>();
|
|
@@ -1185,19 +2863,39 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1185
2863
|
const endNet = conn.length ? conn[0]!.net : '';
|
|
1186
2864
|
const startConn = { y: cursor0 - CHAIN_GAP, net: topEnd.kind === 'anchor' ? netOf2(topEnd.ref, topEnd.pin.number) : endNet };
|
|
1187
2865
|
const lastConn = { y: axisEndY, net: bottomEnd.kind === 'anchor' ? netOf2(bottomEnd.ref, bottomEnd.pin.number) : (conn.length ? conn[conn.length - 1]!.net : '') };
|
|
1188
|
-
if (fits && finalizeMoves([{ axisX, ys: [cursor0 - CHAIN_GAP, axisEndY], conn: [startConn, ...conn, lastConn] }], moves, clearBoxes))
|
|
2866
|
+
if (fits && finalizeMoves([{ axisX, ys: [cursor0 - CHAIN_GAP, axisEndY], conn: [startConn, ...conn, lastConn] }], moves, clearBoxes)) {
|
|
2867
|
+
stacked = true;
|
|
2868
|
+
break;
|
|
2869
|
+
}
|
|
1189
2870
|
if (!fits) break; // lifting only shrinks the room below; no retry can help
|
|
1190
2871
|
}
|
|
2872
|
+
if (!stacked) abandon();
|
|
1191
2873
|
}
|
|
1192
2874
|
|
|
1193
2875
|
const memberRefs = [...members.map((m) => m.key), ...capRefs];
|
|
1194
2876
|
const cells = memberRefs.map((r) => placed.get(r)!);
|
|
1195
2877
|
if (cells.length) {
|
|
1196
2878
|
const minX = Math.min(...cells.map((c) => c.body.minX)) - MARGIN * U;
|
|
1197
|
-
|
|
1198
|
-
|
|
2879
|
+
// a two-lead part carries its reference and value beside its body;
|
|
2880
|
+
// at the group's right edge that text is what the next group must
|
|
2881
|
+
// clear (the rails bank's "47uF/10V" ran under the amplifier's box)
|
|
2882
|
+
const fieldReach = (c: Placed): number =>
|
|
2883
|
+
c.sym.pins.length <= 2 ? Math.max(c.refDes.length, c.part.value.length) * TEXT_RESERVE * LABEL_HEIGHT + 1.27 : 0;
|
|
2884
|
+
const maxX = Math.max(...cells.map((c) => c.body.maxX + fieldReach(c))) + MARGIN * U;
|
|
2885
|
+
// the top inset holds the caption band (a bold caption at
|
|
2886
|
+
// CAPTION_SIZE, inset 2 mm) above the tallest thing a cell can carry
|
|
2887
|
+
// over its body: a rail symbol on an upward stub with its value text
|
|
2888
|
+
// over it, which with an IC at the top-left corner sat 4.3 mm under
|
|
2889
|
+
// the box edge at six units; CAPTION_BAND keeps the caption's
|
|
2890
|
+
// bottom edge clear of it
|
|
2891
|
+
const minY = Math.min(...cells.map((c) => c.body.minY)) - (MARGIN + CAPTION_BAND) * U;
|
|
1199
2892
|
const maxY = Math.max(...cells.map((c) => c.body.maxY)) + (MARGIN + 2) * U;
|
|
1200
|
-
|
|
2893
|
+
// a measured round already knows how far the box will grow above
|
|
2894
|
+
// and below its cells (caption band, text, power symbols): the rect
|
|
2895
|
+
// carries it so every fit stacks boxes, not cells
|
|
2896
|
+
const reach = reachMeasured?.get(gname);
|
|
2897
|
+
groupRects.push({ name: gname, x1: minX, y1: minY - (reach?.top ?? 0), x2: maxX, y2: maxY + (reach?.bottom ?? 0) });
|
|
2898
|
+
trace(`group "${gname}": rect x ${minX.toFixed(1)}..${maxX.toFixed(1)} y ${minY.toFixed(1)}..${maxY.toFixed(1)} (${cells.length} cells)`);
|
|
1201
2899
|
groupX = Math.round(maxX / U) + GROUP_GAP;
|
|
1202
2900
|
prevGroup = gname;
|
|
1203
2901
|
bandsOf.set(gname, bandCount);
|
|
@@ -1223,9 +2921,9 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1223
2921
|
const hinted = paperHint ? PAPERS.find((p) => p.name === paperHint) : undefined;
|
|
1224
2922
|
// A hint pins the width budget; otherwise try every sheet, smallest first.
|
|
1225
2923
|
const candidates = hinted ? [hinted] : PAPERS;
|
|
1226
|
-
const gap = GROUP_GAP * U;
|
|
1227
|
-
const usableW = (p: { w: number }): number => p.w - 2 * FRAME;
|
|
1228
|
-
const usableH = (p: { h: number }): number => p.h - 2 * FRAME - TITLE_STRIP;
|
|
2924
|
+
const gap = GROUP_GAP * U + wrapGap;
|
|
2925
|
+
const usableW = (p: { w: number }): number => p.w - 2 * FRAME - frameSlack;
|
|
2926
|
+
const usableH = (p: { h: number }): number => p.h - 2 * FRAME - TITLE_STRIP - frameSlack;
|
|
1229
2927
|
|
|
1230
2928
|
/**
|
|
1231
2929
|
* Shelf-wrap the group rects to a width budget; returns per-group offsets.
|
|
@@ -1249,24 +2947,280 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1249
2947
|
let rowLeftExt = leftExtOf(groupRects[0]!.name);
|
|
1250
2948
|
let dyUnits = 0;
|
|
1251
2949
|
let rowH = 0;
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
2950
|
+
// Every box in a row shares the row's top. The single-row pass gives the
|
|
2951
|
+
// boxes different tops when one reserved room above its IC for a
|
|
2952
|
+
// pull-up (esp32-amp's rails box started 16 mm below its neighbours),
|
|
2953
|
+
// and a row height measured box by box then misses that offset, so the
|
|
2954
|
+
// next row landed on the tall box's bottom. Each box moves to the common
|
|
2955
|
+
// top; its height is then its own.
|
|
2956
|
+
const topY = Math.min(...groupRects.map((r) => r.y1));
|
|
2957
|
+
// Rows are consecutive runs of groups (reading order is kept), but WHICH
|
|
2958
|
+
// consecutive runs is chosen to minimise the stack's height, not by
|
|
2959
|
+
// filling each row until the next group no longer fits: first-fit paired
|
|
2960
|
+
// esp32-amp's two tallest groups with two short ones and stacked five
|
|
2961
|
+
// rows where four would do. A group wider than the whole budget still
|
|
2962
|
+
// starts its own row; it will overflow, and the caller rejects this paper
|
|
2963
|
+
// for it.
|
|
2964
|
+
const n = groupRects.length;
|
|
2965
|
+
const heightOf = (r: (typeof groupRects)[number]): number => r.y2 - r.y1;
|
|
2966
|
+
const runFits = (i: number, j: number): boolean =>
|
|
2967
|
+
i === j || groupRects[j]!.x2 - groupRects[i]!.x1 + leftExtOf(groupRects[i]!.name) + rightExtOf(groupRects[j]!.name) <= budgetW;
|
|
2968
|
+
const best: number[] = new Array(n + 1).fill(Infinity);
|
|
2969
|
+
const cut: number[] = new Array(n + 1).fill(0);
|
|
2970
|
+
best[0] = 0;
|
|
2971
|
+
for (let j = 1; j <= n; j++) {
|
|
2972
|
+
let rowMax = 0;
|
|
2973
|
+
for (let i = j; i >= 1; i--) {
|
|
2974
|
+
rowMax = Math.max(rowMax, heightOf(groupRects[i - 1]!));
|
|
2975
|
+
if (!runFits(i - 1, j - 1)) break;
|
|
2976
|
+
const h = best[i - 1]! + rowMax + (i > 1 ? gap : 0);
|
|
2977
|
+
if (h < best[j]!) {
|
|
2978
|
+
best[j] = h;
|
|
2979
|
+
cut[j] = i - 1;
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
if (best[j] === Infinity) {
|
|
2983
|
+
// no run ending here fits: the group is its own (overflowing) row
|
|
2984
|
+
best[j] = best[j - 1]! + heightOf(groupRects[j - 1]!) + (j > 1 ? gap : 0);
|
|
2985
|
+
cut[j] = j - 1;
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
const starts: number[] = [];
|
|
2989
|
+
for (let j = n; j > 0; j = cut[j]!) starts.unshift(cut[j]!);
|
|
2990
|
+
const rowStart = new Set(starts);
|
|
2991
|
+
if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') {
|
|
2992
|
+
const rows: string[] = [];
|
|
2993
|
+
for (let k = 0; k < starts.length; k++) {
|
|
2994
|
+
const a = starts[k]!;
|
|
2995
|
+
const b = k + 1 < starts.length ? starts[k + 1]! : n;
|
|
2996
|
+
const members = groupRects.slice(a, b);
|
|
2997
|
+
rows.push(`[${members.map((r) => r.name.slice(0, 2).trim()).join('+')} h${Math.max(...members.map(heightOf)).toFixed(0)}]`);
|
|
2998
|
+
}
|
|
2999
|
+
trace(`wrap at ${budgetW.toFixed(0)}: rows ${rows.join(' ')} -> ${best[n]!.toFixed(0)} tall`);
|
|
3000
|
+
}
|
|
3001
|
+
for (const [idx, r] of groupRects.entries()) {
|
|
3002
|
+
if (idx > 0 && rowStart.has(idx)) {
|
|
1256
3003
|
dyUnits += Math.ceil((rowH + gap) / U);
|
|
1257
3004
|
rowOriginX = r.x1;
|
|
1258
3005
|
rowLeftExt = leftExtOf(r.name);
|
|
1259
3006
|
rowH = 0;
|
|
1260
3007
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
3008
|
+
const lift = grid(Math.round((topY - r.y1) / U));
|
|
3009
|
+
deltas.push({ dx: grid(Math.round((originX - rowOriginX) / U)), dy: dyUnits * U + lift });
|
|
3010
|
+
rowH = Math.max(rowH, r.y2 + lift - topY);
|
|
1263
3011
|
}
|
|
3012
|
+
void rowLeftExt;
|
|
1264
3013
|
const xs = groupRects.flatMap((r, i) => [r.x1 + deltas[i]!.dx, r.x2 + deltas[i]!.dx]);
|
|
1265
3014
|
const ys = groupRects.flatMap((r, i) => [r.y1 + deltas[i]!.dy, r.y2 + deltas[i]!.dy]);
|
|
1266
3015
|
return { deltas, w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
|
|
1267
3016
|
};
|
|
1268
3017
|
|
|
1269
|
-
|
|
3018
|
+
/**
|
|
3019
|
+
* The column-major counterpart of `wrapTo`: groups keep their declared
|
|
3020
|
+
* order but fill top-to-bottom, then left-to-right, the way a person lays
|
|
3021
|
+
* a newspaper page. Consecutive runs form columns no taller than `budgetH`;
|
|
3022
|
+
* a column is as wide as its widest group (label extents included) and the
|
|
3023
|
+
* breaks are chosen to minimise the total width. Groups of like width stack
|
|
3024
|
+
* well this way (the engagement sheet's three 250 mm groups in one column,
|
|
3025
|
+
* its three 205 mm groups in the next), which rows of mismatched heights
|
|
3026
|
+
* never achieve.
|
|
3027
|
+
*/
|
|
3028
|
+
const wrapColumns = (budgetH: number): { deltas: { dx: number; dy: number }[]; w: number; h: number; kind: 'columns' } => {
|
|
3029
|
+
const originX = groupRects[0]!.x1;
|
|
3030
|
+
const topY = Math.min(...groupRects.map((r) => r.y1));
|
|
3031
|
+
const leftExtOf = (name: string): number => groupExtents.get(name)?.left ?? 0;
|
|
3032
|
+
const rightExtOf = (name: string): number => groupExtents.get(name)?.right ?? 0;
|
|
3033
|
+
const gap = GROUP_GAP * U + wrapGap;
|
|
3034
|
+
const n = groupRects.length;
|
|
3035
|
+
const hOf = (r: (typeof groupRects)[number]): number => r.y2 - r.y1;
|
|
3036
|
+
const wOf = (r: (typeof groupRects)[number]): number => r.x2 - r.x1 + leftExtOf(r.name) + rightExtOf(r.name);
|
|
3037
|
+
const runH = (i: number, j: number): number => {
|
|
3038
|
+
let h = 0;
|
|
3039
|
+
for (let k = i; k <= j; k++) h += hOf(groupRects[k]!) + (k > i ? gap : 0);
|
|
3040
|
+
return h;
|
|
3041
|
+
};
|
|
3042
|
+
const best: number[] = new Array(n + 1).fill(Infinity);
|
|
3043
|
+
const cut: number[] = new Array(n + 1).fill(0);
|
|
3044
|
+
best[0] = 0;
|
|
3045
|
+
for (let j = 1; j <= n; j++) {
|
|
3046
|
+
let colW = 0;
|
|
3047
|
+
for (let i = j; i >= 1; i--) {
|
|
3048
|
+
colW = Math.max(colW, wOf(groupRects[i - 1]!));
|
|
3049
|
+
if (i < j && runH(i - 1, j - 1) > budgetH) break;
|
|
3050
|
+
const w = best[i - 1]! + colW + (i > 1 ? gap : 0);
|
|
3051
|
+
if (w < best[j]!) {
|
|
3052
|
+
best[j] = w;
|
|
3053
|
+
cut[j] = i - 1;
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
const starts: number[] = [];
|
|
3058
|
+
for (let j = n; j > 0; j = cut[j]!) starts.unshift(cut[j]!);
|
|
3059
|
+
const deltas: { dx: number; dy: number }[] = [];
|
|
3060
|
+
let colOriginUnits = Math.round(originX / U);
|
|
3061
|
+
let maxH = 0;
|
|
3062
|
+
for (let k = 0; k < starts.length; k++) {
|
|
3063
|
+
const a = starts[k]!;
|
|
3064
|
+
const b = k + 1 < starts.length ? starts[k + 1]! : n;
|
|
3065
|
+
const members = groupRects.slice(a, b);
|
|
3066
|
+
const colLeft = Math.max(...members.map((r) => leftExtOf(r.name)));
|
|
3067
|
+
let yUnits = Math.round(topY / U);
|
|
3068
|
+
for (const r of members) {
|
|
3069
|
+
deltas.push({ dx: (colOriginUnits + Math.ceil(colLeft / U)) * U - grid(Math.round(r.x1 / U)), dy: yUnits * U - grid(Math.round(r.y1 / U)) });
|
|
3070
|
+
yUnits += Math.ceil(hOf(r) / U) + GROUP_GAP + Math.ceil(wrapGap / U);
|
|
3071
|
+
}
|
|
3072
|
+
maxH = Math.max(maxH, (yUnits - GROUP_GAP) * U - topY);
|
|
3073
|
+
colOriginUnits += Math.ceil(Math.max(...members.map(wOf)) / U) + GROUP_GAP + Math.ceil(wrapGap / U);
|
|
3074
|
+
}
|
|
3075
|
+
if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') {
|
|
3076
|
+
trace(`column wrap at ${budgetH.toFixed(0)}: ${starts.map((a, k) => `[${groupRects.slice(a, k + 1 < starts.length ? starts[k + 1]! : n).map((r) => r.name.slice(0, 2).trim()).join('+')}]`).join(' ')} -> ${best[n]!.toFixed(0)} wide`);
|
|
3077
|
+
}
|
|
3078
|
+
return { deltas, w: best[n]!, h: maxH, kind: 'columns' };
|
|
3079
|
+
};
|
|
3080
|
+
|
|
3081
|
+
/**
|
|
3082
|
+
* The grid a person lays when neither rows nor columns fit: k columns,
|
|
3083
|
+
* groups dealt to them in declared order (group i in column i mod k), each
|
|
3084
|
+
* column stacking its own groups from the top. Reading order runs along
|
|
3085
|
+
* the rows as before, but a short group no longer holds a whole row's
|
|
3086
|
+
* height open beside a tall one (the hand-laid A2 sheet: power, amplifier
|
|
3087
|
+
* and UI in one column, PD, MCU and UART in the next, rails in the third).
|
|
3088
|
+
*/
|
|
3089
|
+
/** Masonry deals already searched in this draft, by budget and group sizes. */
|
|
3090
|
+
const masonryDeals = new Map<string, number[][] | null>();
|
|
3091
|
+
const wrapMasonry = (k: number, budgetH: number): { deltas: { dx: number; dy: number }[]; w: number; h: number; kind: 'masonry' } | null => {
|
|
3092
|
+
const originX = groupRects[0]!.x1;
|
|
3093
|
+
const topY = Math.min(...groupRects.map((r) => r.y1));
|
|
3094
|
+
const leftExtOf = (name: string): number => groupExtents.get(name)?.left ?? 0;
|
|
3095
|
+
const rightExtOf = (name: string): number => groupExtents.get(name)?.right ?? 0;
|
|
3096
|
+
const n = groupRects.length;
|
|
3097
|
+
const wOf = (i: number): number => groupRects[i]!.x2 - groupRects[i]!.x1 + leftExtOf(groupRects[i]!.name) + rightExtOf(groupRects[i]!.name);
|
|
3098
|
+
const hOf = (i: number): number => groupRects[i]!.y2 - groupRects[i]!.y1;
|
|
3099
|
+
const gap = GROUP_GAP * U + wrapGap;
|
|
3100
|
+
// Every way of dealing n groups to k columns (order kept within a
|
|
3101
|
+
// column, columns in the order their first groups are declared) is
|
|
3102
|
+
// judged and the narrowest that fits the height wins; ties go to the
|
|
3103
|
+
// shorter. Seven groups in three columns is 301 deals, but twelve in four
|
|
3104
|
+
// is 611 501, and the fit asks for this at every budget of every sheet:
|
|
3105
|
+
// enumerating them all made a twelve-group board draft in tens of
|
|
3106
|
+
// seconds. So the deals are walked as a tree, a group at a time, and a
|
|
3107
|
+
// branch is cut as soon as a column is already too tall, or the columns
|
|
3108
|
+
// opened so far are already wider than the best deal found (or as wide
|
|
3109
|
+
// and as tall): no deal below it could replace the best. The walk visits
|
|
3110
|
+
// deals in the enumeration's order and cuts only branches that could not
|
|
3111
|
+
// win, so the deal it keeps is the one the full enumeration keeps. Past
|
|
3112
|
+
// a node budget the walk stops with the best deal so far, and deals
|
|
3113
|
+
// round-robin when it has found none. Shapes recur across the budgets
|
|
3114
|
+
// the fit tries, so a search is remembered by the groups' sizes.
|
|
3115
|
+
const sizesKey = `${k}|${budgetH}|${gap}|${groupRects.map((_, i) => `${wOf(i)}x${hOf(i)}`).join(',')}`;
|
|
3116
|
+
let cols: number[][] | null | undefined = masonryDeals.get(sizesKey);
|
|
3117
|
+
if (cols === undefined) {
|
|
3118
|
+
let found: number[][] | null = null;
|
|
3119
|
+
let bestW = Infinity;
|
|
3120
|
+
let bestH = Infinity;
|
|
3121
|
+
const judge = (assign: number[]): void => {
|
|
3122
|
+
const cs: number[][] = Array.from({ length: k }, () => []);
|
|
3123
|
+
assign.forEach((c, i) => cs[c]!.push(i));
|
|
3124
|
+
if (cs.some((c) => !c.length)) return;
|
|
3125
|
+
const hs = cs.map((c) => c.reduce((h, i, m) => h + hOf(i) + (m ? gap : 0), 0));
|
|
3126
|
+
const maxH = Math.max(...hs);
|
|
3127
|
+
if (maxH > budgetH) return;
|
|
3128
|
+
const w = cs.reduce((acc, c) => acc + Math.max(...c.map(wOf)), 0) + (k - 1) * gap;
|
|
3129
|
+
if (w < bestW - 1e-6 || (Math.abs(w - bestW) <= 1e-6 && maxH < bestH)) {
|
|
3130
|
+
bestW = w;
|
|
3131
|
+
bestH = maxH;
|
|
3132
|
+
found = cs;
|
|
3133
|
+
}
|
|
3134
|
+
};
|
|
3135
|
+
// columns are opened in reading order: the first group starts the
|
|
3136
|
+
// first column, and a group may start a new column only when every
|
|
3137
|
+
// earlier one is open (a restricted-growth string), so the same deal
|
|
3138
|
+
// is never judged under k! column orders
|
|
3139
|
+
const assign: number[] = new Array(n).fill(0);
|
|
3140
|
+
const colH: number[] = new Array(k).fill(0);
|
|
3141
|
+
const colW: number[] = new Array(k).fill(0);
|
|
3142
|
+
const colN: number[] = new Array(k).fill(0);
|
|
3143
|
+
let nodes = 0;
|
|
3144
|
+
const grow = (i: number, open: number, widthSoFar: number, tallest: number): void => {
|
|
3145
|
+
if (nodes > MASONRY_NODE_BUDGET) return;
|
|
3146
|
+
nodes++;
|
|
3147
|
+
if (n - i < k - open) return; // too few groups left to open every column
|
|
3148
|
+
if (i === n) {
|
|
3149
|
+
judge(assign);
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
for (let c = 0; c < Math.min(open + 1, k); c++) {
|
|
3153
|
+
const h = colH[c]! + hOf(i) + (colN[c] ? gap : 0);
|
|
3154
|
+
if (h > budgetH) continue;
|
|
3155
|
+
const w = Math.max(colW[c]!, wOf(i));
|
|
3156
|
+
const lowerW = widthSoFar - colW[c]! + w + (k - 1) * gap;
|
|
3157
|
+
const tall = Math.max(tallest, h);
|
|
3158
|
+
if (lowerW > bestW + 1e-6 || (lowerW >= bestW - 1e-6 && tall >= bestH)) continue;
|
|
3159
|
+
const [h0, w0] = [colH[c]!, colW[c]!];
|
|
3160
|
+
assign[i] = c;
|
|
3161
|
+
colH[c] = h;
|
|
3162
|
+
colW[c] = w;
|
|
3163
|
+
colN[c]!++;
|
|
3164
|
+
grow(i + 1, Math.max(open, c + 1), lowerW - (k - 1) * gap, tall);
|
|
3165
|
+
colN[c]!--;
|
|
3166
|
+
colH[c] = h0;
|
|
3167
|
+
colW[c] = w0;
|
|
3168
|
+
}
|
|
3169
|
+
};
|
|
3170
|
+
grow(0, 0, 0, 0);
|
|
3171
|
+
if (nodes > MASONRY_NODE_BUDGET) {
|
|
3172
|
+
trace(`masonry k=${k} at ${budgetH.toFixed(0)}: search stopped at ${MASONRY_NODE_BUDGET} nodes, ${found ? 'keeping the best deal so far' : 'dealing round-robin'}`);
|
|
3173
|
+
if (!found) judge(groupRects.map((_, i) => i % k));
|
|
3174
|
+
}
|
|
3175
|
+
cols = found;
|
|
3176
|
+
masonryDeals.set(sizesKey, cols);
|
|
3177
|
+
}
|
|
3178
|
+
if (!cols) return null;
|
|
3179
|
+
const colW = (cols as number[][]).map((c) => Math.max(0, ...c.map(wOf)));
|
|
3180
|
+
const colH = (cols as number[][]).map((c) => c.reduce((h, i, m) => h + hOf(i) + (m ? gap : 0), 0));
|
|
3181
|
+
const deltas: { dx: number; dy: number }[] = new Array(groupRects.length);
|
|
3182
|
+
let colOriginUnits = Math.round(originX / U);
|
|
3183
|
+
for (const [ci, c] of (cols as number[][]).entries()) {
|
|
3184
|
+
const colLeft = Math.max(0, ...c.map((i) => leftExtOf(groupRects[i]!.name)));
|
|
3185
|
+
let yUnits = Math.round(topY / U);
|
|
3186
|
+
for (const i of c) {
|
|
3187
|
+
const r = groupRects[i]!;
|
|
3188
|
+
deltas[i] = { dx: (colOriginUnits + Math.ceil(colLeft / U)) * U - grid(Math.round(r.x1 / U)), dy: yUnits * U - grid(Math.round(r.y1 / U)) };
|
|
3189
|
+
yUnits += Math.ceil((r.y2 - r.y1) / U) + GROUP_GAP + Math.ceil(wrapGap / U);
|
|
3190
|
+
}
|
|
3191
|
+
colOriginUnits += Math.ceil(colW[ci]! / U) + (ci + 1 < k ? GROUP_GAP + Math.ceil(wrapGap / U) : 0);
|
|
3192
|
+
}
|
|
3193
|
+
const w = (colOriginUnits - Math.round(originX / U)) * U;
|
|
3194
|
+
trace(`masonry wrap k=${k} at ${budgetH.toFixed(0)}: ${(cols as number[][]).map((c) => `[${c.map((i) => groupRects[i]!.name.slice(0, 2).trim()).join('+')}]`).join(' ')} -> ${w.toFixed(0)} wide, ${Math.max(...colH).toFixed(0)} tall`);
|
|
3195
|
+
return { deltas, w, h: Math.max(...colH), kind: 'masonry' };
|
|
3196
|
+
};
|
|
3197
|
+
|
|
3198
|
+
type SheetFit = {
|
|
3199
|
+
paper: (typeof PAPERS)[number];
|
|
3200
|
+
wrap: { deltas: { dx: number; dy: number }[]; w: number; h: number; kind?: 'rows' | 'columns' | 'masonry' } | null;
|
|
3201
|
+
/** The content runs into the title strip beside the title block (the corner itself stays clear). */
|
|
3202
|
+
intoStrip?: boolean;
|
|
3203
|
+
};
|
|
3204
|
+
/**
|
|
3205
|
+
* Does wrapped content of `w` × `h` fit sheet `p`? The title strip is
|
|
3206
|
+
* reserved across the whole width by default; content taller than that
|
|
3207
|
+
* may still fit when the group boxes that reach into the strip stay clear
|
|
3208
|
+
* of the title block's own corner (a person lays the left-hand column down
|
|
3209
|
+
* to the frame and keeps the bottom-right for the block).
|
|
3210
|
+
*/
|
|
3211
|
+
const fitsFrame = (p: (typeof PAPERS)[number], w: number, h: number, rects: { x1: number; y1: number; x2: number; y2: number }[]): { ok: boolean; intoStrip: boolean } => {
|
|
3212
|
+
if (w > usableW(p)) return { ok: false, intoStrip: false };
|
|
3213
|
+
if (h <= usableH(p)) return { ok: true, intoStrip: false };
|
|
3214
|
+
if (h > usableH(p) + TITLE_STRIP - 4 * U) return { ok: false, intoStrip: false };
|
|
3215
|
+
// final placement: centred in width, top-aligned (the frame plus four units) in height
|
|
3216
|
+
const minX = Math.min(...rects.map((r) => r.x1));
|
|
3217
|
+
const minY = Math.min(...rects.map((r) => r.y1));
|
|
3218
|
+
const x0 = FRAME + Math.max(0, (usableW(p) - w) / 2) - minX;
|
|
3219
|
+
const y0 = FRAME + 4 * U - minY;
|
|
3220
|
+
const corner = { minX: p.w - FRAME - TITLE_BLOCK_W, minY: p.h - FRAME - TITLE_STRIP, maxX: p.w - FRAME, maxY: p.h - FRAME };
|
|
3221
|
+
const clear = rects.every((r) => !(r.x1 + x0 < corner.maxX && r.x2 + x0 > corner.minX && r.y1 + y0 < corner.maxY && r.y2 + y0 > corner.minY));
|
|
3222
|
+
return { ok: clear, intoStrip: clear };
|
|
3223
|
+
};
|
|
1270
3224
|
/**
|
|
1271
3225
|
* Whether the current placement fits sheet `p`, with the group shelf-wrap
|
|
1272
3226
|
* deltas that make it fit. `wrap` is null when there is nothing to reflow:
|
|
@@ -1275,8 +3229,26 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1275
3229
|
*/
|
|
1276
3230
|
const fitsOn = (p: (typeof PAPERS)[number]): SheetFit | null => {
|
|
1277
3231
|
if (groupRects.length > 1) {
|
|
3232
|
+
const wrappedRects = (d: { dx: number; dy: number }[]): { x1: number; y1: number; x2: number; y2: number }[] =>
|
|
3233
|
+
groupRects.map((r, i) => ({ x1: r.x1 + d[i]!.dx, y1: r.y1 + d[i]!.dy, x2: r.x2 + d[i]!.dx, y2: r.y2 + d[i]!.dy }));
|
|
1278
3234
|
const w = wrapTo(usableW(p));
|
|
1279
|
-
|
|
3235
|
+
const fw = fitsFrame(p, w.w, w.h, wrappedRects(w.deltas));
|
|
3236
|
+
if (fw.ok) return { paper: p, wrap: { ...w, kind: 'rows' }, intoStrip: fw.intoStrip };
|
|
3237
|
+
// rows of mismatched heights may miss where columns of like widths fit
|
|
3238
|
+
for (const budgetH of [usableH(p), usableH(p) + TITLE_STRIP - 4 * U]) {
|
|
3239
|
+
const c = wrapColumns(budgetH);
|
|
3240
|
+
const fc = fitsFrame(p, c.w, c.h, wrappedRects(c.deltas));
|
|
3241
|
+
if (fc.ok) return { paper: p, wrap: c, intoStrip: fc.intoStrip };
|
|
3242
|
+
}
|
|
3243
|
+
for (const budgetH of [usableH(p), usableH(p) + TITLE_STRIP - 4 * U]) {
|
|
3244
|
+
for (let k = 2; k <= Math.min(4, groupRects.length - 1); k++) {
|
|
3245
|
+
const m = wrapMasonry(k, budgetH);
|
|
3246
|
+
if (!m) continue;
|
|
3247
|
+
const fm = fitsFrame(p, m.w, m.h, wrappedRects(m.deltas));
|
|
3248
|
+
if (fm.ok) return { paper: p, wrap: m, intoStrip: fm.intoStrip };
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
return null;
|
|
1280
3252
|
}
|
|
1281
3253
|
const r = groupRects[0];
|
|
1282
3254
|
return !r || (r.x2 - r.x1 <= usableW(p) && r.y2 - r.y1 <= usableH(p)) ? { paper: p, wrap: null } : null;
|
|
@@ -1298,15 +3270,34 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1298
3270
|
* spread the same cells into more side-by-side columns, so walk the height
|
|
1299
3271
|
* fractions until the content matches the sheet's aspect or nothing fits.
|
|
1300
3272
|
*/
|
|
3273
|
+
/** Closest miss per sheet a budgeted attempt could not fit, for the notes. */
|
|
3274
|
+
const budgetMisses: string[] = [];
|
|
1301
3275
|
const tryPaperBudgeted = (p: (typeof PAPERS)[number]): { fit: SheetFit; bands: Map<string, number> } | null => {
|
|
1302
|
-
|
|
1303
|
-
|
|
3276
|
+
let best: { w: number; h: number } | null = null;
|
|
3277
|
+
// finer fractions re-row a tall group's columns until the groups are
|
|
3278
|
+
// short enough for two or three rows of them to stack on the sheet
|
|
3279
|
+
for (const [frac, div] of [1, 0.7, 0.5, 0.4, 0.35, 0.3, 0.25].flatMap((fr) => [1, 2, 2.5, 3].map((d) => [fr, d] as const))) {
|
|
3280
|
+
// band budgets of a half and a third of the sheet fold a wide flat
|
|
3281
|
+
// group (five button blocks in a row) toward the width of a column
|
|
3282
|
+
const b = placeAllGroups(Math.floor(usableW(p) / div / U), Math.floor((usableH(p) * frac) / U));
|
|
1304
3283
|
const f = fitsOn(p);
|
|
3284
|
+
if (groupRects.length > 1) {
|
|
3285
|
+
const w = wrapTo(usableW(p));
|
|
3286
|
+
trace(`try ${p.name} frac ${frac} div ${div}: wrapped ${w.w.toFixed(0)}x${w.h.toFixed(0)} vs ${usableW(p)}x${usableH(p)}; ${groupRects.map((r) => `${r.name.slice(0, 12)}=${(r.x2 - r.x1).toFixed(0)}x${(r.y2 - r.y1).toFixed(0)}`).join(' ')}`);
|
|
3287
|
+
}
|
|
1305
3288
|
if (f) return { fit: f, bands: b };
|
|
3289
|
+
// remember the closest miss so a failed pass can say how far off it was
|
|
3290
|
+
if (groupRects.length) {
|
|
3291
|
+
const w = groupRects.length > 1 ? wrapTo(usableW(p)) : { w: groupRects[0]!.x2 - groupRects[0]!.x1, h: groupRects[0]!.y2 - groupRects[0]!.y1 };
|
|
3292
|
+
const over = Math.max(w.w - usableW(p), w.h - usableH(p));
|
|
3293
|
+
if (!best || over < Math.max(best.w - usableW(p), best.h - usableH(p))) best = { w: w.w, h: w.h };
|
|
3294
|
+
}
|
|
1306
3295
|
}
|
|
3296
|
+
if (best) budgetMisses.push(`${p.name} ${Math.round(best.w)}×${Math.round(best.h)} mm vs ${usableW(p)}×${usableH(p)} usable`);
|
|
1307
3297
|
return null;
|
|
1308
3298
|
};
|
|
1309
3299
|
|
|
3300
|
+
let compaction: SchematicDraftReport['sheetFit']['compaction'] = hinted ? 'pinned' : 'not-needed';
|
|
1310
3301
|
let bands = placeAllGroups(Infinity);
|
|
1311
3302
|
let fit = bestFit();
|
|
1312
3303
|
if (!fit) {
|
|
@@ -1323,10 +3314,12 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1323
3314
|
if (t) {
|
|
1324
3315
|
bands = t.bands;
|
|
1325
3316
|
fit = t.fit;
|
|
3317
|
+
compaction = 'banded';
|
|
1326
3318
|
break;
|
|
1327
3319
|
}
|
|
1328
3320
|
}
|
|
1329
3321
|
if (!fit) {
|
|
3322
|
+
compaction = 'overflow';
|
|
1330
3323
|
const largest = candidates[candidates.length - 1]!;
|
|
1331
3324
|
bands = placeAllGroups(Math.floor(usableW(largest) / U), Math.floor(usableH(largest) / U));
|
|
1332
3325
|
fit = { paper: largest, wrap: groupRects.length > 1 ? wrapTo(usableW(largest)) : null };
|
|
@@ -1366,6 +3359,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1366
3359
|
}
|
|
1367
3360
|
}
|
|
1368
3361
|
if (compacted) {
|
|
3362
|
+
compaction = 'compacted';
|
|
1369
3363
|
bands = compactedBands;
|
|
1370
3364
|
fit = compacted;
|
|
1371
3365
|
notes.push(
|
|
@@ -1375,8 +3369,12 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1375
3369
|
if (n > 1) notes.push(`group "${g}" was wider than the sheet; its columns wrapped onto ${n} bands`);
|
|
1376
3370
|
}
|
|
1377
3371
|
} else {
|
|
1378
|
-
// nothing smaller holds the reflowed content:
|
|
1379
|
-
// placement byte for byte
|
|
3372
|
+
// nothing smaller holds the reflowed content: say so, with how far
|
|
3373
|
+
// each sheet missed, then restore the natural placement byte for byte
|
|
3374
|
+
compaction = 'failed';
|
|
3375
|
+
notes.push(
|
|
3376
|
+
`sheet not compacted: the natural layout fits ${naturalPaper.name} at ${Math.round(naturalUtil * 100)}% utilization, but no smaller sheet holds the reflowed content (${budgetMisses.join('; ')})`,
|
|
3377
|
+
);
|
|
1380
3378
|
bands = placeAllGroups(Infinity);
|
|
1381
3379
|
fit = bestFit()!;
|
|
1382
3380
|
}
|
|
@@ -1384,8 +3382,18 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1384
3382
|
}
|
|
1385
3383
|
if (fit.wrap) {
|
|
1386
3384
|
const wrap = fit.wrap;
|
|
1387
|
-
|
|
1388
|
-
|
|
3385
|
+
if (wrap.kind === 'columns') {
|
|
3386
|
+
const cols = new Set(wrap.deltas.map((d, i) => Math.round((groupRects[i]!.x1 + d.dx) / U))).size;
|
|
3387
|
+
notes.push(`groups wrapped onto ${cols} columns to fit the sheet (declared order runs down each column)`);
|
|
3388
|
+
} else if (wrap.kind === 'masonry') {
|
|
3389
|
+
const cols = new Set(wrap.deltas.map((d, i) => Math.round((groupRects[i]!.x1 + d.dx) / U))).size;
|
|
3390
|
+
notes.push(`groups laid in ${cols} columns to fit the sheet (declared order runs along the rows; each column stacks its own groups)`);
|
|
3391
|
+
} else {
|
|
3392
|
+
// rows, like columns above, are counted by where their boxes land, not
|
|
3393
|
+
// by the shift each took to get there (every group starts elsewhere)
|
|
3394
|
+
const rows = new Set(wrap.deltas.map((d, i) => Math.round((groupRects[i]!.y1 + d.dy) / U))).size;
|
|
3395
|
+
if (rows > 1) notes.push(`groups wrapped onto ${rows} rows to fit the sheet`);
|
|
3396
|
+
}
|
|
1389
3397
|
groupRects.forEach((r, i) => {
|
|
1390
3398
|
const d = wrap.deltas[i]!;
|
|
1391
3399
|
if (!d.dx && !d.dy) return;
|
|
@@ -1645,6 +3653,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1645
3653
|
const y = c.ep.at.y + c.o.dy * STUB * U;
|
|
1646
3654
|
const seg = { x1: prev.ep.at.x, y1: y, x2: c.ep.at.x, y2: y };
|
|
1647
3655
|
return (
|
|
3656
|
+
groupOf.get(prev.ep.ref) === groupOf.get(c.ep.ref) &&
|
|
1648
3657
|
c.ep.at.x - prev.ep.at.x <= BANK_PITCH_MAX * U &&
|
|
1649
3658
|
!powerBodies.some((b) => segCrossesBody(seg.x1, seg.y1, seg.x2, seg.y2, b)) &&
|
|
1650
3659
|
!touchesForeign([seg], net.name, ownEps, { predictStubs: true }) &&
|
|
@@ -1681,10 +3690,16 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1681
3690
|
}
|
|
1682
3691
|
if (len > maxLen) len = maxLen;
|
|
1683
3692
|
const at = (l: number): { x: number; y: number } => ({ x: ep.at.x + o.dx * l * U, y: ep.at.y + o.dy * l * U });
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
3693
|
+
// A vertical stub carries its name beyond the bar, in line. A HORIZONTAL
|
|
3694
|
+
// stub used to put the name 3.556 mm above (rails) or below (grounds)
|
|
3695
|
+
// the bar: 1.4 pin rows off, so on any IC with pins one row apart the
|
|
3696
|
+
// rail name of pin 5 sat on the label of pin 4 (esp32-amp's eFuse,
|
|
3697
|
+
// VBUS_FUSED over EFUSE_USB_FLT). The name now continues outward along
|
|
3698
|
+
// the row, past the bar, where the row is its own.
|
|
3699
|
+
const valueAtOf = (end: { x: number; y: number }): { x: number; y: number } =>
|
|
3700
|
+
o.dy !== 0
|
|
3701
|
+
? { x: end.x, y: end.y + o.dy * 3.556 }
|
|
3702
|
+
: { x: end.x + o.dx * (Math.max(1, net.name.length) * LABEL_ADVANCE * LABEL_HEIGHT / 2 + 1.905), y: end.y };
|
|
1688
3703
|
// Adjacent power pins collide their value texts two ways, resolved two
|
|
1689
3704
|
// ways. The SAME net repeated (a TQFP's VCC pins one row apart) hides
|
|
1690
3705
|
// the duplicates: one visible name per cluster carries the same
|
|
@@ -1835,28 +3850,35 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1835
3850
|
const o = outward(ep.pin);
|
|
1836
3851
|
return { ep, end: { x: ep.at.x + o.dx * STUB * U, y: ep.at.y + o.dy * STUB * U }, o };
|
|
1837
3852
|
});
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
const
|
|
3853
|
+
type Stub = { ep: (typeof eps)[number]; end: { x: number; y: number }; o: { dx: number; dy: number } };
|
|
3854
|
+
/**
|
|
3855
|
+
* Trunk-and-branch route for `subset`: a vertical trunk with horizontal
|
|
3856
|
+
* branches, at the first of three deterministic trunk positions (median
|
|
3857
|
+
* stub x, right of everything, left of everything) whose every segment
|
|
3858
|
+
* clears every body and every foreign connection point (I22, #204). Null
|
|
3859
|
+
* when none does — the engine may never trip its own wire-through-symbol
|
|
3860
|
+
* gate, and a routed contact would merge nets.
|
|
3861
|
+
*/
|
|
3862
|
+
type RouteSeg = { x1: number; y1: number; x2: number; y2: number };
|
|
3863
|
+
type Route = { segs: RouteSeg[]; trunkX: number; trunkY?: number };
|
|
3864
|
+
const routeStubs = (subset: Stub[]): Route | null => {
|
|
3865
|
+
const xs = subset.map((s) => s.end.x).sort((a, b) => a - b);
|
|
3866
|
+
const ys = subset.map((s) => s.end.y);
|
|
3867
|
+
const admit = (candidate: RouteSeg[]): boolean => {
|
|
3868
|
+
if (candidate.some((c) => bodies.some((b) => segCrossesBody(c.x1, c.y1, c.x2, c.y2, b)))) return false;
|
|
3869
|
+
return !touchesForeign(candidate, net.name, new Set(net.pins));
|
|
3870
|
+
};
|
|
3871
|
+
const found: Route[] = [];
|
|
3872
|
+
// A vertical trunk with horizontal branches, at the median stub x,
|
|
3873
|
+
// right of everything, left of everything.
|
|
1851
3874
|
const trunkCandidates = [
|
|
1852
3875
|
grid(Math.round(xs[Math.floor(xs.length / 2)]! / U)),
|
|
1853
3876
|
grid(Math.round(xs[xs.length - 1]! / U) + STUB),
|
|
1854
3877
|
grid(Math.round(xs[0]! / U) - STUB),
|
|
1855
3878
|
];
|
|
1856
|
-
let routed = false;
|
|
1857
3879
|
for (const trunkX of trunkCandidates) {
|
|
1858
|
-
const candidate:
|
|
1859
|
-
for (const s of
|
|
3880
|
+
const candidate: RouteSeg[] = [];
|
|
3881
|
+
for (const s of subset) {
|
|
1860
3882
|
candidate.push({ x1: s.ep.at.x, y1: s.ep.at.y, x2: s.end.x, y2: s.end.y });
|
|
1861
3883
|
if (!sameCoord(s.end.x, trunkX)) candidate.push({ x1: s.end.x, y1: s.end.y, x2: trunkX, y2: s.end.y });
|
|
1862
3884
|
}
|
|
@@ -1866,69 +3888,250 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1866
3888
|
for (let i = 1; i < meetYs.length; i++) {
|
|
1867
3889
|
candidate.push({ x1: trunkX, y1: meetYs[i - 1]!, x2: trunkX, y2: meetYs[i]! });
|
|
1868
3890
|
}
|
|
1869
|
-
if (candidate
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
const
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
3891
|
+
if (admit(candidate)) found.push({ segs: candidate, trunkX });
|
|
3892
|
+
}
|
|
3893
|
+
// A comb: the trunk lies ALONG one endpoint's row — the IC pin's row
|
|
3894
|
+
// when the net has one — and every other stub rises or drops to it.
|
|
3895
|
+
// This is how a pin with parts hung on it is drawn: the row runs out to
|
|
3896
|
+
// the axis, the chain above and the chain below meet it in one T. The
|
|
3897
|
+
// vertical trunk alone could not route a pull-up and a pull-down on one
|
|
3898
|
+
// pin (its branch to the second part ran along a foreign row), so one
|
|
3899
|
+
// of them shipped labelled.
|
|
3900
|
+
const rows = [...new Map(subset.map((s) => [knum(s.end.y), s])).values()].sort((a, b) => {
|
|
3901
|
+
const ica = (placed.get(a.ep.ref)?.sym.pins.length ?? 0) >= 3 ? 0 : 1;
|
|
3902
|
+
const icb = (placed.get(b.ep.ref)?.sym.pins.length ?? 0) >= 3 ? 0 : 1;
|
|
3903
|
+
return ica - icb || a.end.y - b.end.y;
|
|
3904
|
+
});
|
|
3905
|
+
for (const rowStub of rows) {
|
|
3906
|
+
const trunkY = rowStub.end.y;
|
|
3907
|
+
const candidate: RouteSeg[] = [];
|
|
3908
|
+
for (const s of subset) {
|
|
3909
|
+
candidate.push({ x1: s.ep.at.x, y1: s.ep.at.y, x2: s.end.x, y2: s.end.y });
|
|
3910
|
+
if (!sameCoord(s.end.y, trunkY)) candidate.push({ x1: s.end.x, y1: s.end.y, x2: s.end.x, y2: trunkY });
|
|
3911
|
+
}
|
|
3912
|
+
const meetXs = [...new Map(xs.map((x) => [knum(x), x])).values()].sort((a, b) => a - b);
|
|
3913
|
+
for (let i = 1; i < meetXs.length; i++) {
|
|
3914
|
+
candidate.push({ x1: meetXs[i - 1]!, y1: trunkY, x2: meetXs[i]!, y2: trunkY });
|
|
3915
|
+
}
|
|
3916
|
+
if (admit(candidate)) found.push({ segs: candidate, trunkX: meetXs[0]!, trunkY });
|
|
3917
|
+
}
|
|
3918
|
+
// A hook, for two stubs where one lies on a row and the other is a
|
|
3919
|
+
// lead below or above it on an axis the row would have to cross: run
|
|
3920
|
+
// the row past the lead by two units, turn to the lead's level, and
|
|
3921
|
+
// come back to its stub end. This is how a bootstrap cap's far lead is
|
|
3922
|
+
// reached from the row it bridges to (the other lead sits on the same
|
|
3923
|
+
// axis, in the way of a straight drop).
|
|
3924
|
+
if (!found.length && subset.length === 2) {
|
|
3925
|
+
for (const [a, b] of [[subset[0]!, subset[1]!], [subset[1]!, subset[0]!]] as const) {
|
|
3926
|
+
if (a.o.dx === 0 || b.o.dy === 0) continue; // a: a sideways pin (the row); b: a vertical lead
|
|
3927
|
+
for (const past of [2 * U, -2 * U]) {
|
|
3928
|
+
const turnX = grid(Math.round((b.end.x + past) / U));
|
|
3929
|
+
const candidate: RouteSeg[] = [
|
|
3930
|
+
{ x1: a.ep.at.x, y1: a.ep.at.y, x2: a.end.x, y2: a.end.y },
|
|
3931
|
+
{ x1: a.end.x, y1: a.end.y, x2: turnX, y2: a.end.y },
|
|
3932
|
+
{ x1: turnX, y1: a.end.y, x2: turnX, y2: b.end.y },
|
|
3933
|
+
{ x1: turnX, y1: b.end.y, x2: b.end.x, y2: b.end.y },
|
|
3934
|
+
{ x1: b.ep.at.x, y1: b.ep.at.y, x2: b.end.x, y2: b.end.y },
|
|
3935
|
+
].filter((c) => !(sameCoord(c.x1, c.x2) && sameCoord(c.y1, c.y2)));
|
|
3936
|
+
if (admit(candidate)) {
|
|
3937
|
+
found.push({ segs: candidate, trunkX: turnX });
|
|
3938
|
+
break;
|
|
3939
|
+
}
|
|
1891
3940
|
}
|
|
3941
|
+
if (found.length) break;
|
|
1892
3942
|
}
|
|
1893
|
-
routed = true;
|
|
1894
|
-
break;
|
|
1895
3943
|
}
|
|
1896
|
-
if (!
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
3944
|
+
if (!found.length) {
|
|
3945
|
+
trace(`route ${net.name}: no clean route for ${subset.map((s) => `${s.ep.ref}.${s.ep.pin.number}`).join(', ')}`);
|
|
3946
|
+
return null;
|
|
3947
|
+
}
|
|
3948
|
+
// the fewest segments is the fewest bends; ties keep the classic order
|
|
3949
|
+
return found.reduce((best, r) => (r.segs.length < best.segs.length ? r : best), found[0]!);
|
|
3950
|
+
};
|
|
3951
|
+
/** Labels naming this net's wired runs, promoted to flags below if the net also leaves through a stub. */
|
|
3952
|
+
const netWired: number[] = [];
|
|
3953
|
+
/** Draw a routed subset: its wires, one label naming the net, and junction dots. */
|
|
3954
|
+
const commitRoute = (subset: Stub[], r: Route): void => {
|
|
3955
|
+
for (const c of r.segs) addWire(net.name, c.x1, c.y1, c.x2, c.y2);
|
|
3956
|
+
// one label names the wired run (topmost-leftmost wire point): the net
|
|
3957
|
+
// stays identifiable to PINOUT/drift and to a reviewer without a
|
|
3958
|
+
// label-per-pin, matching hand-drafting practice
|
|
3959
|
+
const pts = r.segs.flatMap((c) => [
|
|
3960
|
+
{ x: c.x1, y: c.y1 },
|
|
3961
|
+
{ x: c.x2, y: c.y2 },
|
|
3962
|
+
]);
|
|
3963
|
+
pts.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
3964
|
+
// The name stands above the wire from its anchor rightward. A trunk's
|
|
3965
|
+
// top is the classic place; a run that is one horizontal row (a part
|
|
3966
|
+
// on its pin's row) has no trunk, and its left end is the pin, so the
|
|
3967
|
+
// name sits over the stub and the gap the row left for it. Whichever
|
|
3968
|
+
// candidate clears every body wins; the run's top-left point is the
|
|
3969
|
+
// last resort.
|
|
3970
|
+
const vertTops = r.segs.filter((c) => sameCoord(c.x1, c.x2)).map((c) => ({ x: c.x1, y: Math.min(c.y1, c.y2) }));
|
|
3971
|
+
const candidates = [...vertTops.sort((a, b) => a.y - b.y || a.x - b.x), pts[0]!];
|
|
3972
|
+
const clearOfBodies = (pt: { x: number; y: number }): boolean => {
|
|
3973
|
+
const b = labelTextBox(net.name, pt.x, pt.y, 0);
|
|
3974
|
+
if ([...placed.values()].some((pl) => boundsOverlap(b, pl.body))) return false;
|
|
3975
|
+
return !labels.some((o) => o.name !== net.name && boundsOverlap(padBox(b), labelTextBox(o.name, o.x, o.y, o.rot)));
|
|
3976
|
+
};
|
|
3977
|
+
const at = candidates.find(clearOfBodies) ?? pts[0]!;
|
|
3978
|
+
labels.push({ name: net.name, x: at.x, y: at.y, rot: 0, kind: 'local' });
|
|
3979
|
+
netWired.push(labels.length - 1);
|
|
3980
|
+
wiredLabels.push({ label: labels.length - 1, pts, segs: r.segs.map((c) => ({ ...c })) });
|
|
3981
|
+
wired++;
|
|
3982
|
+
if (subset.length > 2) {
|
|
3983
|
+
if (r.trunkY !== undefined) {
|
|
3984
|
+
// comb: a branch meeting the row trunk strictly inside its span is a T
|
|
3985
|
+
const xs = subset.map((s) => s.end.x);
|
|
3986
|
+
for (const s of subset) {
|
|
3987
|
+
const meet = sameCoord(s.end.y, r.trunkY) ? s.end : { x: s.end.x, y: r.trunkY };
|
|
3988
|
+
if (meet.x > Math.min(...xs) + 0.01 && meet.x < Math.max(...xs) - 0.01) junctions.push(meet);
|
|
1924
3989
|
}
|
|
3990
|
+
} else {
|
|
3991
|
+
const ys = subset.map((s) => s.end.y);
|
|
3992
|
+
for (const s of subset) {
|
|
3993
|
+
const meet = sameCoord(s.end.x, r.trunkX) ? s.end : { x: r.trunkX, y: s.end.y };
|
|
3994
|
+
if (meet.y > Math.min(...ys) && meet.y < Math.max(...ys)) junctions.push(meet);
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
};
|
|
3999
|
+
|
|
4000
|
+
// Clusters: endpoints in one group whose stub ends sit within wire span of
|
|
4001
|
+
// one another, in x order. A net that stays in one group and fits the
|
|
4002
|
+
// span is one cluster — the classic case, drawn as before. A net that
|
|
4003
|
+
// also leaves the group, or runs longer, still gets each local run wired
|
|
4004
|
+
// and labelled once: adjacency before labelling (#220 phase 3), so a part
|
|
4005
|
+
// hung on a pin or lying on its row is wired to it whatever the rest of
|
|
4006
|
+
// the net does. Lone endpoints keep a stub and a label.
|
|
4007
|
+
const sorted: Stub[] = [...stubs].sort((a, b) => a.end.x - b.end.x || a.end.y - b.end.y);
|
|
4008
|
+
const clusters: Stub[][] = [];
|
|
4009
|
+
const anchorKey = (ref: string): string => {
|
|
4010
|
+
let k = ref;
|
|
4011
|
+
for (let i = 0; i < 4 && boundTo.has(k); i++) k = boundTo.get(k)!;
|
|
4012
|
+
return k;
|
|
4013
|
+
};
|
|
4014
|
+
const near = (a: Stub, b: Stub): boolean =>
|
|
4015
|
+
(Math.abs(a.end.x - b.end.x) <= MAX_WIRE_SPAN && Math.abs(a.end.y - b.end.y) <= MAX_WIRE_SPAN) ||
|
|
4016
|
+
// a part hung on a pin or lying on its row joins that pin's cluster
|
|
4017
|
+
// however far its lane sits: the fourth lane of a comb is past the
|
|
4018
|
+
// wire span, and it is still that pin's part
|
|
4019
|
+
anchorKey(a.ep.ref) === anchorKey(b.ep.ref);
|
|
4020
|
+
for (const s of sorted) {
|
|
4021
|
+
const home = clusters.find((cl) => groupOf.get(cl[0]!.ep.ref) === groupOf.get(s.ep.ref) && cl.every((o) => near(o, s)));
|
|
4022
|
+
if (home) home.push(s);
|
|
4023
|
+
else clusters.push([s]);
|
|
4024
|
+
}
|
|
4025
|
+
const wiredStubs = new Set<Stub>();
|
|
4026
|
+
for (const found of clusters) {
|
|
4027
|
+
// A cluster past the wired-net size is a pin with its hung parts plus
|
|
4028
|
+
// whatever else sits within span: it is narrowed to the largest set of
|
|
4029
|
+
// endpoints bound to one anchor, so the parts hung on a pin are wired
|
|
4030
|
+
// to it however many there are, and the rest keep stubs and labels.
|
|
4031
|
+
let cl = found;
|
|
4032
|
+
if (cl.length > MAX_WIRED_ENDPOINTS) {
|
|
4033
|
+
const byAnchor = new Map<string, Stub[]>();
|
|
4034
|
+
for (const s of cl) byAnchor.set(anchorKey(s.ep.ref), [...(byAnchor.get(anchorKey(s.ep.ref)) ?? []), s]);
|
|
4035
|
+
cl = [...byAnchor.values()].reduce((a, b) => (b.length > a.length ? b : a));
|
|
4036
|
+
trace(`route ${net.name}: cluster of ${found.length} narrowed to ${cl.length} bound to ${anchorKey(cl[0]!.ep.ref)}`);
|
|
4037
|
+
}
|
|
4038
|
+
if (cl.length < 2) continue;
|
|
4039
|
+
// the whole cluster, else the largest subset that routes: a run on the
|
|
4040
|
+
// IC pin's row still draws as a wire when a third endpoint's branch
|
|
4041
|
+
// cannot be cleared, and only that endpoint keeps a stub label. Subsets
|
|
4042
|
+
// are tried largest first in index order, at most ROUTE_ATTEMPTS.
|
|
4043
|
+
let attempts = 0;
|
|
4044
|
+
let done = false;
|
|
4045
|
+
const cur: Stub[] = [];
|
|
4046
|
+
const pick = (start: number, size: number): void => {
|
|
4047
|
+
if (done || attempts >= ROUTE_ATTEMPTS) return;
|
|
4048
|
+
if (cur.length === size) {
|
|
4049
|
+
attempts++;
|
|
4050
|
+
const r = routeStubs(cur);
|
|
4051
|
+
if (!r) return;
|
|
4052
|
+
const sub = [...cur];
|
|
4053
|
+
commitRoute(sub, r);
|
|
4054
|
+
for (const s of sub) wiredStubs.add(s);
|
|
4055
|
+
done = true;
|
|
4056
|
+
return;
|
|
4057
|
+
}
|
|
4058
|
+
for (let i = start; i <= cl.length - (size - cur.length) && !done; i++) {
|
|
4059
|
+
cur.push(cl[i]!);
|
|
4060
|
+
pick(i + 1, size);
|
|
4061
|
+
cur.pop();
|
|
4062
|
+
}
|
|
4063
|
+
};
|
|
4064
|
+
for (let size = cl.length; size >= 2 && !done && attempts < ROUTE_ATTEMPTS; size--) pick(0, size);
|
|
4065
|
+
if (!done && attempts >= ROUTE_ATTEMPTS) trace(`route ${net.name}: ${ROUTE_ATTEMPTS} subsets of ${cl.length} tried, none routed`);
|
|
4066
|
+
}
|
|
4067
|
+
for (const s of stubs) {
|
|
4068
|
+
if (wiredStubs.has(s)) continue;
|
|
4069
|
+
// A stub is a wire too: its endpoint resting on a foreign net's wire
|
|
4070
|
+
// or connection point merges nets exactly like a trunk would (the
|
|
4071
|
+
// cap-to-ground drop's 2-unit stub ended on the neighbouring power
|
|
4072
|
+
// pin's stub interior — I22's third face). Grow the stub a grid unit
|
|
4073
|
+
// at a time until the endpoint is clear; the interior then CROSSES
|
|
4074
|
+
// the foreign wire mid-segment, which does not connect. If no length
|
|
4075
|
+
// clears, emit the plain stub and let the merged-net gate refuse
|
|
4076
|
+
// loudly rather than ship the contact.
|
|
4077
|
+
let end = s.end;
|
|
4078
|
+
const own = new Set(net.pins);
|
|
4079
|
+
// Rungs in preference order: the classic 0..2 extensions first so
|
|
4080
|
+
// clear cases stay byte-identical, then deeper extensions, then a
|
|
4081
|
+
// one-unit retreat. A stub that ships with NO clear rung still ends
|
|
4082
|
+
// touching a foreign wire and the merge gate refuses the draft, so
|
|
4083
|
+
// every extra rung here is a board that drafts instead of refusing
|
|
4084
|
+
// (jetson's twelve wire-contact refusals were exactly this fallback).
|
|
4085
|
+
for (const len of [STUB, STUB + 1, STUB + 2, STUB + 3, STUB + 4, STUB + 5, STUB + 6, 1]) {
|
|
4086
|
+
const cand = {
|
|
4087
|
+
x: s.ep.at.x + s.o.dx * len * U,
|
|
4088
|
+
y: s.ep.at.y + s.o.dy * len * U,
|
|
4089
|
+
};
|
|
4090
|
+
if (!touchesForeign([{ x1: s.ep.at.x, y1: s.ep.at.y, x2: cand.x, y2: cand.y }], net.name, own)) {
|
|
4091
|
+
end = cand;
|
|
4092
|
+
break;
|
|
1925
4093
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
4094
|
+
}
|
|
4095
|
+
addWire(net.name, s.ep.at.x, s.ep.at.y, end.x, end.y);
|
|
4096
|
+
// a stub ends in a global flag that continues the stub's direction,
|
|
4097
|
+
// the way a person hangs a flag on a wire end: a leftward stub reads
|
|
4098
|
+
// leftward, an upward one reads upward; the shape says what the pin is
|
|
4099
|
+
labels.push({ name: net.name, x: end.x, y: end.y, rot: rotOutward(s.o), kind: 'global', shape: flagShape(s.ep.pin.etype) });
|
|
4100
|
+
stubbedLabels.push({ label: labels.length - 1, wire: wires.length - 1, o: s.o, pins: net.pins });
|
|
4101
|
+
labelled++;
|
|
4102
|
+
}
|
|
4103
|
+
// A net that also leaves its run through a stub is named by global flags
|
|
4104
|
+
// there, and a local label of the same name would not connect to them in
|
|
4105
|
+
// KiCad: the run's own name becomes a flag too, re-anchored where a flag
|
|
4106
|
+
// fits (a local name stands over its wire; a flag has a body of its own).
|
|
4107
|
+
// A net wired whole keeps its plain local name on the run.
|
|
4108
|
+
if (netWired.length && stubs.some((s) => !wiredStubs.has(s))) {
|
|
4109
|
+
for (const idx of netWired) {
|
|
4110
|
+
const lb = labels[idx]!;
|
|
4111
|
+
const rec = wiredLabels.find((w) => w.label === idx)!;
|
|
4112
|
+
lb.kind = 'global';
|
|
4113
|
+
lb.shape = 'passive';
|
|
4114
|
+
const clear = (c: { x: number; y: number; rot: number }): boolean =>
|
|
4115
|
+
![...placed.values()].some((pl) => boundsOverlap(labelTextBox(lb.name, c.x, c.y, c.rot, 'global'), pl.body));
|
|
4116
|
+
const at = wiredFlagCandidates(rec.segs).find(clear) ?? { x: lb.x, y: lb.y, rot: 90 };
|
|
4117
|
+
trace(`label ${lb.name}: run name becomes a flag at (${at.x}, ${at.y}, ${at.rot})`);
|
|
4118
|
+
lb.x = at.x;
|
|
4119
|
+
lb.y = at.y;
|
|
4120
|
+
lb.rot = at.rot;
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
|
|
4125
|
+
if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') {
|
|
4126
|
+
// every crossing of two nets' wires, for the trace: a comb whose lanes
|
|
4127
|
+
// cross another pin's row is legal, but a drafter wants to know where
|
|
4128
|
+
const H = wires.filter((w) => sameCoord(w.y1, w.y2));
|
|
4129
|
+
const V = wires.filter((w) => sameCoord(w.x1, w.x2));
|
|
4130
|
+
for (const h of H) {
|
|
4131
|
+
for (const v of V) {
|
|
4132
|
+
if (h.net === v.net) continue;
|
|
4133
|
+
const hx1 = Math.min(h.x1, h.x2), hx2 = Math.max(h.x1, h.x2), vy1 = Math.min(v.y1, v.y2), vy2 = Math.max(v.y1, v.y2);
|
|
4134
|
+
if (v.x1 > hx1 + 0.01 && v.x1 < hx2 - 0.01 && h.y1 > vy1 + 0.01 && h.y1 < vy2 - 0.01) trace(`crossing ${h.net} (row y=${h.y1}) x ${v.net} (x=${v.x1})`);
|
|
1932
4135
|
}
|
|
1933
4136
|
}
|
|
1934
4137
|
}
|
|
@@ -1946,11 +4149,38 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1946
4149
|
* unit letter (U1A, U1B), so width metrics must measure the rendered text. */
|
|
1947
4150
|
const displayRefOf = (pl: Placed): string =>
|
|
1948
4151
|
pl.unit !== null ? `${pl.refDes}${String.fromCharCode(64 + Math.min(pl.unit, 26))}` : pl.refDes;
|
|
4152
|
+
/**
|
|
4153
|
+
* Width of the body interior left free by the pin NAMES drawn inside it on
|
|
4154
|
+
* the rows a ref/value pair at `cy` would occupy. A large module's names
|
|
4155
|
+
* (ESP32-WROVER's "MTMS/GPIO14/ADC2_CH6") run most of the way across, and a
|
|
4156
|
+
* value dropped on the centre line read as one word with them.
|
|
4157
|
+
*/
|
|
4158
|
+
const interiorSpan = (pl: Placed, cy: number): { left: number; right: number } => {
|
|
4159
|
+
let leftW = 0;
|
|
4160
|
+
let rightW = 0;
|
|
4161
|
+
for (const pin of pl.sym.pins) {
|
|
4162
|
+
const o = outward(pin);
|
|
4163
|
+
if (o.dx === 0) continue;
|
|
4164
|
+
const py = pinAt(pl, pin).y;
|
|
4165
|
+
if (Math.abs(py - cy) > 2.54 + 0.7) continue;
|
|
4166
|
+
const w = Math.max(0, pin.name.length) * NAME_ADVANCE * LABEL_HEIGHT;
|
|
4167
|
+
if (o.dx === -1) leftW = Math.max(leftW, w);
|
|
4168
|
+
else rightW = Math.max(rightW, w);
|
|
4169
|
+
}
|
|
4170
|
+
return { left: pl.body.minX + leftW, right: pl.body.maxX - rightW };
|
|
4171
|
+
};
|
|
4172
|
+
/** A library symbol that draws text of its own inside the body (ESP32-WROVER
|
|
4173
|
+
* prints its family name across the middle) has no free interior. */
|
|
4174
|
+
const hasGraphicText = (pl: Placed): boolean => /\(text\s/.test(pl.sym.sourceText ?? '');
|
|
4175
|
+
const pinSidesOf = (pl: Placed): Set<string> =>
|
|
4176
|
+
new Set(
|
|
4177
|
+
pl.sym.pins.map((p) => {
|
|
4178
|
+
const o = outward(p);
|
|
4179
|
+
return o.dx === -1 ? 'left' : o.dx === 1 ? 'right' : o.dy === -1 ? 'top' : 'bottom';
|
|
4180
|
+
}),
|
|
4181
|
+
);
|
|
1949
4182
|
for (const [, pl] of [...placed.entries()].sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }))) {
|
|
1950
|
-
const pinSides =
|
|
1951
|
-
const o = outward(p);
|
|
1952
|
-
return o.dx === -1 ? 'left' : o.dx === 1 ? 'right' : o.dy === -1 ? 'top' : 'bottom';
|
|
1953
|
-
}));
|
|
4183
|
+
const pinSides = pinSidesOf(pl);
|
|
1954
4184
|
const cy = (pl.body.minY + pl.body.maxY) / 2;
|
|
1955
4185
|
const cx = (pl.body.minX + pl.body.maxX) / 2;
|
|
1956
4186
|
const textW = Math.max(displayRefOf(pl).length, pl.part.value.length) * 0.8 * 1.27;
|
|
@@ -1962,15 +4192,22 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1962
4192
|
// below the body otherwise
|
|
1963
4193
|
valueAt = pinSides.has('bottom') ? { x: cx, y: pl.body.minY - 5.08 } : { x: cx, y: pl.body.maxY + 2.54 };
|
|
1964
4194
|
} else if (
|
|
1965
|
-
pl.body.
|
|
1966
|
-
pl
|
|
4195
|
+
pl.body.maxY - pl.body.minY >= 7.62 &&
|
|
4196
|
+
!hasGraphicText(pl) &&
|
|
4197
|
+
// the name at its real advance, centred in the interval the pin names
|
|
4198
|
+
// leave free, with two text heights of air each side: measured from
|
|
4199
|
+
// the body centre instead, ESP32-WROVER's value printed across the
|
|
4200
|
+
// ends of its long left-hand pin names
|
|
4201
|
+
interiorSpan(pl, cy).right - interiorSpan(pl, cy).left >= (textW * NAME_ADVANCE) / LABEL_ADVANCE + 4 * 2.54
|
|
1967
4202
|
) {
|
|
1968
4203
|
// pins on top AND a body big enough to hold its own name: a TQFP-class
|
|
1969
4204
|
// part carries pins on all four sides, so every outside slot lands on
|
|
1970
4205
|
// some pin's stub or label; the body interior is the one guaranteed-free
|
|
1971
4206
|
// area, and it is where KiCad's own large symbols put their text
|
|
1972
|
-
|
|
1973
|
-
|
|
4207
|
+
const span = interiorSpan(pl, cy);
|
|
4208
|
+
const mid = grid(Math.round((span.left + span.right) / 2 / U));
|
|
4209
|
+
refAt = { x: mid, y: cy - 1.27 };
|
|
4210
|
+
valueAt = { x: mid, y: cy + 1.27 };
|
|
1974
4211
|
} else {
|
|
1975
4212
|
refAt = { x: pl.body.maxX + textW / 2 + 1.27, y: cy - 1.27 };
|
|
1976
4213
|
valueAt = { x: pl.body.maxX + textW / 2 + 1.27, y: cy + 1.27 };
|
|
@@ -1980,7 +4217,8 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
1980
4217
|
libId: pl.sym.libId,
|
|
1981
4218
|
value: pl.part.value,
|
|
1982
4219
|
footprint: pl.part.footprint ?? '',
|
|
1983
|
-
at: { x: pl.x, y: pl.y, rot:
|
|
4220
|
+
at: { x: pl.x, y: pl.y, rot: pl.rot },
|
|
4221
|
+
...(pl.mirror ? { mirror: pl.mirror } : {}),
|
|
1984
4222
|
refAt,
|
|
1985
4223
|
valueAt,
|
|
1986
4224
|
pinNumbers: pl.sym.pins.map((p) => p.number),
|
|
@@ -2017,6 +4255,8 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2017
4255
|
const fieldBoxes: Bounds[] = extraSymbols
|
|
2018
4256
|
.filter((s) => !s.hideValue)
|
|
2019
4257
|
.map((s) => centeredTextBox(s.value, s.valueAt.x, s.valueAt.y));
|
|
4258
|
+
const powerBodies: Bounds[] = extraSymbols.map((s) => ({ minX: s.at.x - 2 * U, minY: s.at.y - 2 * U, maxX: s.at.x + 2 * U, maxY: s.at.y + 2 * U }));
|
|
4259
|
+
const labelBoxes: Bounds[] = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
|
|
2020
4260
|
for (const { sym, pl } of emitPairs) {
|
|
2021
4261
|
const dref = displayRefOf(pl);
|
|
2022
4262
|
const cx = (pl.body.minX + pl.body.maxX) / 2;
|
|
@@ -2028,16 +4268,62 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2028
4268
|
for (const op of placed.values()) {
|
|
2029
4269
|
if (op !== pl && boundsOverlap(b, op.body)) return false;
|
|
2030
4270
|
}
|
|
2031
|
-
|
|
4271
|
+
// a rail bar or ground symbol is a drawn body too, not just its
|
|
4272
|
+
// value text (R23's reference printed through PVDD's bar)
|
|
4273
|
+
if (powerBodies.some((pb) => boundsOverlap(b, pb))) return false;
|
|
4274
|
+
// the part's own pins: the pin line and its number, from the pin
|
|
4275
|
+
// end to the body edge (U8's name printed over its no-connect pins,
|
|
4276
|
+
// which draw no wire for the wire check to see)
|
|
4277
|
+
for (const pin of pl.sym.pins) {
|
|
4278
|
+
const p = pinAt(pl, pin);
|
|
4279
|
+
const o = outward(pin);
|
|
4280
|
+
const band: Bounds = o.dx !== 0
|
|
4281
|
+
? { minX: Math.min(p.x, o.dx === 1 ? pl.body.maxX : pl.body.minX), maxX: Math.max(p.x, o.dx === 1 ? pl.body.maxX : pl.body.minX), minY: p.y - 1.5, maxY: p.y + 1.5 }
|
|
4282
|
+
: { minX: p.x - 1.5, maxX: p.x + 1.5, minY: Math.min(p.y, o.dy === 1 ? pl.body.maxY : pl.body.minY), maxY: Math.max(p.y, o.dy === 1 ? pl.body.maxY : pl.body.minY) };
|
|
4283
|
+
if (boundsOverlap(b, band)) return false;
|
|
4284
|
+
}
|
|
4285
|
+
if (fieldBoxes.some((t) => boundsOverlap(t, padBox(b)))) return false;
|
|
4286
|
+
// net labels are on the sheet by now; a slot on top of one is no slot
|
|
4287
|
+
if (labelBoxes.some((t) => boundsOverlap(t, padBox(b)))) return false;
|
|
2032
4288
|
}
|
|
2033
4289
|
return true;
|
|
2034
4290
|
};
|
|
2035
4291
|
if (!pairClear(sym.refAt, sym.valueAt)) {
|
|
2036
4292
|
const ladder: [{ x: number; y: number }, { x: number; y: number }][] = [];
|
|
2037
|
-
|
|
4293
|
+
// corner slots: a part with a stub on its top or bottom centre (a
|
|
4294
|
+
// module's rail pin) still has its top-left and top-right free
|
|
4295
|
+
const xl = pl.body.minX + textW / 2 + 1.27;
|
|
4296
|
+
const xr = pl.body.maxX - textW / 2 - 1.27;
|
|
4297
|
+
// A part lying on a row (leads left and right) has rows one pitch
|
|
4298
|
+
// above and below it; its texts fit BETWEEN the rows, half a pitch
|
|
4299
|
+
// out from the body, wherever the neighbouring row carries no wire
|
|
4300
|
+
// there. Reference above and value below first, then both on one
|
|
4301
|
+
// side, then side by side on one line.
|
|
4302
|
+
if (pinSidesOf(pl).has('left') && pinSidesOf(pl).has('right') && !pinSidesOf(pl).has('top')) {
|
|
4303
|
+
const hh = 1.27;
|
|
4304
|
+
// Both texts on ONE side first, and side by side on one line before
|
|
4305
|
+
// stacked: two parts on rows two pitches apart then keep their texts
|
|
4306
|
+
// in their own half of the gap (C27's value and L3's reference met
|
|
4307
|
+
// in the middle of the five millimetres between their rows).
|
|
4308
|
+
ladder.push(
|
|
4309
|
+
[{ x: cx, y: pl.body.minY - hh - 2.54 }, { x: cx, y: pl.body.minY - hh }],
|
|
4310
|
+
[{ x: cx - textW / 2 - 0.635, y: pl.body.minY - hh }, { x: cx + textW / 2 + 0.635, y: pl.body.minY - hh }],
|
|
4311
|
+
[{ x: cx, y: pl.body.maxY + hh }, { x: cx, y: pl.body.maxY + hh + 2.54 }],
|
|
4312
|
+
[{ x: cx - textW / 2 - 0.635, y: pl.body.maxY + hh }, { x: cx + textW / 2 + 0.635, y: pl.body.maxY + hh }],
|
|
4313
|
+
[{ x: cx, y: pl.body.minY - hh }, { x: cx, y: pl.body.maxY + hh }],
|
|
4314
|
+
);
|
|
4315
|
+
}
|
|
4316
|
+
// rungs reach past a rail symbol and its name on a top or bottom
|
|
4317
|
+
// stub (about 6 mm out), so a module pinned on all four sides still
|
|
4318
|
+
// finds a slot above or below itself instead of on its own labels
|
|
4319
|
+
for (const extra of [0, 2.54, 5.08, 7.62, 10.16]) {
|
|
2038
4320
|
ladder.push(
|
|
2039
4321
|
[{ x: cx, y: pl.body.maxY + 2.54 + extra }, { x: cx, y: pl.body.maxY + 5.08 + extra }],
|
|
2040
4322
|
[{ x: cx, y: pl.body.minY - 5.08 - extra }, { x: cx, y: pl.body.minY - 2.54 - extra }],
|
|
4323
|
+
[{ x: xl, y: pl.body.minY - 5.08 - extra }, { x: xl, y: pl.body.minY - 2.54 - extra }],
|
|
4324
|
+
[{ x: xr, y: pl.body.minY - 5.08 - extra }, { x: xr, y: pl.body.minY - 2.54 - extra }],
|
|
4325
|
+
[{ x: xl, y: pl.body.maxY + 2.54 + extra }, { x: xl, y: pl.body.maxY + 5.08 + extra }],
|
|
4326
|
+
[{ x: xr, y: pl.body.maxY + 2.54 + extra }, { x: xr, y: pl.body.maxY + 5.08 + extra }],
|
|
2041
4327
|
[
|
|
2042
4328
|
{ x: pl.body.maxX + textW / 2 + 1.27 + extra, y: cy - 1.27 },
|
|
2043
4329
|
{ x: pl.body.maxX + textW / 2 + 1.27 + extra, y: cy + 1.27 },
|
|
@@ -2048,13 +4334,16 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2048
4334
|
],
|
|
2049
4335
|
);
|
|
2050
4336
|
}
|
|
4337
|
+
let slotted = false;
|
|
2051
4338
|
for (const [r, v] of ladder) {
|
|
2052
4339
|
if (pairClear(r, v)) {
|
|
2053
4340
|
sym.refAt = r;
|
|
2054
4341
|
sym.valueAt = v;
|
|
4342
|
+
slotted = true;
|
|
2055
4343
|
break;
|
|
2056
4344
|
}
|
|
2057
4345
|
}
|
|
4346
|
+
if (!slotted) trace(`fields of ${dref}: no clear slot in ${ladder.length} rungs; heuristic kept`);
|
|
2058
4347
|
}
|
|
2059
4348
|
fieldBoxes.push(centeredTextBox(dref, sym.refAt.x, sym.refAt.y), centeredTextBox(sym.value, sym.valueAt.x, sym.valueAt.y));
|
|
2060
4349
|
}
|
|
@@ -2095,22 +4384,86 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2095
4384
|
// are the label's own attachment and never count as collisions.
|
|
2096
4385
|
for (const rec of wiredLabels) {
|
|
2097
4386
|
const lb = labels[rec.label]!;
|
|
2098
|
-
const clearWired = (x: number, y: number): boolean => {
|
|
2099
|
-
const box = labelTextBox(lb.name, x, y,
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
4387
|
+
const clearWired = (x: number, y: number, rot: number, pad = TEXT_PAD): boolean => {
|
|
4388
|
+
const box = labelTextBox(lb.name, x, y, rot, lb.kind);
|
|
4389
|
+
const padBox = (b: Bounds): Bounds => ({ minX: b.minX - pad, minY: b.minY - pad, maxX: b.maxX + pad, maxY: b.maxY + pad });
|
|
4390
|
+
const blocked = (why: string): false => {
|
|
4391
|
+
trace(`label ${lb.name} at (${x}, ${y}, ${rot}) blocked by ${why}`);
|
|
4392
|
+
return false;
|
|
4393
|
+
};
|
|
4394
|
+
// A wire through the anchor is the label's own attachment — for a
|
|
4395
|
+
// plain label, whose text stands above the line. A flag is drawn
|
|
4396
|
+
// CENTRED on its anchor line, so a wire continuing under its text
|
|
4397
|
+
// runs straight through the flag; only the wire on the pole side
|
|
4398
|
+
// (behind the flag's tip) is its attachment.
|
|
4399
|
+
const attached = (w: { x1: number; y1: number; x2: number; y2: number }): boolean => {
|
|
4400
|
+
if (!segContains(w, x, y)) return false;
|
|
4401
|
+
if (lb.kind !== 'global') return true;
|
|
4402
|
+
const r = ((rot % 360) + 360) % 360;
|
|
4403
|
+
if (r === 0) return Math.max(w.x1, w.x2) <= x + 0.01;
|
|
4404
|
+
if (r === 180) return Math.min(w.x1, w.x2) >= x - 0.01;
|
|
4405
|
+
if (r === 90) return Math.min(w.y1, w.y2) >= y - 0.01;
|
|
4406
|
+
return Math.max(w.y1, w.y2) <= y + 0.01;
|
|
4407
|
+
};
|
|
4408
|
+
const hitBody = [...placed.entries()].find(([, pl]) => boundsOverlap(box, pl.body));
|
|
4409
|
+
if (hitBody) return blocked(`the body of ${hitBody[0]} (${JSON.stringify(hitBody[1].body)})`);
|
|
4410
|
+
if (textObstacles.some((b) => boundsOverlap(padBox(box), b))) return blocked('field text');
|
|
4411
|
+
if (wires.some((w) => !attached(w) && segHitsBox(w, box))) return blocked('a wire');
|
|
2103
4412
|
return !labels.some(
|
|
2104
|
-
(o, i) => i !== rec.label && o.name !== lb.name && boundsOverlap(box, labelTextBox(o.name, o.x, o.y, o.rot)),
|
|
4413
|
+
(o, i) => i !== rec.label && o.name !== lb.name && boundsOverlap(padBox(box), labelTextBox(o.name, o.x, o.y, o.rot, o.kind)),
|
|
2105
4414
|
);
|
|
2106
4415
|
};
|
|
2107
|
-
if (clearWired(lb.x, lb.y)) continue;
|
|
2108
|
-
|
|
4416
|
+
if (clearWired(lb.x, lb.y, lb.rot)) continue;
|
|
4417
|
+
trace(`label ${lb.name}: (${lb.x}, ${lb.y}, ${lb.rot}) not clear, searching`);
|
|
4418
|
+
// A flag may turn as well as move — its candidates each carry a rotation
|
|
4419
|
+
// — and prefers a trunk top; a local name stays horizontal and walks the
|
|
4420
|
+
// run's points in anchor-preference order.
|
|
4421
|
+
if (lb.kind === 'global') {
|
|
4422
|
+
// a run's name reads along the row (the drafting standard keeps text
|
|
4423
|
+
// horizontal); a flag that could only stand up leaves the net to
|
|
4424
|
+
// local labels below
|
|
4425
|
+
const alt = wiredFlagCandidates(rec.segs)
|
|
4426
|
+
.filter((c) => c.rot === 0 || c.rot === 180)
|
|
4427
|
+
.find((c) => clearWired(c.x, c.y, c.rot));
|
|
4428
|
+
if (alt) {
|
|
4429
|
+
lb.x = alt.x;
|
|
4430
|
+
lb.y = alt.y;
|
|
4431
|
+
lb.rot = alt.rot;
|
|
4432
|
+
continue;
|
|
4433
|
+
}
|
|
4434
|
+
// A flag that fits nowhere on its run (a part on a pin row of a
|
|
4435
|
+
// 2.54 mm-pitch IC has no free end and no room for a flag standing up
|
|
4436
|
+
// between the rows) leaves the net named by plain labels instead:
|
|
4437
|
+
// every flag of this net becomes a local label, which KiCad connects
|
|
4438
|
+
// across the sheet just the same, and the run's name goes back to
|
|
4439
|
+
// standing on its wire.
|
|
4440
|
+
for (const o of labels) {
|
|
4441
|
+
if (o.name !== lb.name) continue;
|
|
4442
|
+
o.kind = 'local';
|
|
4443
|
+
delete o.shape;
|
|
4444
|
+
if (o.rot === 90 || o.rot === 270) o.rot = 0; // plain text reads along the row
|
|
4445
|
+
}
|
|
4446
|
+
lb.rot = 0;
|
|
4447
|
+
trace(`label ${lb.name}: no flag position clears; the net keeps local labels`);
|
|
4448
|
+
}
|
|
4449
|
+
const alt = rec.pts.find((p) => clearWired(p.x, p.y, 0));
|
|
2109
4450
|
if (alt) {
|
|
4451
|
+
trace(`label ${lb.name} moved along its run (${lb.x}, ${lb.y}) -> (${alt.x}, ${alt.y})`);
|
|
2110
4452
|
lb.x = alt.x;
|
|
2111
4453
|
lb.y = alt.y;
|
|
2112
4454
|
continue;
|
|
2113
4455
|
}
|
|
4456
|
+
// reading leftward from a point on the run is the same text on the same
|
|
4457
|
+
// wire; a run whose right-hand end is a part's body (a resistor on the
|
|
4458
|
+
// row) often clears only that way
|
|
4459
|
+
const altLeft = [...rec.pts, ...interiorGridPoints(rec.segs, U)].find((p) => clearWired(p.x, p.y, 180));
|
|
4460
|
+
if (altLeft) {
|
|
4461
|
+
trace(`label ${lb.name} reads leftward from (${altLeft.x}, ${altLeft.y})`);
|
|
4462
|
+
lb.x = altLeft.x;
|
|
4463
|
+
lb.y = altLeft.y;
|
|
4464
|
+
lb.rot = 180;
|
|
4465
|
+
continue;
|
|
4466
|
+
}
|
|
2114
4467
|
// No segment endpoint clears, but the label may sit at ANY point of its
|
|
2115
4468
|
// own net's wires: walk the interior grid points of each segment too
|
|
2116
4469
|
// (#220 phase 2). Endpoints stay the first choice so a run that used to
|
|
@@ -2123,20 +4476,39 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2123
4476
|
// one the IR gave it. Three corpus nets shipped exactly that way —
|
|
2124
4477
|
// Net-(F201-Pad1), Net-(U8-BIN) and Net-(U8-RIN), each anchored on no
|
|
2125
4478
|
// wire of its own run.
|
|
2126
|
-
const inner = interiorGridPoints(rec.segs, U).find((p) => clearWired(p.x, p.y));
|
|
4479
|
+
const inner = interiorGridPoints(rec.segs, U).find((p) => clearWired(p.x, p.y, 0));
|
|
2127
4480
|
if (inner) {
|
|
4481
|
+
trace(`label ${lb.name} moved to an interior point (${inner.x}, ${inner.y})`);
|
|
2128
4482
|
lb.x = inner.x;
|
|
2129
4483
|
lb.y = inner.y;
|
|
4484
|
+
continue;
|
|
2130
4485
|
}
|
|
4486
|
+
// Last resort before leaving the name on a body: the full pad cannot be
|
|
4487
|
+
// had on a 2.54 mm pin row under a flag (a flag's half height plus a
|
|
4488
|
+
// standing label's height already fill the pitch), so accept a quarter
|
|
4489
|
+
// pad at the first point that clears everything else.
|
|
4490
|
+
// (A flag on the row above already reaches a full height below its line,
|
|
4491
|
+
// so against it even a quarter pad is too much; touching boxes are not a
|
|
4492
|
+
// collision to the checker, and are the last rung.)
|
|
4493
|
+
const tightCandidates = [...rec.pts.map((p) => ({ ...p, rot: 0 })), ...interiorGridPoints(rec.segs, U).map((p) => ({ ...p, rot: 0 })), ...rec.pts.map((p) => ({ ...p, rot: 180 }))];
|
|
4494
|
+
const tight = tightCandidates.find((c) => clearWired(c.x, c.y, c.rot, TEXT_PAD / 4)) ?? tightCandidates.find((c) => clearWired(c.x, c.y, c.rot, 0));
|
|
4495
|
+
if (tight) {
|
|
4496
|
+
trace(`label ${lb.name} placed at (${tight.x}, ${tight.y}, ${tight.rot}) with a reduced pad`);
|
|
4497
|
+
lb.x = tight.x;
|
|
4498
|
+
lb.y = tight.y;
|
|
4499
|
+
lb.rot = tight.rot;
|
|
4500
|
+
continue;
|
|
4501
|
+
}
|
|
4502
|
+
trace(`label ${lb.name}: no clear point on its run; left at (${lb.x}, ${lb.y})`);
|
|
2131
4503
|
}
|
|
2132
4504
|
|
|
2133
4505
|
for (const rec of stubbedLabels) {
|
|
2134
4506
|
const lb = labels[rec.label]!;
|
|
2135
4507
|
const stub = wires[rec.wire]!;
|
|
2136
4508
|
const clearAt = (x: number, y: number, rot: number = lb.rot): boolean => {
|
|
2137
|
-
const box = labelTextBox(lb.name, x, y, rot);
|
|
4509
|
+
const box = labelTextBox(lb.name, x, y, rot, lb.kind);
|
|
2138
4510
|
if (bodies.some((b) => boundsOverlap(box, b))) return false;
|
|
2139
|
-
if (textObstacles.some((b) => boundsOverlap(box, b))) return false;
|
|
4511
|
+
if (textObstacles.some((b) => boundsOverlap(padBox(box), b))) return false;
|
|
2140
4512
|
if (wires.some((w, i) => i !== rec.wire && segHitsBox(w, box))) return false;
|
|
2141
4513
|
// Foreign labels are part of what a label must clear, not just bodies and
|
|
2142
4514
|
// wires. Without this the pass declares a point clear that another net's
|
|
@@ -2146,12 +4518,10 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2146
4518
|
// coordinates. Read live from `labels`, so already-nudged neighbours are
|
|
2147
4519
|
// seen at their final positions and immovable wired-net labels (which
|
|
2148
4520
|
// carry no stub to ride) are seen at all.
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
boundsOverlap(box, labelTextBox(o.name, o.x, o.y, o.rot)),
|
|
2154
|
-
);
|
|
4521
|
+
// A label of the SAME net is still text: two BUCK_EN labels one over
|
|
4522
|
+
// the other read as one smudge and the checker flags them (a hung
|
|
4523
|
+
// part's stub label rode down onto the IC pin's).
|
|
4524
|
+
return !labels.some((o, i) => i !== rec.label && boundsOverlap(padBox(box), labelTextBox(o.name, o.x, o.y, o.rot, o.kind)));
|
|
2155
4525
|
};
|
|
2156
4526
|
/**
|
|
2157
4527
|
* Does another net's label sit on exactly this point? That is the fatal
|
|
@@ -2177,11 +4547,23 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2177
4547
|
predictStubs: false,
|
|
2178
4548
|
});
|
|
2179
4549
|
const rideTo = (x: number, y: number): void => {
|
|
4550
|
+
if (!sameCoord(x, lb.x) || !sameCoord(y, lb.y)) trace(`label ${lb.name}: rides its stub from (${lb.x}, ${lb.y}) to (${x}, ${y})`);
|
|
2180
4551
|
stub.x2 = x;
|
|
2181
4552
|
stub.y2 = y;
|
|
2182
4553
|
lb.x = x;
|
|
2183
4554
|
lb.y = y;
|
|
2184
4555
|
};
|
|
4556
|
+
// A flag on a vertical stub reads along the row when nothing stands
|
|
4557
|
+
// there (the drafting standard keeps text horizontal; the checker names
|
|
4558
|
+
// every rotated label a horizontal one would have fitted).
|
|
4559
|
+
if (lb.kind === 'global' && rec.o.dy !== 0 && (lb.rot === 90 || lb.rot === 270)) {
|
|
4560
|
+
const flat = [0, 180].find((r) => clearAt(lb.x, lb.y, r) && !mergesAt(lb.x, lb.y));
|
|
4561
|
+
if (flat !== undefined) {
|
|
4562
|
+
lb.rot = flat;
|
|
4563
|
+
continue;
|
|
4564
|
+
}
|
|
4565
|
+
trace(`flag ${lb.name} at (${lb.x}, ${lb.y}) stays vertical: no horizontal draw clears`);
|
|
4566
|
+
}
|
|
2185
4567
|
if (clearAt(lb.x, lb.y)) continue;
|
|
2186
4568
|
/** Candidate points along the stub, nearest first. */
|
|
2187
4569
|
const candidates: { x: number; y: number }[] = [];
|
|
@@ -2220,6 +4602,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2220
4602
|
(sameCoord(c.x, lb.x) && sameCoord(c.y, lb.y) ? true : wireClearAt(c.x, c.y)),
|
|
2221
4603
|
);
|
|
2222
4604
|
if (flip) {
|
|
4605
|
+
trace(`label ${lb.name}: turns to rotation ${flipRot} to clear`);
|
|
2223
4606
|
lb.rot = flipRot;
|
|
2224
4607
|
rideTo(flip.x, flip.y);
|
|
2225
4608
|
continue;
|
|
@@ -2251,7 +4634,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2251
4634
|
// measure as clean. Nothing clear keeps the placed slot so the report stays
|
|
2252
4635
|
// honest, and a value that is already clean does not move at all.
|
|
2253
4636
|
{
|
|
2254
|
-
const labelBoxesFinal = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot));
|
|
4637
|
+
const labelBoxesFinal = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
|
|
2255
4638
|
const memberText = emitPairs.flatMap(({ sym: s, pl }) => [
|
|
2256
4639
|
centeredTextBox(displayRefOf(pl), s.refAt.x, s.refAt.y),
|
|
2257
4640
|
centeredTextBox(s.value, s.valueAt.x, s.valueAt.y),
|
|
@@ -2261,9 +4644,9 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2261
4644
|
const clearFor = (self: EmitSymbol, b: Bounds): boolean =>
|
|
2262
4645
|
!bodies.some((bd) => boundsOverlap(b, bd)) &&
|
|
2263
4646
|
!wires.some((w) => segHitsBoxEarly(w, b)) &&
|
|
2264
|
-
!labelBoxesFinal.some((lb) => boundsOverlap(lb, b)) &&
|
|
2265
|
-
!memberText.some((t) => boundsOverlap(t, b)) &&
|
|
2266
|
-
![...liveBoxes].some(([o, ob]) => o !== self && boundsOverlap(ob, b));
|
|
4647
|
+
!labelBoxesFinal.some((lb) => boundsOverlap(lb, padBox(b))) &&
|
|
4648
|
+
!memberText.some((t) => boundsOverlap(t, padBox(b))) &&
|
|
4649
|
+
![...liveBoxes].some(([o, ob]) => o !== self && boundsOverlap(ob, padBox(b)));
|
|
2267
4650
|
for (const s of valueEntries) {
|
|
2268
4651
|
if (clearFor(s, liveBoxes.get(s)!)) continue;
|
|
2269
4652
|
// outward = the side of the symbol the text was already offset to
|
|
@@ -2310,6 +4693,420 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2310
4693
|
}
|
|
2311
4694
|
|
|
2312
4695
|
// ---------- sheet: content-derived paper, balanced placement ----------
|
|
4696
|
+
// ---------- group boxes enclose their text ----------
|
|
4697
|
+
// what the wrap was fitted on, to measure how far the text grew each box
|
|
4698
|
+
const rectsBeforeText = groupRects.map((r) => ({ name: r.name, x1: r.x1, x2: r.x2, y1: r.y1, y2: r.y2 }));
|
|
4699
|
+
const reachOut = new Map<string, Reach>();
|
|
4700
|
+
// A box drawn from bodies plus a fixed margin cuts through the text its
|
|
4701
|
+
// parts carry: with the IC in the leftmost column, its left-facing pin
|
|
4702
|
+
// labels ran out through the box edge (EN, BTN_PLAY, SPK_L+ on esp32-amp),
|
|
4703
|
+
// and a rail name on a connector stub crossed the line on every reference
|
|
4704
|
+
// board. Grow each box to hold its members' stub labels, their reference
|
|
4705
|
+
// and value fields, and the power symbols on their pins, plus BOX_PAD; the
|
|
4706
|
+
// group gaps above were widened for exactly this text, so boxes stay clear
|
|
4707
|
+
// of one another.
|
|
4708
|
+
{
|
|
4709
|
+
const pinGroup = new Map<string, string>();
|
|
4710
|
+
for (const [key, pl] of placed) {
|
|
4711
|
+
const g = groupOf.get(key);
|
|
4712
|
+
if (!g) continue;
|
|
4713
|
+
for (const pin of pl.sym.pins) {
|
|
4714
|
+
const p = pinAt(pl, pin);
|
|
4715
|
+
pinGroup.set(pointKey(p.x, p.y), g);
|
|
4716
|
+
}
|
|
4717
|
+
}
|
|
4718
|
+
const boxesOf = new Map<string, Bounds[]>();
|
|
4719
|
+
const addBox = (g: string | undefined, b: Bounds): void => {
|
|
4720
|
+
if (!g) return;
|
|
4721
|
+
boxesOf.set(g, [...(boxesOf.get(g) ?? []), b]);
|
|
4722
|
+
};
|
|
4723
|
+
// a label at the end of a stub that starts on a member pin
|
|
4724
|
+
for (const lb of labels) {
|
|
4725
|
+
const stub = wires.find((w) => sameCoord(w.x2, lb.x) && sameCoord(w.y2, lb.y) && pinGroup.has(pointKey(w.x1, w.y1)));
|
|
4726
|
+
const g = stub ? pinGroup.get(pointKey(stub.x1, stub.y1)) : undefined;
|
|
4727
|
+
addBox(g, labelReserveBox(lb.name, lb.x, lb.y, lb.rot, lb.kind));
|
|
4728
|
+
}
|
|
4729
|
+
// reference and value fields of every member, at the reserve advance
|
|
4730
|
+
const fieldBox = (text: string, x: number, y: number): Bounds => {
|
|
4731
|
+
const w = Math.max(1, text.length) * TEXT_RESERVE * LABEL_HEIGHT;
|
|
4732
|
+
return { minX: x - w / 2, minY: y - LABEL_HEIGHT / 2, maxX: x + w / 2, maxY: y + LABEL_HEIGHT / 2 };
|
|
4733
|
+
};
|
|
4734
|
+
const keyOfPlaced = new Map<Placed, string>([...placed.entries()].map(([k, p]) => [p, k]));
|
|
4735
|
+
for (const { sym, pl } of emitPairs) {
|
|
4736
|
+
const g = groupOf.get(keyOfPlaced.get(pl) ?? '');
|
|
4737
|
+
addBox(g, fieldBox(displayRefOf(pl), sym.refAt.x, sym.refAt.y));
|
|
4738
|
+
addBox(g, fieldBox(sym.value, sym.valueAt.x, sym.valueAt.y));
|
|
4739
|
+
}
|
|
4740
|
+
// power symbols on member stubs, with their value text
|
|
4741
|
+
for (const ps of extraSymbols) {
|
|
4742
|
+
const stub = wires.find((w) => sameCoord(w.x2, ps.at.x) && sameCoord(w.y2, ps.at.y) && pinGroup.has(pointKey(w.x1, w.y1)));
|
|
4743
|
+
const g = stub ? pinGroup.get(pointKey(stub.x1, stub.y1)) : undefined;
|
|
4744
|
+
if (!g) continue;
|
|
4745
|
+
addBox(g, { minX: ps.at.x - 2 * U, minY: ps.at.y - 2 * U, maxX: ps.at.x + 2 * U, maxY: ps.at.y + 2 * U });
|
|
4746
|
+
if (!ps.hideValue) addBox(g, powerValueBox(ps.value, ps.valueAt.x, ps.valueAt.y));
|
|
4747
|
+
}
|
|
4748
|
+
// every member body too: a hung part on a shared lane or an infilled
|
|
4749
|
+
// cell may sit past the cells the rect was first drawn from
|
|
4750
|
+
for (const [key, pl] of placed) {
|
|
4751
|
+
const g = groupOf.get(key);
|
|
4752
|
+
if (g) addBox(g, pl.body);
|
|
4753
|
+
}
|
|
4754
|
+
for (const r of groupRects) {
|
|
4755
|
+
for (const b of boxesOf.get(r.name) ?? []) {
|
|
4756
|
+
r.x1 = Math.min(r.x1, b.minX - BOX_PAD * U);
|
|
4757
|
+
r.x2 = Math.max(r.x2, b.maxX + BOX_PAD * U);
|
|
4758
|
+
r.y1 = Math.min(r.y1, b.minY - BOX_PAD * U);
|
|
4759
|
+
r.y2 = Math.max(r.y2, b.maxY + BOX_PAD * U);
|
|
4760
|
+
}
|
|
4761
|
+
// The caption is a band across the top of the box, not a corner the
|
|
4762
|
+
// parts may rise into: nothing drawn starts above the caption's
|
|
4763
|
+
// bottom plus a unit of air, whatever its column (a rail name beside
|
|
4764
|
+
// the caption read as part of it).
|
|
4765
|
+
const contentTop = Math.min(...(boxesOf.get(r.name) ?? []).map((b) => b.minY));
|
|
4766
|
+
if (Number.isFinite(contentTop)) r.y1 = Math.min(r.y1, contentTop - (2 + CAPTION_SIZE + U));
|
|
4767
|
+
r.y1 = grid(Math.floor(r.y1 / U));
|
|
4768
|
+
}
|
|
4769
|
+
// measured before the boxes are aligned: alignment is a choice, not text
|
|
4770
|
+
for (const [i, r] of groupRects.entries()) {
|
|
4771
|
+
const before = rectsBeforeText[i]!;
|
|
4772
|
+
const applied = reachMeasured?.get(r.name);
|
|
4773
|
+
reachOut.set(r.name, {
|
|
4774
|
+
left: Math.max(0, before.x1 - r.x1),
|
|
4775
|
+
right: Math.max(0, r.x2 - before.x2),
|
|
4776
|
+
top: (applied?.top ?? 0) + Math.max(0, before.y1 - r.y1),
|
|
4777
|
+
bottom: (applied?.bottom ?? 0) + Math.max(0, r.y2 - before.y2),
|
|
4778
|
+
});
|
|
4779
|
+
}
|
|
4780
|
+
// Boxes in one row of the wrap share a bottom edge, boxes in one column
|
|
4781
|
+
// a right edge, when the neighbour's space is free anyway: a row of
|
|
4782
|
+
// boxes ending at three different heights reads as three afterthoughts.
|
|
4783
|
+
// A line of the wrap (a row, or a column) is the set of boxes whose
|
|
4784
|
+
// fitted rects landed at one top (one left): the shifts that took them
|
|
4785
|
+
// there differ box by box, since the single-row pass gave them
|
|
4786
|
+
// different tops.
|
|
4787
|
+
const columnar = fit.wrap?.kind === 'columns' || fit.wrap?.kind === 'masonry';
|
|
4788
|
+
// Two boxes share a line when their fitted rects overlap across it (in
|
|
4789
|
+
// height for a row, in width for a column): a row's boxes may start at
|
|
4790
|
+
// different tops (the single-row pass gives a box room above its IC for
|
|
4791
|
+
// a pull-up) and two rows never overlap, the wrap having left a gap.
|
|
4792
|
+
const lineIndex: number[] = new Array(groupRects.length).fill(-1);
|
|
4793
|
+
{
|
|
4794
|
+
const lo = (i: number): number => (columnar ? rectsBeforeText[i]!.x1 : rectsBeforeText[i]!.y1);
|
|
4795
|
+
const hi = (i: number): number => (columnar ? rectsBeforeText[i]!.x2 : rectsBeforeText[i]!.y2);
|
|
4796
|
+
const order = groupRects.map((_, i) => i).sort((a, b) => lo(a) - lo(b));
|
|
4797
|
+
let line = -1;
|
|
4798
|
+
let reach = -Infinity;
|
|
4799
|
+
for (const i of order) {
|
|
4800
|
+
if (lo(i) >= reach - 1e-6) line++;
|
|
4801
|
+
lineIndex[i] = line;
|
|
4802
|
+
reach = Math.max(reach, hi(i));
|
|
4803
|
+
}
|
|
4804
|
+
}
|
|
4805
|
+
const lineOf = (i: number): number => lineIndex[i]!;
|
|
4806
|
+
if (fit.wrap) {
|
|
4807
|
+
const byLine = new Map<number, typeof groupRects>();
|
|
4808
|
+
for (const [i, r] of groupRects.entries()) {
|
|
4809
|
+
byLine.set(lineOf(i), [...(byLine.get(lineOf(i)) ?? []), r]);
|
|
4810
|
+
}
|
|
4811
|
+
for (const line of byLine.values()) {
|
|
4812
|
+
if (line.length < 2) continue;
|
|
4813
|
+
if (fit.wrap.kind === 'columns' || fit.wrap.kind === 'masonry') {
|
|
4814
|
+
const right = Math.max(...line.map((r) => r.x2));
|
|
4815
|
+
for (const r of line) r.x2 = right;
|
|
4816
|
+
} else {
|
|
4817
|
+
const bottom = Math.max(...line.map((r) => r.y2));
|
|
4818
|
+
for (const r of line) r.y2 = bottom;
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4821
|
+
}
|
|
4822
|
+
// The boxes grew into the gaps the wrap left between cells, each by its
|
|
4823
|
+
// own text, so two boxes might nearly touch where two others stood 10 mm
|
|
4824
|
+
// apart, and a row's boxes started at three different heights. Every box
|
|
4825
|
+
// now moves, with everything drawn in it, so that BOX_LINE_GAP separates
|
|
4826
|
+
// neighbours along and across the lines of the wrap, and a row shares
|
|
4827
|
+
// its top (a column its left). Content moves by whole units to keep the
|
|
4828
|
+
// grid; the box takes the exact position, so its padding varies by less
|
|
4829
|
+
// than a unit while the gaps do not.
|
|
4830
|
+
{
|
|
4831
|
+
const lineGap = BOX_LINE_GAP * U;
|
|
4832
|
+
const byLine = new Map<number, { r: (typeof groupRects)[number]; i: number }[]>();
|
|
4833
|
+
for (const [i, r] of groupRects.entries()) {
|
|
4834
|
+
byLine.set(lineOf(i), [...(byLine.get(lineOf(i)) ?? []), { r, i }]);
|
|
4835
|
+
}
|
|
4836
|
+
// Every drawn item belongs to one group, decided once before anything
|
|
4837
|
+
// moves: a wire, label, junction, no-connect or power symbol to the
|
|
4838
|
+
// group of the pins on its run (wires joined end to end form the run),
|
|
4839
|
+
// anything unwired to the box it lies in. Deciding by box while boxes
|
|
4840
|
+
// move would hand a moved group's items to the next box they land in.
|
|
4841
|
+
const parent = new Map<string, string>();
|
|
4842
|
+
const find = (k: string): string => {
|
|
4843
|
+
let x = k;
|
|
4844
|
+
while (parent.has(x) && parent.get(x) !== x) x = parent.get(x)!;
|
|
4845
|
+
return x;
|
|
4846
|
+
};
|
|
4847
|
+
const union = (a: string, b: string): void => {
|
|
4848
|
+
if (!parent.has(a)) parent.set(a, a);
|
|
4849
|
+
if (!parent.has(b)) parent.set(b, b);
|
|
4850
|
+
const ra = find(a);
|
|
4851
|
+
const rb = find(b);
|
|
4852
|
+
if (ra !== rb) parent.set(ra, rb);
|
|
4853
|
+
};
|
|
4854
|
+
for (const w of wires) union(pointKey(w.x1, w.y1), pointKey(w.x2, w.y2));
|
|
4855
|
+
const runGroup = new Map<string, string>();
|
|
4856
|
+
for (const [pt, g] of pinGroup) {
|
|
4857
|
+
const root = find(pt);
|
|
4858
|
+
if (!runGroup.has(root)) runGroup.set(root, g);
|
|
4859
|
+
}
|
|
4860
|
+
const inside = (x: number, y: number, r: { x1: number; y1: number; x2: number; y2: number }): boolean => x >= r.x1 - 1e-6 && x <= r.x2 + 1e-6 && y >= r.y1 - 1e-6 && y <= r.y2 + 1e-6;
|
|
4861
|
+
const groupAt = (x: number, y: number): string | undefined => runGroup.get(find(pointKey(x, y))) ?? groupRects.find((r) => inside(x, y, r))?.name;
|
|
4862
|
+
const wireGroup = new Map<(typeof wires)[number], string | undefined>(wires.map((w) => [w, groupAt(w.x1, w.y1) ?? groupAt(w.x2, w.y2)]));
|
|
4863
|
+
const pointGroup = new Map<{ x: number; y: number }, string | undefined>();
|
|
4864
|
+
for (const it of [...labels, ...uniqJunctions, ...noConnects]) pointGroup.set(it, groupAt(it.x, it.y));
|
|
4865
|
+
const powerGroup = new Map<(typeof extraSymbols)[number], string | undefined>(extraSymbols.map((ps) => [ps, groupAt(ps.at.x, ps.at.y)]));
|
|
4866
|
+
const moveGroup = (r: (typeof groupRects)[number], dx: number, dy: number): void => {
|
|
4867
|
+
if (!dx && !dy) return;
|
|
4868
|
+
for (const [key, pl] of placed) {
|
|
4869
|
+
if (groupOf.get(key) !== r.name) continue;
|
|
4870
|
+
pl.x += dx;
|
|
4871
|
+
pl.y += dy;
|
|
4872
|
+
pl.body.minX += dx;
|
|
4873
|
+
pl.body.maxX += dx;
|
|
4874
|
+
pl.body.minY += dy;
|
|
4875
|
+
pl.body.maxY += dy;
|
|
4876
|
+
}
|
|
4877
|
+
for (const { sym, pl } of emitPairs) {
|
|
4878
|
+
if (groupOf.get(keyOfPlaced.get(pl) ?? '') !== r.name) continue;
|
|
4879
|
+
sym.at.x += dx;
|
|
4880
|
+
sym.at.y += dy;
|
|
4881
|
+
sym.refAt.x += dx;
|
|
4882
|
+
sym.refAt.y += dy;
|
|
4883
|
+
sym.valueAt.x += dx;
|
|
4884
|
+
sym.valueAt.y += dy;
|
|
4885
|
+
}
|
|
4886
|
+
for (const ps of extraSymbols) {
|
|
4887
|
+
if (powerGroup.get(ps) !== r.name) continue;
|
|
4888
|
+
ps.at.x += dx;
|
|
4889
|
+
ps.at.y += dy;
|
|
4890
|
+
ps.valueAt.x += dx;
|
|
4891
|
+
ps.valueAt.y += dy;
|
|
4892
|
+
}
|
|
4893
|
+
for (const w of wires) {
|
|
4894
|
+
if (wireGroup.get(w) !== r.name) continue;
|
|
4895
|
+
w.x1 += dx;
|
|
4896
|
+
w.x2 += dx;
|
|
4897
|
+
w.y1 += dy;
|
|
4898
|
+
w.y2 += dy;
|
|
4899
|
+
}
|
|
4900
|
+
for (const it of [...labels, ...uniqJunctions, ...noConnects]) {
|
|
4901
|
+
if (pointGroup.get(it) !== r.name) continue;
|
|
4902
|
+
it.x += dx;
|
|
4903
|
+
it.y += dy;
|
|
4904
|
+
}
|
|
4905
|
+
r.x1 += dx;
|
|
4906
|
+
r.x2 += dx;
|
|
4907
|
+
r.y1 += dy;
|
|
4908
|
+
r.y2 += dy;
|
|
4909
|
+
};
|
|
4910
|
+
const snap = (d: number): number => grid(Math.round(d / U));
|
|
4911
|
+
const lines = [...byLine.entries()].sort((a, b) => a[0] - b[0]).map(([, members]) => members);
|
|
4912
|
+
const left0 = Math.min(...groupRects.map((r) => r.x1));
|
|
4913
|
+
const top0 = Math.min(...groupRects.map((r) => r.y1));
|
|
4914
|
+
// what each box was before the alignment above shared its edges
|
|
4915
|
+
const ownSize = new Map(groupRects.map((r, i) => [r.name, { w: Math.max(...(boxesOf.get(r.name) ?? []).map((b) => b.maxX + BOX_PAD * U), rectsBeforeText[i]!.x2) - r.x1, h: Math.max(...(boxesOf.get(r.name) ?? []).map((b) => b.maxY + BOX_PAD * U), rectsBeforeText[i]!.y2) - r.y1 }]));
|
|
4916
|
+
if (columnar) {
|
|
4917
|
+
let colLeft = left0;
|
|
4918
|
+
for (const members of lines) {
|
|
4919
|
+
members.sort((a, b) => a.r.y1 - b.r.y1);
|
|
4920
|
+
const colW = Math.max(...members.map((m) => m.r.x2 - m.r.x1));
|
|
4921
|
+
let y = top0;
|
|
4922
|
+
for (const { r } of members) {
|
|
4923
|
+
const h = r.y2 - r.y1;
|
|
4924
|
+
moveGroup(r, snap(colLeft - r.x1), snap(y - r.y1));
|
|
4925
|
+
r.x1 = colLeft;
|
|
4926
|
+
r.x2 = colLeft + colW;
|
|
4927
|
+
r.y1 = y;
|
|
4928
|
+
r.y2 = y + h;
|
|
4929
|
+
y += h + lineGap;
|
|
4930
|
+
}
|
|
4931
|
+
colLeft += colW + lineGap;
|
|
4932
|
+
}
|
|
4933
|
+
} else {
|
|
4934
|
+
let rowTop = top0;
|
|
4935
|
+
for (const members of lines) {
|
|
4936
|
+
members.sort((a, b) => a.r.x1 - b.r.x1);
|
|
4937
|
+
const rowH = Math.max(...members.map((m) => m.r.y2 - m.r.y1));
|
|
4938
|
+
let x = left0;
|
|
4939
|
+
for (const { r } of members) {
|
|
4940
|
+
const w = r.x2 - r.x1;
|
|
4941
|
+
moveGroup(r, snap(x - r.x1), snap(rowTop - r.y1));
|
|
4942
|
+
r.x1 = x;
|
|
4943
|
+
r.x2 = x + w;
|
|
4944
|
+
r.y1 = rowTop;
|
|
4945
|
+
r.y2 = rowTop + rowH;
|
|
4946
|
+
x += w + lineGap;
|
|
4947
|
+
}
|
|
4948
|
+
rowTop += rowH + lineGap;
|
|
4949
|
+
}
|
|
4950
|
+
}
|
|
4951
|
+
// A shared edge may reach where the box's own text did not: the
|
|
4952
|
+
// title-block corner. The sheet pass below centres the content in the
|
|
4953
|
+
// frame; predicted here the same way, any box whose aligned edge would
|
|
4954
|
+
// enter the corner falls back to its own width or height there.
|
|
4955
|
+
{
|
|
4956
|
+
const paper = fit.paper;
|
|
4957
|
+
const xs = groupRects.flatMap((r) => [r.x1, r.x2]);
|
|
4958
|
+
const ys = groupRects.flatMap((r) => [r.y1, r.y2]);
|
|
4959
|
+
const contentW = Math.max(...xs) - Math.min(...xs);
|
|
4960
|
+
const contentH = Math.max(...ys) - Math.min(...ys);
|
|
4961
|
+
const dx = grid(Math.round((FRAME + Math.max(0, (paper.w - 2 * FRAME - contentW) / 2) - Math.min(...xs)) / U));
|
|
4962
|
+
const dy = grid(Math.round((FRAME + 4 * U + Math.max(0, (paper.h - 2 * FRAME - TITLE_STRIP - contentH) / 2) - Math.min(...ys)) / U));
|
|
4963
|
+
const cornerX = paper.w - FRAME - TITLE_BLOCK_W - dx;
|
|
4964
|
+
const cornerY = paper.h - FRAME - TITLE_STRIP - dy;
|
|
4965
|
+
for (const r of groupRects) {
|
|
4966
|
+
if (r.x2 <= cornerX || r.y2 <= cornerY) continue;
|
|
4967
|
+
const o = ownSize.get(r.name)!;
|
|
4968
|
+
const ownX2 = r.x1 + o.w;
|
|
4969
|
+
const ownY2 = r.y1 + o.h;
|
|
4970
|
+
// give back the aligned width first (a wide short row), then the height
|
|
4971
|
+
if (ownX2 <= cornerX && r.x2 > ownX2) r.x2 = ownX2;
|
|
4972
|
+
if (r.x2 > cornerX && ownY2 <= cornerY && r.y2 > ownY2) r.y2 = ownY2;
|
|
4973
|
+
}
|
|
4974
|
+
}
|
|
4975
|
+
}
|
|
4976
|
+
}
|
|
4977
|
+
|
|
4978
|
+
// ---------- measure what every cell drew (for the squeeze), before the sheet shift ----------
|
|
4979
|
+
// Every drawn item is attributed to the cells it belongs to: a body and its
|
|
4980
|
+
// fields to their instance; a wire, with the labels and power symbols on
|
|
4981
|
+
// it, to every instance with a pin on its run (a comb belongs to the IC and
|
|
4982
|
+
// the parts hung on it, which roll up to the IC below). A cell's overhang is
|
|
4983
|
+
// then how far that union reaches past its body on each side.
|
|
4984
|
+
const measuredOut = new Map<string, Overhang>();
|
|
4985
|
+
{
|
|
4986
|
+
const parent = new Map<string, string>();
|
|
4987
|
+
const find = (k: string): string => {
|
|
4988
|
+
let r = k;
|
|
4989
|
+
while (parent.has(r) && parent.get(r) !== r) r = parent.get(r)!;
|
|
4990
|
+
return r;
|
|
4991
|
+
};
|
|
4992
|
+
const union = (a: string, b: string): void => {
|
|
4993
|
+
if (!parent.has(a)) parent.set(a, a);
|
|
4994
|
+
if (!parent.has(b)) parent.set(b, b);
|
|
4995
|
+
const ra = find(a);
|
|
4996
|
+
const rb = find(b);
|
|
4997
|
+
if (ra !== rb) parent.set(ra, rb);
|
|
4998
|
+
};
|
|
4999
|
+
for (const w of wires) union(pointKey(w.x1, w.y1), pointKey(w.x2, w.y2));
|
|
5000
|
+
const boxes = new Map<string, Bounds>();
|
|
5001
|
+
const dbgSrc = new Map<string, string[]>();
|
|
5002
|
+
const extend = (key: string, b: Bounds, why = 'body'): void => {
|
|
5003
|
+
if (process.env['COPPERHEAD_DRAFT_TRACE'] === '1') dbgSrc.set(key, [...(dbgSrc.get(key) ?? []), `${why}[${b.minX.toFixed(0)},${b.minY.toFixed(0)}..${b.maxX.toFixed(0)},${b.maxY.toFixed(0)}]`]);
|
|
5004
|
+
const cur = boxes.get(key);
|
|
5005
|
+
boxes.set(key, cur ? { minX: Math.min(cur.minX, b.minX), minY: Math.min(cur.minY, b.minY), maxX: Math.max(cur.maxX, b.maxX), maxY: Math.max(cur.maxY, b.maxY) } : { ...b });
|
|
5006
|
+
};
|
|
5007
|
+
const compOwners = new Map<string, Set<string>>();
|
|
5008
|
+
for (const [key, pl] of placed) {
|
|
5009
|
+
extend(key, pl.body);
|
|
5010
|
+
for (const pin of pl.sym.pins) {
|
|
5011
|
+
const p = pinAt(pl, pin);
|
|
5012
|
+
const pk = pointKey(p.x, p.y);
|
|
5013
|
+
if (!parent.has(pk)) continue;
|
|
5014
|
+
const c = find(pk);
|
|
5015
|
+
compOwners.set(c, (compOwners.get(c) ?? new Set<string>()).add(key));
|
|
5016
|
+
}
|
|
5017
|
+
}
|
|
5018
|
+
const keyOf = new Map<Placed, string>([...placed.entries()].map(([k, p]) => [p, k]));
|
|
5019
|
+
for (const { sym, pl } of emitPairs) {
|
|
5020
|
+
const key = keyOf.get(pl);
|
|
5021
|
+
if (!key) continue;
|
|
5022
|
+
extend(key, centeredTextBox(displayRefOf(pl), sym.refAt.x, sym.refAt.y), 'ref');
|
|
5023
|
+
extend(key, centeredTextBox(sym.value, sym.valueAt.x, sym.valueAt.y), 'value');
|
|
5024
|
+
}
|
|
5025
|
+
const compBox = new Map<string, Bounds>();
|
|
5026
|
+
const extendComp = (c: string, b: Bounds): void => {
|
|
5027
|
+
const cur = compBox.get(c);
|
|
5028
|
+
compBox.set(c, cur ? { minX: Math.min(cur.minX, b.minX), minY: Math.min(cur.minY, b.minY), maxX: Math.max(cur.maxX, b.maxX), maxY: Math.max(cur.maxY, b.maxY) } : { ...b });
|
|
5029
|
+
};
|
|
5030
|
+
for (const w of wires) extendComp(find(pointKey(w.x1, w.y1)), { 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) });
|
|
5031
|
+
const compAt = (x: number, y: number): string | null => {
|
|
5032
|
+
const pk = pointKey(x, y);
|
|
5033
|
+
if (parent.has(pk)) return find(pk);
|
|
5034
|
+
const w = wires.find((s) => segContains(s, x, y));
|
|
5035
|
+
return w ? find(pointKey(w.x1, w.y1)) : null;
|
|
5036
|
+
};
|
|
5037
|
+
for (const l of labels) {
|
|
5038
|
+
const c = compAt(l.x, l.y);
|
|
5039
|
+
if (c) extendComp(c, labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
|
|
5040
|
+
}
|
|
5041
|
+
for (const ps of extraSymbols) {
|
|
5042
|
+
const c = compAt(ps.at.x, ps.at.y);
|
|
5043
|
+
if (!c) continue;
|
|
5044
|
+
extendComp(c, { minX: ps.at.x - 2 * U, minY: ps.at.y - 2 * U, maxX: ps.at.x + 2 * U, maxY: ps.at.y + 2 * U });
|
|
5045
|
+
if (!ps.hideValue) extendComp(c, powerValueBox(ps.value, ps.valueAt.x, ps.valueAt.y));
|
|
5046
|
+
}
|
|
5047
|
+
const rootOf = (k: string): string => {
|
|
5048
|
+
let r = k;
|
|
5049
|
+
for (let i = 0; i < 6 && boundTo.has(r); i++) r = boundTo.get(r)!;
|
|
5050
|
+
return r;
|
|
5051
|
+
};
|
|
5052
|
+
// A run with one owner cell (an IC and the parts hung on it) is that
|
|
5053
|
+
// cell's; a run shared by several independent cells (a bank's rail and
|
|
5054
|
+
// ground trunks, a cluster wire between two column parts) gives each
|
|
5055
|
+
// owner only the wires that touch its own pins, with the labels and
|
|
5056
|
+
// symbols at their far ends — attributed whole, a bank's trunk made every
|
|
5057
|
+
// capacitor's cell as wide as the bank.
|
|
5058
|
+
const pinComp = new Map<string, string>(); // pin point -> owner key
|
|
5059
|
+
for (const [key, pl] of placed) for (const pin of pl.sym.pins) {
|
|
5060
|
+
const p = pinAt(pl, pin);
|
|
5061
|
+
pinComp.set(pointKey(p.x, p.y), key);
|
|
5062
|
+
}
|
|
5063
|
+
const itemsAt = new Map<string, Bounds[]>(); // point -> boxes of labels/symbols anchored there
|
|
5064
|
+
const addItem = (x: number, y: number, b: Bounds): void => {
|
|
5065
|
+
itemsAt.set(pointKey(x, y), [...(itemsAt.get(pointKey(x, y)) ?? []), b]);
|
|
5066
|
+
};
|
|
5067
|
+
for (const l of labels) addItem(l.x, l.y, labelTextBox(l.name, l.x, l.y, l.rot, l.kind));
|
|
5068
|
+
for (const ps of extraSymbols) {
|
|
5069
|
+
addItem(ps.at.x, ps.at.y, { minX: ps.at.x - 2 * U, minY: ps.at.y - 2 * U, maxX: ps.at.x + 2 * U, maxY: ps.at.y + 2 * U });
|
|
5070
|
+
if (!ps.hideValue) addItem(ps.at.x, ps.at.y, powerValueBox(ps.value, ps.valueAt.x, ps.valueAt.y));
|
|
5071
|
+
}
|
|
5072
|
+
for (const [c, owners] of compOwners) {
|
|
5073
|
+
const roots = new Set([...owners].map(rootOf));
|
|
5074
|
+
const b = compBox.get(c);
|
|
5075
|
+
if (!b) continue;
|
|
5076
|
+
if (roots.size === 1) {
|
|
5077
|
+
extend([...roots][0]!, b, `run(${[...owners].join('+')})`);
|
|
5078
|
+
continue;
|
|
5079
|
+
}
|
|
5080
|
+
for (const w of wires) {
|
|
5081
|
+
if (find(pointKey(w.x1, w.y1)) !== c) continue;
|
|
5082
|
+
const ends = [pointKey(w.x1, w.y1), pointKey(w.x2, w.y2)];
|
|
5083
|
+
const touching = new Set(ends.map((e) => pinComp.get(e)).filter((k): k is string => k !== undefined).map(rootOf));
|
|
5084
|
+
for (const r of touching) {
|
|
5085
|
+
extend(r, { 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) }, `wire(${w.net})`);
|
|
5086
|
+
for (const e of ends) for (const ib of itemsAt.get(e) ?? []) extend(r, ib, `item@${e}`);
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
for (const [key, b] of [...boxes.entries()]) {
|
|
5091
|
+
const r = rootOf(key);
|
|
5092
|
+
if (r !== key) extend(r, b, `child ${key}`);
|
|
5093
|
+
}
|
|
5094
|
+
for (const [key, pl] of placed) {
|
|
5095
|
+
if (rootOf(key) !== key) continue;
|
|
5096
|
+
const b = boxes.get(key);
|
|
5097
|
+
if (!b) continue;
|
|
5098
|
+
if (process.env['COPPERHEAD_DRAFT_TRACE'] && (b.maxX - b.minX > 4 * (pl.body.maxX - pl.body.minX) + 30 || b.maxY - b.minY > 4 * (pl.body.maxY - pl.body.minY) + 30)) {
|
|
5099
|
+
trace(`measured ${key}: drawn extent ${(b.maxX - b.minX).toFixed(0)}x${(b.maxY - b.minY).toFixed(0)} mm around a ${(pl.body.maxX - pl.body.minX).toFixed(0)}x${(pl.body.maxY - pl.body.minY).toFixed(0)} body <- ${(dbgSrc.get(key) ?? []).slice(0, 12).join(' ')}`);
|
|
5100
|
+
}
|
|
5101
|
+
measuredOut.set(key, {
|
|
5102
|
+
left: Math.max(0, pl.body.minX - b.minX),
|
|
5103
|
+
right: Math.max(0, b.maxX - pl.body.maxX),
|
|
5104
|
+
top: Math.max(0, pl.body.minY - b.minY),
|
|
5105
|
+
bottom: Math.max(0, b.maxY - pl.body.maxY),
|
|
5106
|
+
});
|
|
5107
|
+
}
|
|
5108
|
+
}
|
|
5109
|
+
|
|
2313
5110
|
const allX = [...groupRects.map((r) => r.x1), ...groupRects.map((r) => r.x2)];
|
|
2314
5111
|
const allY = [...groupRects.map((r) => r.y1), ...groupRects.map((r) => r.y2)];
|
|
2315
5112
|
const contentW = allX.length ? Math.max(...allX) - Math.min(...allX) : 0;
|
|
@@ -2337,11 +5134,15 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2337
5134
|
// the frame, and an extent wider than the window keeps the centered offset
|
|
2338
5135
|
// (that overflow was already noted by the fit pass).
|
|
2339
5136
|
const textBoxes = [
|
|
2340
|
-
...labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot)),
|
|
5137
|
+
...labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot, l.kind)),
|
|
2341
5138
|
// power VALUE text is measured by the checker too, and the sweep above
|
|
2342
5139
|
// may have slid it past its group rect's margin (pic_programmer put a
|
|
2343
5140
|
// rail name 1 mm over the top edge of a compacted sheet)
|
|
2344
5141
|
...extraSymbols.filter((s) => !s.hideValue).map((s) => powerValueBox(s.value, s.valueAt.x, s.valueAt.y)),
|
|
5142
|
+
// group captions: left-top justified at CAPTION_SIZE from the box
|
|
5143
|
+
// corner, the way emit.ts writes them and the checker measures them; a
|
|
5144
|
+
// long caption on a narrow group can outrun the box's right edge
|
|
5145
|
+
...groupRects.map((r): Bounds => ({ minX: r.x1 + 2, minY: r.y1 + 2, maxX: r.x1 + 2 + Math.max(1, r.name.length) * LABEL_ADVANCE * CAPTION_SIZE, maxY: r.y1 + 2 + CAPTION_SIZE })),
|
|
2345
5146
|
];
|
|
2346
5147
|
const fullMinX = Math.min(minX, ...textBoxes.map((b) => b.minX));
|
|
2347
5148
|
const fullMaxX = Math.max(minX + contentW, ...textBoxes.map((b) => b.maxX));
|
|
@@ -2363,7 +5164,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2363
5164
|
// the bottom edge is the engine's own usable bottom, ABOVE the title strip:
|
|
2364
5165
|
// content that fills the sheet's height exactly would otherwise carry the
|
|
2365
5166
|
// centering pass's 4-unit downward offset into the reserved corner
|
|
2366
|
-
dy = clampShift(dy, fullMinY, fullMaxY, FRAME, paper.h - FRAME - TITLE_STRIP);
|
|
5167
|
+
dy = clampShift(dy, fullMinY, fullMaxY, FRAME, fit.intoStrip ? paper.h - FRAME : paper.h - FRAME - TITLE_STRIP);
|
|
2367
5168
|
const shift = <T extends { x?: number; y?: number; x1?: number; y1?: number; x2?: number; y2?: number }>(o: T): T => {
|
|
2368
5169
|
if (o.x !== undefined) o.x += dx;
|
|
2369
5170
|
if (o.y !== undefined) o.y += dy;
|
|
@@ -2439,6 +5240,7 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2439
5240
|
noConnects,
|
|
2440
5241
|
rectangles: groupRects.map((r) => ({ x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2, stroke: 'solid' as const, name: r.name })),
|
|
2441
5242
|
captions: groupRects.map((r) => ({ text: r.name, x: r.x1 + 2, y: r.y1 + 2, name: r.name })),
|
|
5243
|
+
netColors: netColorsOf(intent.nets, netClasses),
|
|
2442
5244
|
};
|
|
2443
5245
|
|
|
2444
5246
|
const report: SchematicDraftReport = {
|
|
@@ -2456,11 +5258,17 @@ export function draftSchematicPlacement(validated: ValidatedIntent, projectName:
|
|
|
2456
5258
|
labelCount: labels.length,
|
|
2457
5259
|
pwrFlags,
|
|
2458
5260
|
noConnects: noConnects.length,
|
|
5261
|
+
sheetFit: {
|
|
5262
|
+
paper: paper.name,
|
|
5263
|
+
inkUtilization: [...placed.values()].reduce((a, p) => a + p.cellW * p.cellH * U * U, 0) / (usableW(paper) * usableH(paper)),
|
|
5264
|
+
compaction,
|
|
5265
|
+
misses: [...budgetMisses],
|
|
5266
|
+
},
|
|
2459
5267
|
paper: paper.name,
|
|
2460
5268
|
notes,
|
|
2461
5269
|
mergedNets,
|
|
2462
5270
|
labelOverlaps,
|
|
2463
5271
|
labelOverlapBudgetExceeded,
|
|
2464
5272
|
};
|
|
2465
|
-
return { model, report };
|
|
5273
|
+
return { model, report, rects: groupRects, measured: measuredOut, reach: reachOut, wrapped: fit.wrap !== null };
|
|
2466
5274
|
}
|