copperhead 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +1 -1
- package/README.md +6 -6
- package/dist/agent/filetools.js +24 -1
- package/dist/agent/filetools.js.map +1 -1
- package/dist/agent/ledger.js +24 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +29 -58
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +4 -3
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/tool-protocol.js +21 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -1
- package/dist/agent/recovery.js +95 -1
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/tools.js +185 -1
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +2 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +75 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +33 -1
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +177 -25
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +50 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +24 -3
- package/dist/kicad/bootstrap.js.map +1 -1
- package/dist/kicad/dossier.js +207 -0
- package/dist/kicad/dossier.js.map +1 -0
- package/dist/kicad/draft/draft.js +132 -0
- package/dist/kicad/draft/draft.js.map +1 -0
- package/dist/kicad/draft/engine.js +2389 -0
- package/dist/kicad/draft/engine.js.map +1 -0
- package/dist/kicad/draft/ir.js +368 -0
- package/dist/kicad/draft/ir.js.map +1 -0
- package/dist/kicad/draft/symsource.js +490 -0
- package/dist/kicad/draft/symsource.js.map +1 -0
- package/dist/kicad/emit.js +181 -0
- package/dist/kicad/emit.js.map +1 -0
- package/dist/kicad/fab.js +13 -0
- package/dist/kicad/fab.js.map +1 -1
- package/dist/kicad/legibility.js +561 -0
- package/dist/kicad/legibility.js.map +1 -0
- package/dist/kicad/score.js +261 -0
- package/dist/kicad/score.js.map +1 -0
- package/dist/kicad/sexp.js +239 -6
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/kicad/symlib.js +346 -16
- package/dist/kicad/symlib.js.map +1 -1
- package/dist/memory/bom-table.js +75 -39
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/scaffold.js +6 -0
- package/dist/memory/scaffold.js.map +1 -1
- package/dist/util/redact.js +6 -0
- package/dist/util/redact.js.map +1 -1
- package/package.json +9 -7
- package/src/agent/filetools.ts +26 -1
- package/src/agent/ledger.ts +24 -0
- package/src/agent/loop.ts +28 -61
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +94 -1
- package/src/agent/tools.ts +189 -1
- package/src/agent/transcript.ts +6 -0
- package/src/cli.ts +71 -0
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +179 -20
- package/src/commands/doctor.ts +51 -3
- package/src/config.ts +24 -0
- package/src/kicad/bootstrap.ts +24 -3
- package/src/kicad/dossier.ts +217 -0
- package/src/kicad/draft/draft.ts +171 -0
- package/src/kicad/draft/engine.ts +2466 -0
- package/src/kicad/draft/ir.ts +416 -0
- package/src/kicad/draft/symsource.ts +535 -0
- package/src/kicad/emit.ts +236 -0
- package/src/kicad/fab.ts +15 -0
- package/src/kicad/legibility.ts +646 -0
- package/src/kicad/score.ts +323 -0
- package/src/kicad/sexp.ts +315 -6
- package/src/kicad/symlib.ts +364 -18
- package/src/memory/bom-table.ts +85 -38
- package/src/memory/scaffold.ts +6 -0
- package/src/util/redact.ts +6 -0
- package/dist/memory/synap.js +0 -152
- package/dist/memory/synap.js.map +0 -1
- package/src/memory/synap.ts +0 -217
|
@@ -0,0 +1,2389 @@
|
|
|
1
|
+
import { knum } from '../emit.js';
|
|
2
|
+
import { powerSymbolSource, pwrFlagSource } from './symsource.js';
|
|
3
|
+
/**
|
|
4
|
+
* The rule-based deterministic drafting engine (design D1/D2). All geometry is
|
|
5
|
+
* computed in integer multiples of the 1.27mm grid, so every pin lands on-grid
|
|
6
|
+
* by construction. No randomness, no clock, no environment-dependent ordering:
|
|
7
|
+
* identical IR yields an identical placement model on every machine.
|
|
8
|
+
*/
|
|
9
|
+
/** The grid. Every symbol origin and wire endpoint is an integer multiple. */
|
|
10
|
+
const U = 1.27;
|
|
11
|
+
/** Stub length from a pin to its label/power symbol, in grid units. */
|
|
12
|
+
const STUB = 2;
|
|
13
|
+
/** Cell margin around a symbol body (room for stubs, labels, text), in units. */
|
|
14
|
+
const MARGIN = 6;
|
|
15
|
+
/** Vertical gap between rows and horizontal channel between columns, units. */
|
|
16
|
+
const ROW_GAP = 4;
|
|
17
|
+
const CHANNEL = 8;
|
|
18
|
+
/** Gap between group boxes, units. */
|
|
19
|
+
const GROUP_GAP = 8;
|
|
20
|
+
/** Local nets up to this many endpoints may be wired (design D2). */
|
|
21
|
+
const MAX_WIRED_ENDPOINTS = 4;
|
|
22
|
+
/** Wire-span budget in mm beyond which a net becomes labels. */
|
|
23
|
+
const MAX_WIRE_SPAN = 50.8;
|
|
24
|
+
/** Label text metrics, matching the legibility checker's conservative box. */
|
|
25
|
+
const LABEL_HEIGHT = 1.27;
|
|
26
|
+
const LABEL_ADVANCE = 0.6;
|
|
27
|
+
/** How far a colliding label may ride its stub outward, in grid units.
|
|
28
|
+
* Deep enough to carry a bottom-pin label past the routing channel that runs
|
|
29
|
+
* under its connector (#220 phase 2); rungs stay ordered nearest-first, so a
|
|
30
|
+
* label that used to clear at rung n still clears at rung n. */
|
|
31
|
+
const MAX_LABEL_NUDGE = 8;
|
|
32
|
+
/**
|
|
33
|
+
* How far a power stub may be pulled in or pushed out to clear a foreign
|
|
34
|
+
* connection point, in grid units. Bounded so the symbol stays visibly attached
|
|
35
|
+
* to the pin it serves; past this the merged-net gate is the better answer.
|
|
36
|
+
*/
|
|
37
|
+
const MAX_POWER_STUB_SHIFT = 4;
|
|
38
|
+
/**
|
|
39
|
+
* Fraction of labels allowed to still overlap a foreign net's label text after
|
|
40
|
+
* the de-collision pass has done what it can.
|
|
41
|
+
*
|
|
42
|
+
* Overlapping text boxes and coincident label POINTS are different failures and
|
|
43
|
+
* are treated differently. A shared point merges two nets (`findMergedNets`) and
|
|
44
|
+
* is always refused — the drawn netlist would not be the IR's. Overlapping text
|
|
45
|
+
* is a legibility defect: the sheet is harder to read, the netlist is correct.
|
|
46
|
+
* Refusing a whole draft over the second kind is what turned a real placement
|
|
47
|
+
* bug into a pipeline that could not finish, so a small budget is tolerated,
|
|
48
|
+
* counted, and reported rather than gated.
|
|
49
|
+
*/
|
|
50
|
+
const LABEL_OVERLAP_BUDGET = 0.02;
|
|
51
|
+
const ceilU = (mm) => Math.ceil(mm / U - 1e-9);
|
|
52
|
+
const grid = (units) => Math.round(units) * U;
|
|
53
|
+
/**
|
|
54
|
+
* Two coordinates are the same POINT iff they emit identically: comparisons and
|
|
55
|
+
* map keys go through `knum`, the emitter's rounding, because KiCad's
|
|
56
|
+
* connectivity sees the rounded file, not the engine's float dust —
|
|
57
|
+
* 13.969999999999999 and 13.97 are one coordinate on the sheet. Raw float keys
|
|
58
|
+
* here would let two distinct nets whose labels differ by dust but emit to the
|
|
59
|
+
* same point evade the merged-net refusal.
|
|
60
|
+
*/
|
|
61
|
+
const sameCoord = (a, b) => knum(a) === knum(b);
|
|
62
|
+
const pointKey = (x, y) => `${knum(x)},${knum(y)}`;
|
|
63
|
+
/** Standard landscape sheets, smallest first, for content-derived paper. */
|
|
64
|
+
const PAPERS = [
|
|
65
|
+
{ name: 'A5', w: 210, h: 148 },
|
|
66
|
+
{ name: 'A4', w: 297, h: 210 },
|
|
67
|
+
{ name: 'A3', w: 420, h: 297 },
|
|
68
|
+
{ name: 'A2', w: 594, h: 420 },
|
|
69
|
+
{ name: 'A1', w: 841, h: 594 },
|
|
70
|
+
{ name: 'A0', w: 1189, h: 841 },
|
|
71
|
+
];
|
|
72
|
+
const FRAME = 10;
|
|
73
|
+
const TITLE_STRIP = 30;
|
|
74
|
+
/** Max pin-to-pin gap, grid units, for chaining a passive bank on one trunk
|
|
75
|
+
* (#233): wide enough for two-pin parts sitting in adjacent COLUMNS (cell
|
|
76
|
+
* width plus the channel, ~23 units), tight enough that a trunk never spans
|
|
77
|
+
* unrelated structure — and every join is still vetoed by the body-crossing
|
|
78
|
+
* and touches-foreign checks regardless of distance. */
|
|
79
|
+
const BANK_PITCH_MAX = 32;
|
|
80
|
+
/** Natural-fit utilization below which the paper pass tries smaller sheets
|
|
81
|
+
* with width and height budgets (#220 phase 4). Matches the legibility
|
|
82
|
+
* checker's low-utilization threshold: a sheet the checker would call mostly
|
|
83
|
+
* empty is a sheet worth compacting. */
|
|
84
|
+
const COMPACT_UTILIZATION = 0.5;
|
|
85
|
+
/**
|
|
86
|
+
* Points carrying labels for two or more distinct nets.
|
|
87
|
+
*
|
|
88
|
+
* Co-located labels are not a cosmetic overlap: KiCad resolves them to a single
|
|
89
|
+
* net and reports `Both A and B are attached to the same items; A will be used
|
|
90
|
+
* in the netlist` — as a *warning*. A live run drew ISET (charge-current
|
|
91
|
+
* program) and NTC (thermistor input) onto one node of a BQ24040 that way,
|
|
92
|
+
* which would have shipped a board whose charge current is not set by its
|
|
93
|
+
* programming resistor and whose temperature cutoff does not work.
|
|
94
|
+
*
|
|
95
|
+
* The engine computes every coordinate, so this is the engine's to catch, and
|
|
96
|
+
* it is strictly worse than the failures already gated: an unreadable sheet
|
|
97
|
+
* stops the pipeline loudly, a merged net flows quietly into layout and
|
|
98
|
+
* fabrication outputs.
|
|
99
|
+
*/
|
|
100
|
+
export function findMergedNets(labels) {
|
|
101
|
+
const byPoint = new Map();
|
|
102
|
+
for (const l of labels) {
|
|
103
|
+
const key = pointKey(l.x, l.y);
|
|
104
|
+
const at = byPoint.get(key) ?? new Set();
|
|
105
|
+
at.add(l.name);
|
|
106
|
+
byPoint.set(key, at);
|
|
107
|
+
}
|
|
108
|
+
return [...byPoint.entries()]
|
|
109
|
+
.filter(([, nets]) => nets.size > 1)
|
|
110
|
+
.map(([key, nets]) => {
|
|
111
|
+
const [x, y] = key.split(',').map(Number);
|
|
112
|
+
return { x: x, y: y, nets: [...nets].sort() };
|
|
113
|
+
})
|
|
114
|
+
.sort((a, b) => a.nets[0].localeCompare(b.nets[0]));
|
|
115
|
+
}
|
|
116
|
+
/** True when (px,py) lies on the horizontal/vertical segment, endpoints
|
|
117
|
+
* included. The same dust tolerance as `sameCoord` for the fixed coordinate;
|
|
118
|
+
* the along-segment range check uses a plain epsilon. */
|
|
119
|
+
const SEG_EPS = 0.005;
|
|
120
|
+
const pointOnSeg = (px, py, s) => {
|
|
121
|
+
if (sameCoord(s.x1, s.x2)) {
|
|
122
|
+
return sameCoord(px, s.x1) && py >= Math.min(s.y1, s.y2) - SEG_EPS && py <= Math.max(s.y1, s.y2) + SEG_EPS;
|
|
123
|
+
}
|
|
124
|
+
if (sameCoord(s.y1, s.y2)) {
|
|
125
|
+
return sameCoord(py, s.y1) && px >= Math.min(s.x1, s.x2) - SEG_EPS && px <= Math.max(s.x1, s.x2) + SEG_EPS;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Cross-net wire contact is a merged net the co-located-label check cannot
|
|
131
|
+
* see: KiCad joins wires at coincident endpoints and at an endpoint on
|
|
132
|
+
* another wire's interior, whatever the labels say. The lemondrop run routed
|
|
133
|
+
* a local net's trunk down a column of neighbouring stub ends and shorted the
|
|
134
|
+
* crystal drive onto TOUCH_IRQ exactly this way (I22, #204) — ERC demoted it
|
|
135
|
+
* to a warning and it would have flowed into layout. The router now avoids
|
|
136
|
+
* foreign contact; this check gates whatever geometry any pass produces, so
|
|
137
|
+
* a merge can never again leave the engine silently. A label whose anchor
|
|
138
|
+
* sits on a foreign net's wire attaches to that wire in KiCad and is the
|
|
139
|
+
* same defect.
|
|
140
|
+
*/
|
|
141
|
+
export function findWireContactMerges(wires, labels) {
|
|
142
|
+
const out = new Map();
|
|
143
|
+
const add = (x, y, a, b) => {
|
|
144
|
+
if (a === b)
|
|
145
|
+
return;
|
|
146
|
+
const nets = [a, b].sort();
|
|
147
|
+
const key = `${nets[0]}/${nets[1]}@${pointKey(x, y)}`;
|
|
148
|
+
if (!out.has(key))
|
|
149
|
+
out.set(key, { x, y, nets });
|
|
150
|
+
};
|
|
151
|
+
for (let i = 0; i < wires.length; i++) {
|
|
152
|
+
for (let j = i + 1; j < wires.length; j++) {
|
|
153
|
+
const a = wires[i];
|
|
154
|
+
const b = wires[j];
|
|
155
|
+
if (a.net === b.net)
|
|
156
|
+
continue;
|
|
157
|
+
if (pointOnSeg(a.x1, a.y1, b))
|
|
158
|
+
add(a.x1, a.y1, a.net, b.net);
|
|
159
|
+
if (pointOnSeg(a.x2, a.y2, b))
|
|
160
|
+
add(a.x2, a.y2, a.net, b.net);
|
|
161
|
+
if (pointOnSeg(b.x1, b.y1, a))
|
|
162
|
+
add(b.x1, b.y1, a.net, b.net);
|
|
163
|
+
if (pointOnSeg(b.x2, b.y2, a))
|
|
164
|
+
add(b.x2, b.y2, a.net, b.net);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const l of labels) {
|
|
168
|
+
for (const w of wires) {
|
|
169
|
+
if (w.net === l.name)
|
|
170
|
+
continue;
|
|
171
|
+
if (pointOnSeg(l.x, l.y, w))
|
|
172
|
+
add(l.x, l.y, l.name, w.net);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return [...out.values()].sort((a, b) => a.nets[0].localeCompare(b.nets[0]) || a.x - b.x || a.y - b.y);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Every on-grid point strictly BETWEEN the ends of each segment.
|
|
179
|
+
*
|
|
180
|
+
* Takes segments, never a bag of points: a label anchored at a point that lies
|
|
181
|
+
* on no wire of its own net is attached to nothing in KiCad, and the net
|
|
182
|
+
* silently loses the name the IR gave it. Callers that keep their points in
|
|
183
|
+
* some other order (anchor preference, say) must pass the segments themselves
|
|
184
|
+
* so the walk cannot interpolate between two points that share no wire.
|
|
185
|
+
*/
|
|
186
|
+
export function interiorGridPoints(segs, step) {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const seg of segs) {
|
|
189
|
+
const steps = Math.round((Math.abs(seg.x2 - seg.x1) + Math.abs(seg.y2 - seg.y1)) / step);
|
|
190
|
+
const sx = Math.sign(seg.x2 - seg.x1);
|
|
191
|
+
const sy = Math.sign(seg.y2 - seg.y1);
|
|
192
|
+
for (let k = 1; k < steps; k++)
|
|
193
|
+
out.push({ x: seg.x1 + sx * k * step, y: seg.y1 + sy * k * step });
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Whether `seg` crosses the LINE a foreign pin's stub could grow along.
|
|
199
|
+
*
|
|
200
|
+
* `touchesForeign` predicts foreign stubs at their base length only, but a
|
|
201
|
+
* signal stub's clearance ladder may extend it further — jetson-agx-thor-
|
|
202
|
+
* baseboard shipped twelve trunk-on-stub contacts exactly that way, each a
|
|
203
|
+
* merged net the gate then refused. `reach` is that maximum growth, in mm.
|
|
204
|
+
* Both are orthogonal, so bounding-box overlap IS intersection, and it also
|
|
205
|
+
* catches collinear overlap, conservatively.
|
|
206
|
+
*/
|
|
207
|
+
export function segCrossesStubGrowth(seg, pin, o, reach, eps) {
|
|
208
|
+
const ex = pin.x + o.dx * reach;
|
|
209
|
+
const ey = pin.y + o.dy * reach;
|
|
210
|
+
return (Math.min(seg.x1, seg.x2) <= Math.max(pin.x, ex) + eps &&
|
|
211
|
+
Math.max(seg.x1, seg.x2) >= Math.min(pin.x, ex) - eps &&
|
|
212
|
+
Math.min(seg.y1, seg.y2) <= Math.max(pin.y, ey) + eps &&
|
|
213
|
+
Math.max(seg.y1, seg.y2) >= Math.min(pin.y, ey) - eps);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Split one line of bank candidates into the runs that may share a trunk.
|
|
217
|
+
*
|
|
218
|
+
* `canStub` vets a member on its own (its stub must clear foreign points) and
|
|
219
|
+
* `canJoin` vets the join to the member before it. A member that fails either
|
|
220
|
+
* ends the run in progress — a bank never buys density with a merged net — and
|
|
221
|
+
* a run of one is no bank at all, so only runs of two or more come back.
|
|
222
|
+
* Pure, so the veto semantics are testable without a sheet to place.
|
|
223
|
+
*/
|
|
224
|
+
export function splitBankRuns(line, canStub, canJoin) {
|
|
225
|
+
const runs = [];
|
|
226
|
+
let run = [];
|
|
227
|
+
const flush = () => {
|
|
228
|
+
if (run.length >= 2)
|
|
229
|
+
runs.push(run);
|
|
230
|
+
run = [];
|
|
231
|
+
};
|
|
232
|
+
for (const c of line) {
|
|
233
|
+
if (!canStub(c)) {
|
|
234
|
+
flush();
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!run.length) {
|
|
238
|
+
run.push(c);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (canJoin(run[run.length - 1], c))
|
|
242
|
+
run.push(c);
|
|
243
|
+
else {
|
|
244
|
+
flush();
|
|
245
|
+
run = [c];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
flush();
|
|
249
|
+
return runs;
|
|
250
|
+
}
|
|
251
|
+
const bodyBoundsOf = (sym) => {
|
|
252
|
+
if (sym.body)
|
|
253
|
+
return sym.body;
|
|
254
|
+
const xs = sym.pins.map((p) => p.x);
|
|
255
|
+
const ys = sym.pins.map((p) => p.y);
|
|
256
|
+
if (!xs.length)
|
|
257
|
+
return { minX: -U, minY: -U, maxX: U, maxY: U };
|
|
258
|
+
return { minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys) };
|
|
259
|
+
};
|
|
260
|
+
/** Pin connection point in schematic space for a part placed at (x, y), rot 0. */
|
|
261
|
+
const pinAt = (p, pin) => ({ x: p.x + pin.x, y: p.y - pin.y });
|
|
262
|
+
/** Outward direction of a pin (away from the body), schematic space. */
|
|
263
|
+
function outward(pin) {
|
|
264
|
+
// pin angle points from the connection point toward the body (symbol space,
|
|
265
|
+
// Y-up); outward is the opposite, with Y flipped into schematic space.
|
|
266
|
+
const a = ((pin.angle % 360) + 360) % 360;
|
|
267
|
+
if (a === 0)
|
|
268
|
+
return { dx: -1, dy: 0 };
|
|
269
|
+
if (a === 180)
|
|
270
|
+
return { dx: 1, dy: 0 };
|
|
271
|
+
if (a === 90)
|
|
272
|
+
return { dx: 0, dy: 1 };
|
|
273
|
+
return { dx: 0, dy: -1 };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Supply-name shapes for the last-resort classification below. Deliberately
|
|
277
|
+
* narrow, and narrow in ONE direction: a rail misread as a signal draws labels
|
|
278
|
+
* (exactly what the engine did before the fallback existed), while a signal
|
|
279
|
+
* misread as a rail draws a power symbol and makes the sheet assert a supply
|
|
280
|
+
* the design does not have. So an underscore may join a VOLTAGE suffix
|
|
281
|
+
* (VDD_3V3, VCC_1V8) and nothing else — VBUS_DET, VCC_SENSE and VDD_MON are
|
|
282
|
+
* measurement nodes on real boards and stay signals. Anything the shapes miss
|
|
283
|
+
* is still correctable with an IR `kind` declaration, which outranks all of
|
|
284
|
+
* this, and the report names the basis so a miss is visible.
|
|
285
|
+
*/
|
|
286
|
+
const GROUND_NAME = /^([adp]?gnd[0-9a-z]*|vss[0-9a-z]*)$/i;
|
|
287
|
+
const RAIL_NAME = /^(?:[+-]?[0-9]+(?:\.[0-9]+)?v[0-9]*|(?:vcc|vdd|vbus|vee)[0-9a-z]*(?:_[0-9]+v[0-9]*)?)$/i;
|
|
288
|
+
function classifyNet(net, pinsOf) {
|
|
289
|
+
if (net.kind === 'power')
|
|
290
|
+
return { cls: 'rail', overridden: true, basis: 'declared' };
|
|
291
|
+
if (net.kind === 'ground')
|
|
292
|
+
return { cls: 'ground', overridden: true, basis: 'declared' };
|
|
293
|
+
if (net.kind === 'signal')
|
|
294
|
+
return { cls: 'signal', overridden: true, basis: 'declared' };
|
|
295
|
+
const touchesPower = net.pins.some((ep) => {
|
|
296
|
+
const p = pinsOf(ep);
|
|
297
|
+
return p !== null && (p.etype === 'power_in' || p.etype === 'power_out');
|
|
298
|
+
});
|
|
299
|
+
if (!touchesPower) {
|
|
300
|
+
// No electrical-type evidence: real boards routinely carry their supplies
|
|
301
|
+
// on embedded symbols whose pins are all `passive` (stickhub's GND, 80
|
|
302
|
+
// pins, drafted as 80 labels and zero ground bars). Fall back to the
|
|
303
|
+
// unambiguous supply-name shapes only; anything else stays signal.
|
|
304
|
+
if (GROUND_NAME.test(net.name))
|
|
305
|
+
return { cls: 'ground', overridden: false, basis: 'name' };
|
|
306
|
+
if (RAIL_NAME.test(net.name))
|
|
307
|
+
return { cls: 'rail', overridden: false, basis: 'name' };
|
|
308
|
+
return { cls: 'signal', overridden: false, basis: 'name' };
|
|
309
|
+
}
|
|
310
|
+
return { cls: /gnd|vss/i.test(net.name) ? 'ground' : 'rail', overridden: false, basis: 'pin-type' };
|
|
311
|
+
}
|
|
312
|
+
const boundsOverlap = (a, b) => a.minX < b.maxX - 0.01 && a.maxX > b.minX + 0.01 && a.minY < b.maxY - 0.01 && a.maxY > b.minY + 0.01;
|
|
313
|
+
/**
|
|
314
|
+
* The box a label's text occupies, matching the legibility checker's
|
|
315
|
+
* conservative metrics. Shared by the de-collision pass and the overlap report
|
|
316
|
+
* so "clear" means one thing in the engine.
|
|
317
|
+
*/
|
|
318
|
+
const labelTextBox = (name, x, y, rot) => {
|
|
319
|
+
const w = Math.max(1, name.length) * LABEL_ADVANCE * LABEL_HEIGHT;
|
|
320
|
+
const h = LABEL_HEIGHT / 2;
|
|
321
|
+
return rot === 180
|
|
322
|
+
? { minX: x - w, minY: y - h, maxX: x, maxY: y + h }
|
|
323
|
+
: { minX: x, minY: y - h, maxX: x + w, maxY: y + h };
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* Pairs of labels naming DIFFERENT nets whose text boxes overlap.
|
|
327
|
+
*
|
|
328
|
+
* Distinct from `findMergedNets`, which looks for a shared label *point*. A
|
|
329
|
+
* shared point is electrical — KiCad fuses the nets. Overlapping text is
|
|
330
|
+
* cosmetic: the sheet reads badly, the netlist is right. Reported one entry per
|
|
331
|
+
* colliding label position, nets sorted, so the same pair is not listed twice.
|
|
332
|
+
*/
|
|
333
|
+
export function findLabelOverlaps(labels) {
|
|
334
|
+
const out = new Map();
|
|
335
|
+
const boxes = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot));
|
|
336
|
+
for (let i = 0; i < labels.length; i++) {
|
|
337
|
+
for (let j = i + 1; j < labels.length; j++) {
|
|
338
|
+
const a = labels[i];
|
|
339
|
+
const b = labels[j];
|
|
340
|
+
if (a.name === b.name)
|
|
341
|
+
continue;
|
|
342
|
+
// an exact coincidence is a merged net, reported by findMergedNets; do
|
|
343
|
+
// not also count it here or one fault reads as two
|
|
344
|
+
if (sameCoord(a.x, b.x) && sameCoord(a.y, b.y))
|
|
345
|
+
continue;
|
|
346
|
+
if (!boundsOverlap(boxes[i], boxes[j]))
|
|
347
|
+
continue;
|
|
348
|
+
for (const [l, other] of [[a, b], [b, a]]) {
|
|
349
|
+
const key = pointKey(l.x, l.y);
|
|
350
|
+
const e = out.get(key) ?? { x: l.x, y: l.y, nets: new Set([l.name]) };
|
|
351
|
+
e.nets.add(other.name);
|
|
352
|
+
out.set(key, e);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return [...out.values()]
|
|
357
|
+
.map((e) => ({ x: e.x, y: e.y, nets: [...e.nets].sort() }))
|
|
358
|
+
.sort((a, b) => a.nets[0].localeCompare(b.nets[0]) || a.x - b.x || a.y - b.y);
|
|
359
|
+
}
|
|
360
|
+
const segCrossesBody = (x1, y1, x2, y2, b) => {
|
|
361
|
+
const inX = Math.max(Math.min(x1, x2), b.minX) < Math.min(Math.max(x1, x2), b.maxX) - 0.01;
|
|
362
|
+
const inY = Math.max(Math.min(y1, y2), b.minY) < Math.min(Math.max(y1, y2), b.maxY) - 0.01;
|
|
363
|
+
if (x1 === x2)
|
|
364
|
+
return x1 > b.minX + 0.01 && x1 < b.maxX - 0.01 && inY;
|
|
365
|
+
if (y1 === y2)
|
|
366
|
+
return y1 > b.minY + 0.01 && y1 < b.maxY - 0.01 && inX;
|
|
367
|
+
return inX && inY; // conservative for diagonals (the engine never draws them)
|
|
368
|
+
};
|
|
369
|
+
export function draftSchematicPlacement(validated, projectName, today) {
|
|
370
|
+
const { intent, symbols, docGroups } = validated;
|
|
371
|
+
const notes = [];
|
|
372
|
+
// ---------- net classification (deterministic, visible in the report) ----------
|
|
373
|
+
const partByRef = new Map(intent.parts.map((p) => [p.ref, p]));
|
|
374
|
+
// ---------- instance expansion (multi-unit parts, #218) ----------
|
|
375
|
+
// Units none of whose OWN pins a net or no-connect references are left
|
|
376
|
+
// unplaced (the intent says nothing about them, and drawing them would add
|
|
377
|
+
// unconnected pins the intent never declared). Common (unit-0) pins do not
|
|
378
|
+
// count as a unit's own: they appear in every unit's view because KiCad
|
|
379
|
+
// draws them on every placed unit — an LM358's V+/V- reaching the rails
|
|
380
|
+
// must not drag an unused second opamp onto the sheet. A multi-unit part
|
|
381
|
+
// with NO referenced pins at all places all its units, so the part stays
|
|
382
|
+
// visible like an unwired single-unit part does.
|
|
383
|
+
const usedEps = new Set();
|
|
384
|
+
for (const net of intent.nets)
|
|
385
|
+
for (const ep of net.pins)
|
|
386
|
+
if (typeof ep === 'string')
|
|
387
|
+
usedEps.add(ep);
|
|
388
|
+
for (const ep of intent.noConnect ?? [])
|
|
389
|
+
if (typeof ep === 'string')
|
|
390
|
+
usedEps.add(ep);
|
|
391
|
+
/** Endpoints of common (unit-0) pins: drawn — and wired — on EVERY placed
|
|
392
|
+
* instance of their part. */
|
|
393
|
+
const commonEps = new Set();
|
|
394
|
+
const instances = intent.parts.flatMap((p) => {
|
|
395
|
+
const sym = symbols.get(p.ref);
|
|
396
|
+
if (!sym)
|
|
397
|
+
return [];
|
|
398
|
+
if (!sym.multiUnit || !sym.units?.length)
|
|
399
|
+
return [{ key: p.ref, ref: p.ref, unit: null, part: p, sym }];
|
|
400
|
+
const common = new Set(sym.commonUnitPins ?? []);
|
|
401
|
+
for (const n of common)
|
|
402
|
+
commonEps.add(`${p.ref}.${n}`);
|
|
403
|
+
const referenced = sym.units.filter((u) => u.pins.some((pin) => !common.has(pin.number) && usedEps.has(`${p.ref}.${pin.number}`)));
|
|
404
|
+
return (referenced.length ? referenced : sym.units).map((u) => ({
|
|
405
|
+
key: `${p.ref}#${u.unit}`,
|
|
406
|
+
ref: p.ref,
|
|
407
|
+
unit: u.unit,
|
|
408
|
+
part: p,
|
|
409
|
+
sym: { ...sym, pins: u.pins, body: u.body },
|
|
410
|
+
}));
|
|
411
|
+
});
|
|
412
|
+
const instByKey = new Map(instances.map((i) => [i.key, i]));
|
|
413
|
+
/** Placed instances per refdes, for expanding a common pin's endpoint. */
|
|
414
|
+
const instancesOfRef = new Map();
|
|
415
|
+
for (const inst of instances)
|
|
416
|
+
instancesOfRef.set(inst.ref, [...(instancesOfRef.get(inst.ref) ?? []), inst]);
|
|
417
|
+
const epInstKey = new Map();
|
|
418
|
+
for (const inst of instances) {
|
|
419
|
+
for (const pin of inst.sym.pins) {
|
|
420
|
+
const ep = `${inst.ref}.${pin.number}`;
|
|
421
|
+
if (!commonEps.has(ep))
|
|
422
|
+
epInstKey.set(ep, inst.key);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/** Placement-instance key owning endpoint REF.PIN. A common pin resolves to
|
|
426
|
+
* its part's FIRST placed instance (single-instance callers — layering,
|
|
427
|
+
* idiom passes — need one answer; the wiring passes use `expandEp` and
|
|
428
|
+
* reach every appearance). Falls back to the ref itself so lookups fail
|
|
429
|
+
* softly like before. */
|
|
430
|
+
const instKeyOf = (ref, pin) => epInstKey.get(`${ref}.${pin}`) ?? instancesOfRef.get(ref)?.[0]?.key ?? ref;
|
|
431
|
+
/** Every placed instance carrying endpoint REF.PIN: one for a unit's own
|
|
432
|
+
* pin, all of the part's instances for a common pin. */
|
|
433
|
+
const expandEp = (ref, pin) => {
|
|
434
|
+
if (commonEps.has(`${ref}.${pin}`))
|
|
435
|
+
return instancesOfRef.get(ref) ?? [];
|
|
436
|
+
const inst = instByKey.get(instKeyOf(ref, pin));
|
|
437
|
+
return inst ? [inst] : [];
|
|
438
|
+
};
|
|
439
|
+
const pinLookup = (ep) => {
|
|
440
|
+
const m = /^([^.]+)\.(.+)$/.exec(ep);
|
|
441
|
+
if (!m)
|
|
442
|
+
return null;
|
|
443
|
+
const sym = symbols.get(m[1]);
|
|
444
|
+
return sym?.pins.find((p) => p.number === m[2]) ?? null;
|
|
445
|
+
};
|
|
446
|
+
const netClasses = new Map();
|
|
447
|
+
for (const net of intent.nets)
|
|
448
|
+
netClasses.set(net.name, classifyNet(net, pinLookup));
|
|
449
|
+
const powerNets = intent.nets.filter((n) => netClasses.get(n.name).cls !== 'signal');
|
|
450
|
+
const signalNets = intent.nets.filter((n) => netClasses.get(n.name).cls === 'signal');
|
|
451
|
+
// ---------- reductions: decoupling caps and connectors ----------
|
|
452
|
+
// Structural, not name-based (#233): the reduction's own conditions below
|
|
453
|
+
// (a rail on one pin, ground on the other, an owner IC on the same rail)
|
|
454
|
+
// are what make a part a decoupling element. Real boards carry their caps
|
|
455
|
+
// under embedded, renamed lib ids the old `Device:C` test never matched,
|
|
456
|
+
// so their banks stayed in the columns as label islands; and a rail-clamp
|
|
457
|
+
// TVS drawn beside the caps is how the hand-drawn sheets show it too.
|
|
458
|
+
const isTwoPin = (p) => (symbols.get(p.ref)?.pins.length ?? 0) === 2;
|
|
459
|
+
const railsOf = (ref) => powerNets
|
|
460
|
+
.filter((net) => netClasses.get(net.name).cls === 'rail' && net.pins.some((ep) => ep.startsWith(`${ref}.`)))
|
|
461
|
+
.map((net) => net.name);
|
|
462
|
+
const railOf = (ref) => railsOf(ref)[0] ?? null;
|
|
463
|
+
const touchesGround = (ref) => powerNets.some((n) => netClasses.get(n.name).cls === 'ground' && n.pins.some((ep) => ep.startsWith(`${ref}.`)));
|
|
464
|
+
const decapOwner = new Map(); // cap ref -> owner IC ref
|
|
465
|
+
for (const p of intent.parts) {
|
|
466
|
+
const sym = symbols.get(p.ref);
|
|
467
|
+
if (sym.isPower || !isTwoPin(p))
|
|
468
|
+
continue;
|
|
469
|
+
const rail = railOf(p.ref);
|
|
470
|
+
if (!rail || !touchesGround(p.ref))
|
|
471
|
+
continue;
|
|
472
|
+
// owner: an IC (3+ pins) on the same rail — same group first, then most
|
|
473
|
+
// shared nets, then refdes order (deterministic tie-break, engine spec)
|
|
474
|
+
const candidates = intent.parts
|
|
475
|
+
.filter((c) => c.ref !== p.ref && (symbols.get(c.ref)?.pins.length ?? 0) >= 3 && railsOf(c.ref).includes(rail))
|
|
476
|
+
.map((c) => ({
|
|
477
|
+
ref: c.ref,
|
|
478
|
+
sameGroup: c.group === p.group ? 1 : 0,
|
|
479
|
+
shared: intent.nets.filter((n) => n.pins.some((e) => e.startsWith(`${c.ref}.`)) && n.pins.some((e) => e.startsWith(`${p.ref}.`))).length,
|
|
480
|
+
}))
|
|
481
|
+
.sort((a, b) => b.sameGroup - a.sameGroup || b.shared - a.shared || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
|
482
|
+
if (candidates.length)
|
|
483
|
+
decapOwner.set(p.ref, candidates[0].ref);
|
|
484
|
+
}
|
|
485
|
+
const isConnector = (p) => p.libId.startsWith('Connector');
|
|
486
|
+
// ---------- facing-label extents ----------
|
|
487
|
+
// A labelled stub extends horizontal TEXT into the channel beside its pin:
|
|
488
|
+
// the stub plus the net name at the checker's conservative advance. The base
|
|
489
|
+
// column channel and group gap assume short names; two facing pins whose
|
|
490
|
+
// combined names run past ~25 characters overrun them, and the de-collision
|
|
491
|
+
// pass cannot help (riding a stub outward moves the text further INTO the
|
|
492
|
+
// facing group). So the gaps below are widened by the facing extents, and a
|
|
493
|
+
// long-named pair drafts clean by construction instead of surviving as an
|
|
494
|
+
// error-severity text collision. Conservative on purpose: whether a signal
|
|
495
|
+
// net is wired or labelled is decided after placement, so every signal net
|
|
496
|
+
// counts here — typical names fit inside the base gaps and nothing widens.
|
|
497
|
+
const signalNetOfPin = new Map();
|
|
498
|
+
for (const net of signalNets)
|
|
499
|
+
for (const ep of net.pins)
|
|
500
|
+
signalNetOfPin.set(ep, net.name);
|
|
501
|
+
/** Every endpoint's net, power-class nets included (the idiom passes need
|
|
502
|
+
* to see a chain's rail/ground end, which signalNetOfPin cannot). */
|
|
503
|
+
const netByEndpoint = new Map();
|
|
504
|
+
for (const net of intent.nets)
|
|
505
|
+
for (const ep of net.pins)
|
|
506
|
+
netByEndpoint.set(ep, net);
|
|
507
|
+
const labelExtents = (keys) => {
|
|
508
|
+
let left = 0;
|
|
509
|
+
let right = 0;
|
|
510
|
+
for (const key of keys) {
|
|
511
|
+
const inst = instByKey.get(key);
|
|
512
|
+
for (const pin of inst?.sym.pins ?? []) {
|
|
513
|
+
const net = signalNetOfPin.get(`${inst.ref}.${pin.number}`);
|
|
514
|
+
if (!net)
|
|
515
|
+
continue;
|
|
516
|
+
const o = outward(pin);
|
|
517
|
+
if (o.dx === 0)
|
|
518
|
+
continue;
|
|
519
|
+
const extent = STUB * U + Math.max(1, net.length) * LABEL_ADVANCE * LABEL_HEIGHT;
|
|
520
|
+
if (o.dx === -1)
|
|
521
|
+
left = Math.max(left, extent);
|
|
522
|
+
else
|
|
523
|
+
right = Math.max(right, extent);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return { left, right };
|
|
527
|
+
};
|
|
528
|
+
/** Extra gap units so facing label text fits a boundary whose body-to-body
|
|
529
|
+
* clearance is `baseUnits` (one unit of slack between the two texts). */
|
|
530
|
+
const widenBy = (rightOfPrev, leftOfNext, baseUnits) => Math.max(0, ceilU(rightOfPrev + leftOfNext) + 1 - baseUnits);
|
|
531
|
+
// ---------- group ordering: hints, then SUBSYSTEMS.md order, then name ----------
|
|
532
|
+
const groupNames = [...new Set(intent.parts.filter((p) => !symbols.get(p.ref).isPower).map((p) => p.group))];
|
|
533
|
+
const orderIndex = (g) => {
|
|
534
|
+
const hinted = intent.hints?.groupOrder?.findIndex((h) => h.toLowerCase() === g.toLowerCase());
|
|
535
|
+
if (hinted !== undefined && hinted >= 0)
|
|
536
|
+
return hinted;
|
|
537
|
+
const doc = docGroups?.findIndex((h) => h.toLowerCase() === g.toLowerCase());
|
|
538
|
+
if (doc !== undefined && doc >= 0)
|
|
539
|
+
return 1000 + doc;
|
|
540
|
+
return 2000;
|
|
541
|
+
};
|
|
542
|
+
groupNames.sort((a, b) => orderIndex(a) - orderIndex(b) || a.localeCompare(b));
|
|
543
|
+
// ---------- in-group placement: layering + barycenter, integer grid ----------
|
|
544
|
+
const placed = new Map();
|
|
545
|
+
const groupRects = [];
|
|
546
|
+
const groupOf = new Map();
|
|
547
|
+
const groupExtents = new Map();
|
|
548
|
+
for (const gname of groupNames) {
|
|
549
|
+
groupExtents.set(gname, labelExtents(instances.filter((i) => i.part.group === gname && !i.sym.isPower).map((i) => i.key)));
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Place every group's cells. `bandBudgetW` caps a single group's width, in
|
|
553
|
+
* grid units: a column that would tile past it starts a new band of columns
|
|
554
|
+
* below the ones already placed (#219). `colBudgetH` caps a single column's
|
|
555
|
+
* height, in grid units: a depth whose parts stack taller becomes several
|
|
556
|
+
* side-by-side columns, the vertical analog of banding (#220 phase 4).
|
|
557
|
+
* `Infinity` for both keeps the classic single-band ribbon. Placement is
|
|
558
|
+
* deterministic in (intent, budgets), so the paper-selection pass below may
|
|
559
|
+
* re-run it with tighter budgets when a sheet is worth compacting onto.
|
|
560
|
+
* Returns each group's band count.
|
|
561
|
+
*/
|
|
562
|
+
const placeAllGroups = (bandBudgetW, colBudgetH = Infinity) => {
|
|
563
|
+
placed.clear();
|
|
564
|
+
groupRects.length = 0;
|
|
565
|
+
groupOf.clear();
|
|
566
|
+
const bandsOf = new Map();
|
|
567
|
+
let groupX = 0; // running x origin (units) for group tiling
|
|
568
|
+
let prevGroup = null; // last group that actually placed cells
|
|
569
|
+
for (const gname of groupNames) {
|
|
570
|
+
// widen the gap to the previous group when facing label text needs it
|
|
571
|
+
if (prevGroup !== null) {
|
|
572
|
+
groupX += widenBy(groupExtents.get(prevGroup).right, groupExtents.get(gname).left, 2 * MARGIN + GROUP_GAP);
|
|
573
|
+
}
|
|
574
|
+
const members = instances.filter((i) => i.part.group === gname && !i.sym.isPower && !decapOwner.has(i.key));
|
|
575
|
+
const caps = instances.filter((i) => i.part.group === gname && decapOwner.has(i.key));
|
|
576
|
+
for (const i of [...members, ...caps])
|
|
577
|
+
groupOf.set(i.key, gname);
|
|
578
|
+
const memberKeySet = new Set(members.map((m) => m.key));
|
|
579
|
+
// layer assignment: connectors at depth 0; signal edges push depth forward
|
|
580
|
+
const depth = new Map(members.map((m) => [m.key, isConnector(m.part) ? 0 : 1]));
|
|
581
|
+
const edges = [];
|
|
582
|
+
for (const net of signalNets) {
|
|
583
|
+
const eps = net.pins
|
|
584
|
+
.map((ep) => {
|
|
585
|
+
const m = /^([^.]+)\.(.+)$/.exec(ep);
|
|
586
|
+
return m ? instKeyOf(m[1], m[2]) : '';
|
|
587
|
+
})
|
|
588
|
+
.filter((k) => memberKeySet.has(k));
|
|
589
|
+
const uniq = [...new Set(eps)];
|
|
590
|
+
for (let i = 0; i < uniq.length; i++) {
|
|
591
|
+
for (let j = i + 1; j < uniq.length; j++) {
|
|
592
|
+
const [a, b] = [uniq[i], uniq[j]].sort((x, y) => x.localeCompare(y, undefined, { numeric: true }));
|
|
593
|
+
edges.push({ from: a, to: b });
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (let iter = 0; iter < members.length; iter++) {
|
|
598
|
+
let changed = false;
|
|
599
|
+
for (const e of edges) {
|
|
600
|
+
const want = (depth.get(e.from) ?? 0) + 1;
|
|
601
|
+
if ((depth.get(e.to) ?? 0) < want && want <= members.length) {
|
|
602
|
+
depth.set(e.to, want);
|
|
603
|
+
changed = true;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!changed)
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
const depths = [...new Set([...depth.values()])].sort((a, b) => a - b);
|
|
610
|
+
const columns = depths.map((d) => members.filter((m) => depth.get(m.key) === d).map((m) => m.key));
|
|
611
|
+
// barycenter row ordering (two sweeps), refdes as the deterministic tie
|
|
612
|
+
const rowOf = new Map();
|
|
613
|
+
columns.forEach((col) => col.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i)));
|
|
614
|
+
for (let sweep = 0; sweep < 2; sweep++) {
|
|
615
|
+
for (let ci = 1; ci < columns.length; ci++) {
|
|
616
|
+
const col = columns[ci];
|
|
617
|
+
const bary = (ref) => {
|
|
618
|
+
const neigh = edges
|
|
619
|
+
.filter((e) => e.from === ref || e.to === ref)
|
|
620
|
+
.map((e) => (e.from === ref ? e.to : e.from))
|
|
621
|
+
.filter((o) => rowOf.has(o));
|
|
622
|
+
if (!neigh.length)
|
|
623
|
+
return rowOf.get(ref);
|
|
624
|
+
return neigh.reduce((s, o) => s + rowOf.get(o), 0) / neigh.length;
|
|
625
|
+
};
|
|
626
|
+
col.sort((a, b) => bary(a) - bary(b) || a.localeCompare(b, undefined, { numeric: true })).forEach((r, i) => rowOf.set(r, i));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// cells: sized from body plus margins, positions snapped to the grid
|
|
630
|
+
const cellDims = new Map();
|
|
631
|
+
for (const m of members) {
|
|
632
|
+
const b = bodyBoundsOf(m.sym);
|
|
633
|
+
cellDims.set(m.key, {
|
|
634
|
+
w: ceilU(b.maxX - b.minX) + 2 * MARGIN,
|
|
635
|
+
h: ceilU(b.maxY - b.minY) + 2 * MARGIN,
|
|
636
|
+
body: b,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
// Column height budget (#220 phase 4): a depth whose parts stack taller
|
|
640
|
+
// than the budget splits into several side-by-side columns, in row
|
|
641
|
+
// order, so a 24-part board stops drafting as one full-height strip on
|
|
642
|
+
// a sheet two sizes too large. Cells never shrink; only the arrangement
|
|
643
|
+
// changes, so readability is untouched.
|
|
644
|
+
const columnsToPlace = colBudgetH === Infinity
|
|
645
|
+
? columns
|
|
646
|
+
: columns.flatMap((col) => {
|
|
647
|
+
const chunks = [];
|
|
648
|
+
let cur = [];
|
|
649
|
+
let h = 0;
|
|
650
|
+
for (const ref of col) {
|
|
651
|
+
const add = cellDims.get(ref).h + (cur.length ? ROW_GAP : 0);
|
|
652
|
+
if (cur.length && h + add > colBudgetH) {
|
|
653
|
+
chunks.push(cur);
|
|
654
|
+
cur = [ref];
|
|
655
|
+
h = cellDims.get(ref).h;
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
cur.push(ref);
|
|
659
|
+
h += add;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (cur.length)
|
|
663
|
+
chunks.push(cur);
|
|
664
|
+
return chunks;
|
|
665
|
+
});
|
|
666
|
+
let colX = groupX;
|
|
667
|
+
let groupMaxY = 0;
|
|
668
|
+
let bandTop = 0; // y origin (units) of the current band of columns
|
|
669
|
+
let bandCount = 1;
|
|
670
|
+
// The budget must leave room for the label TEXT facing the sheet edges:
|
|
671
|
+
// a band filled to the full usable width puts the leftmost column's
|
|
672
|
+
// left-facing labels outside the frame (#220 phase 1), and no later
|
|
673
|
+
// shift can fix both edges at once.
|
|
674
|
+
const ext = groupExtents.get(gname);
|
|
675
|
+
const bandW = Math.max(1, bandBudgetW - ceilU(ext.left) - ceilU(ext.right));
|
|
676
|
+
for (let ci = 0; ci < columnsToPlace.length; ci++) {
|
|
677
|
+
const col = columnsToPlace[ci];
|
|
678
|
+
const colW = Math.max(...col.map((r) => cellDims.get(r).w));
|
|
679
|
+
// Banding (#219): a column that would tile past the width budget starts
|
|
680
|
+
// a new band of columns below everything placed so far, the way the
|
|
681
|
+
// shelf-wrap below re-rows whole groups. Never before the first column
|
|
682
|
+
// of a band, so a single over-wide column still places (and the caller
|
|
683
|
+
// rejects this budget instead).
|
|
684
|
+
if (colX > groupX && colX + colW - groupX > bandW) {
|
|
685
|
+
bandTop = groupMaxY + GROUP_GAP;
|
|
686
|
+
colX = groupX;
|
|
687
|
+
bandCount++;
|
|
688
|
+
}
|
|
689
|
+
let rowY = bandTop;
|
|
690
|
+
for (const ref of col) {
|
|
691
|
+
const dims = cellDims.get(ref);
|
|
692
|
+
const cx = colX + Math.floor(colW / 2); // shared column axis (units)
|
|
693
|
+
const cy = rowY + Math.floor(dims.h / 2);
|
|
694
|
+
const b = dims.body;
|
|
695
|
+
// origin so the body centers on the cell center, snapped to grid
|
|
696
|
+
const ox = grid(cx - Math.round((b.minX + b.maxX) / 2 / U));
|
|
697
|
+
const oy = grid(cy + Math.round((b.minY + b.maxY) / 2 / U));
|
|
698
|
+
const inst = instByKey.get(ref);
|
|
699
|
+
placed.set(ref, {
|
|
700
|
+
part: inst.part,
|
|
701
|
+
refDes: inst.ref,
|
|
702
|
+
unit: inst.unit,
|
|
703
|
+
sym: inst.sym,
|
|
704
|
+
x: ox,
|
|
705
|
+
y: oy,
|
|
706
|
+
body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
|
|
707
|
+
cellW: dims.w,
|
|
708
|
+
cellH: dims.h,
|
|
709
|
+
});
|
|
710
|
+
rowY += dims.h + ROW_GAP;
|
|
711
|
+
}
|
|
712
|
+
groupMaxY = Math.max(groupMaxY, rowY - ROW_GAP);
|
|
713
|
+
const next = columnsToPlace[ci + 1];
|
|
714
|
+
colX += colW + CHANNEL + (next ? widenBy(labelExtents(col).right, labelExtents(next).left, 2 * MARGIN + CHANNEL) : 0);
|
|
715
|
+
}
|
|
716
|
+
// decoupling rows: caps in a uniform row under their owner (or the group)
|
|
717
|
+
const capRefs = caps.map((c) => c.key).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
718
|
+
if (capRefs.length) {
|
|
719
|
+
// The bank stacks under the circuit at the circuit's own width, the
|
|
720
|
+
// way a hand-drawn sheet does — a 45-cap ribbon run out to the band
|
|
721
|
+
// budget alone turned the group into an L-shape wider than the sheet
|
|
722
|
+
// it deserved (#233). The floor keeps a short bank (four typical cap
|
|
723
|
+
// cells) on one row even when the circuit above it is narrower.
|
|
724
|
+
const blockW = Math.max(64, colX - groupX);
|
|
725
|
+
const capBudget = Math.min(bandW, blockW);
|
|
726
|
+
let capX = groupX;
|
|
727
|
+
let capY = groupMaxY + MARGIN + 4;
|
|
728
|
+
for (const ref of capRefs) {
|
|
729
|
+
const inst = instByKey.get(ref);
|
|
730
|
+
const b = bodyBoundsOf(inst.sym);
|
|
731
|
+
// Banding (#219): a decoupling bank wider than the budget wraps onto
|
|
732
|
+
// another uniform row rather than running past the frame.
|
|
733
|
+
if (capX > groupX && capX + ceilU(b.maxX - b.minX) + 2 * MARGIN - groupX > capBudget) {
|
|
734
|
+
capX = groupX;
|
|
735
|
+
capY += 2 * MARGIN + 6;
|
|
736
|
+
}
|
|
737
|
+
const ox = grid(capX + MARGIN);
|
|
738
|
+
const oy = grid(capY + MARGIN);
|
|
739
|
+
placed.set(ref, {
|
|
740
|
+
part: inst.part,
|
|
741
|
+
refDes: inst.ref,
|
|
742
|
+
unit: inst.unit,
|
|
743
|
+
sym: inst.sym,
|
|
744
|
+
x: ox,
|
|
745
|
+
y: oy,
|
|
746
|
+
body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
|
|
747
|
+
cellW: ceilU(b.maxX - b.minX) + 2 * MARGIN,
|
|
748
|
+
cellH: ceilU(b.maxY - b.minY) + 2 * MARGIN,
|
|
749
|
+
});
|
|
750
|
+
capX += ceilU(b.maxX - b.minX) + 2 * MARGIN;
|
|
751
|
+
}
|
|
752
|
+
groupMaxY = capY + 2 * MARGIN + 6;
|
|
753
|
+
}
|
|
754
|
+
// ---------- idiom micro-templates and the alignment pass (7.5/7.5a) ----------
|
|
755
|
+
// Column placement is correct but reads machine-made for the small
|
|
756
|
+
// structures a human drafter draws by reflex: a pull-up sits directly on
|
|
757
|
+
// the pin it pulls with its rail above, a series RC hangs as one straight
|
|
758
|
+
// vertical run, crystal load caps mirror about their crystal. Two passes
|
|
759
|
+
// rearrange exactly those shapes after column placement, both no-ops
|
|
760
|
+
// unless the textbook topology is present, and both collision-checked so
|
|
761
|
+
// a failed fit falls back to the column position rather than overlapping.
|
|
762
|
+
const inGroup = new Set(members.map((m) => m.key));
|
|
763
|
+
const idiomPlaced = new Set();
|
|
764
|
+
const CHAIN_GAP = 4 * U;
|
|
765
|
+
/** The two pins of a vertically-pinned two-lead instance, or null. */
|
|
766
|
+
const vertPins = (key) => {
|
|
767
|
+
const pins = placed.get(key)?.sym.pins ?? [];
|
|
768
|
+
if (pins.length !== 2)
|
|
769
|
+
return null;
|
|
770
|
+
const top = pins.find((p) => outward(p).dy === -1);
|
|
771
|
+
const bot = pins.find((p) => outward(p).dy === 1);
|
|
772
|
+
return top && bot ? { top, bot } : null;
|
|
773
|
+
};
|
|
774
|
+
const isCrystal = (key) => /crystal|reson/i.test(placed.get(key)?.part.libId ?? '');
|
|
775
|
+
/** An instance the chain pass may move: two vertical leads, in this group,
|
|
776
|
+
* not already spoken for by the decap row or the crystal template. */
|
|
777
|
+
const chainable = (key) => inGroup.has(key) && !decapOwner.has(key) && !idiomPlaced.has(key) && !isCrystal(key) && vertPins(key) !== null;
|
|
778
|
+
/** Endpoint REF.PIN for an instance's pin (the base refdes, never the key). */
|
|
779
|
+
const epOf = (key, pin) => `${placed.get(key)?.refDes ?? key}.${pin}`;
|
|
780
|
+
const parseEp = (ep) => {
|
|
781
|
+
const m = /^([^.]+)\.(.+)$/.exec(ep);
|
|
782
|
+
return m ? { ref: m[1], pin: m[2], key: instKeyOf(m[1], m[2]) } : null;
|
|
783
|
+
};
|
|
784
|
+
const classOf = (name) => netClasses.get(name)?.cls ?? 'signal';
|
|
785
|
+
const padOverlap = (a, b, pad) => a.minX < b.maxX + pad && a.maxX > b.minX - pad && a.minY < b.maxY + pad && a.maxY > b.minY - pad;
|
|
786
|
+
/** Candidate placement putting `key`'s TOP pin connection point at (x, y). */
|
|
787
|
+
const candidateAt = (key, x, y) => {
|
|
788
|
+
const prev = placed.get(key);
|
|
789
|
+
const v = vertPins(key);
|
|
790
|
+
const b = bodyBoundsOf(prev.sym);
|
|
791
|
+
const ox = x - v.top.x;
|
|
792
|
+
const oy = y + v.top.y;
|
|
793
|
+
return {
|
|
794
|
+
part: prev.part,
|
|
795
|
+
refDes: prev.refDes,
|
|
796
|
+
unit: prev.unit,
|
|
797
|
+
sym: prev.sym,
|
|
798
|
+
x: ox,
|
|
799
|
+
y: oy,
|
|
800
|
+
body: { minX: ox + b.minX, minY: oy - b.maxY, maxX: ox + b.maxX, maxY: oy - b.minY },
|
|
801
|
+
cellW: prev.cellW,
|
|
802
|
+
cellH: prev.cellH,
|
|
803
|
+
};
|
|
804
|
+
};
|
|
805
|
+
/** Apply candidate moves unless any moved body lands within a grid unit of
|
|
806
|
+
* an unmoved one; all-or-nothing so a failed fit changes nothing. */
|
|
807
|
+
const applyMoves = (moves) => {
|
|
808
|
+
for (const cand of moves.values()) {
|
|
809
|
+
for (const [oref, op] of placed) {
|
|
810
|
+
if (moves.has(oref))
|
|
811
|
+
continue;
|
|
812
|
+
if (padOverlap(cand.body, op.body, U))
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
for (const [ref, cand] of moves)
|
|
817
|
+
placed.set(ref, cand);
|
|
818
|
+
return true;
|
|
819
|
+
};
|
|
820
|
+
/** Every endpoint of every net a set of parts touches (the chain's own
|
|
821
|
+
* connection points, allowed to sit on its axis by definition). */
|
|
822
|
+
const ownEndpoints = (keys) => {
|
|
823
|
+
const eps = new Set();
|
|
824
|
+
for (const key of keys) {
|
|
825
|
+
for (const pin of placed.get(key)?.sym.pins ?? []) {
|
|
826
|
+
const net = netByEndpoint.get(epOf(key, pin.number));
|
|
827
|
+
for (const ep of net?.pins ?? [])
|
|
828
|
+
eps.add(ep);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return eps;
|
|
832
|
+
};
|
|
833
|
+
/**
|
|
834
|
+
* A chain's wires run vertically along one x. No FOREIGN connected pin or
|
|
835
|
+
* stub end may sit on that line within the chain's y-range: KiCad joins
|
|
836
|
+
* wires at coincident endpoints, so a chain routed down a column of
|
|
837
|
+
* neighbouring stub ends silently merges nets. The first npn-switch run
|
|
838
|
+
* of this pass did exactly that — the reset pull-up's run down U1's
|
|
839
|
+
* left-pin stub column attached 5V to DRIVE. Body boxes cannot catch
|
|
840
|
+
* this; the check must be against connection points.
|
|
841
|
+
*/
|
|
842
|
+
const axisClear = (axisX, yMin, yMax, ownEps, conn = [], movedRefs = new Set()) => {
|
|
843
|
+
for (const [oref, opl] of placed) {
|
|
844
|
+
for (const pin of opl.sym.pins) {
|
|
845
|
+
const ep = `${opl.refDes}.${pin.number}`;
|
|
846
|
+
const net = netByEndpoint.get(ep);
|
|
847
|
+
if (!net)
|
|
848
|
+
continue;
|
|
849
|
+
const o = outward(pin);
|
|
850
|
+
const len = classOf(net.name) !== 'signal' && o.dx !== 0 ? STUB + 2 : STUB;
|
|
851
|
+
const p = pinAt(opl, pin);
|
|
852
|
+
const end = { x: p.x + o.dx * len * U, y: p.y + o.dy * len * U };
|
|
853
|
+
if (!ownEps.has(ep)) {
|
|
854
|
+
for (const q of [p, end]) {
|
|
855
|
+
if (sameCoord(q.x, axisX) && q.y > yMin - U && q.y < yMax + U)
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// A horizontal stub SEGMENT crossing the axis exactly at a chain
|
|
860
|
+
// CONNECTION row of a DIFFERENT net passes through that connection
|
|
861
|
+
// point — a mid-segment crossing elsewhere is harmless, but a lead
|
|
862
|
+
// parked on the crossing row is a KiCad join. The pull-up idiom
|
|
863
|
+
// parked R1's bottom lead on U1's power-pin row and the VCC stub
|
|
864
|
+
// ran straight through the pulled SIG node (I22's chain-pass face,
|
|
865
|
+
// #204). Net-aware, because ownEps is too coarse here: U1's VCC pin
|
|
866
|
+
// shares a net with the chain's rail end, yet its stub through a
|
|
867
|
+
// SIG row still merges. Moved parts are skipped — `placed` holds
|
|
868
|
+
// their stale pre-move positions.
|
|
869
|
+
if (o.dx !== 0 && !movedRefs.has(oref)) {
|
|
870
|
+
const row = conn.find((c) => sameCoord(c.y, p.y));
|
|
871
|
+
if (row && row.net !== net.name) {
|
|
872
|
+
const lo = Math.min(p.x, end.x) - 0.001;
|
|
873
|
+
const hi = Math.max(p.x, end.x) + 0.001;
|
|
874
|
+
if (axisX >= lo && axisX <= hi)
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return true;
|
|
881
|
+
};
|
|
882
|
+
/** The room a rail/ground end grows past its pin: stub, bar, value text. */
|
|
883
|
+
const POWER_CLEAR = 8 * U;
|
|
884
|
+
const powerEndBox = (axisX, pinY, dir) => ({
|
|
885
|
+
minX: axisX - 3 * U,
|
|
886
|
+
maxX: axisX + 3 * U,
|
|
887
|
+
minY: dir === -1 ? pinY - POWER_CLEAR : pinY,
|
|
888
|
+
maxY: dir === -1 ? pinY : pinY + POWER_CLEAR,
|
|
889
|
+
});
|
|
890
|
+
/** Clearance-check a chain (or flank) move set against its axis and its
|
|
891
|
+
* power-end growth, then apply; marks the parts idiom-placed only when
|
|
892
|
+
* everything held. */
|
|
893
|
+
const finalizeMoves = (segments, moves, clearBoxes = []) => {
|
|
894
|
+
const own = ownEndpoints(moves.keys());
|
|
895
|
+
const movedRefs = new Set(moves.keys());
|
|
896
|
+
for (const seg of segments) {
|
|
897
|
+
// the pad covers the power stub and symbol a rail/ground end grows
|
|
898
|
+
if (!axisClear(seg.axisX, Math.min(...seg.ys) - 4 * U, Math.max(...seg.ys) + 4 * U, own, seg.conn ?? [], movedRefs))
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
// a power symbol is not a body, so the body check cannot see it: the
|
|
902
|
+
// divider repro grew R2's GND bar and value text straight into the body
|
|
903
|
+
// of the part below until these boxes were checked explicitly
|
|
904
|
+
for (const box of clearBoxes) {
|
|
905
|
+
for (const [oref, op] of placed) {
|
|
906
|
+
if (moves.has(oref))
|
|
907
|
+
continue;
|
|
908
|
+
if (padOverlap(box, op.body, 0))
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (!applyMoves(moves))
|
|
913
|
+
return false;
|
|
914
|
+
for (const ref of moves.keys())
|
|
915
|
+
idiomPlaced.add(ref);
|
|
916
|
+
return true;
|
|
917
|
+
};
|
|
918
|
+
// Crystal flanking: each horizontal crystal pin whose net also reaches a
|
|
919
|
+
// two-lead cap-to-ground drops that cap below the pin's stub end. The
|
|
920
|
+
// crystal's pins are symmetric about its body, so the two caps come out
|
|
921
|
+
// mirror-placed at equal offsets and a common height by construction.
|
|
922
|
+
for (const m of members) {
|
|
923
|
+
if (!isCrystal(m.key))
|
|
924
|
+
continue;
|
|
925
|
+
const xpl = placed.get(m.key);
|
|
926
|
+
if (!xpl || xpl.sym.pins.length !== 2)
|
|
927
|
+
continue;
|
|
928
|
+
const moves = new Map();
|
|
929
|
+
const segments = [];
|
|
930
|
+
const clearBoxes = [];
|
|
931
|
+
for (const pin of xpl.sym.pins) {
|
|
932
|
+
const o = outward(pin);
|
|
933
|
+
if (o.dx === 0)
|
|
934
|
+
continue;
|
|
935
|
+
const net = netByEndpoint.get(`${m.ref}.${pin.number}`);
|
|
936
|
+
if (!net || classOf(net.name) !== 'signal')
|
|
937
|
+
continue;
|
|
938
|
+
const cap = net.pins
|
|
939
|
+
.map(parseEp)
|
|
940
|
+
.find((e) => {
|
|
941
|
+
if (!e || e.key === m.key || !chainable(e.key) || moves.has(e.key))
|
|
942
|
+
return false;
|
|
943
|
+
const v = vertPins(e.key);
|
|
944
|
+
if (e.pin !== v.top.number)
|
|
945
|
+
return false; // crystal node must enter the cap's top lead
|
|
946
|
+
const other = netByEndpoint.get(`${e.ref}.${v.bot.number}`);
|
|
947
|
+
return other !== undefined && classOf(other.name) !== 'signal';
|
|
948
|
+
});
|
|
949
|
+
if (!cap)
|
|
950
|
+
continue;
|
|
951
|
+
const at = pinAt(xpl, pin);
|
|
952
|
+
const axisX = at.x + o.dx * STUB * U;
|
|
953
|
+
const cand = candidateAt(cap.key, axisX, at.y + CHAIN_GAP);
|
|
954
|
+
const v = vertPins(cap.key);
|
|
955
|
+
moves.set(cap.key, cand);
|
|
956
|
+
segments.push({ axisX, ys: [at.y, cand.y - v.bot.y] });
|
|
957
|
+
clearBoxes.push(powerEndBox(axisX, cand.y - v.bot.y, 1)); // the ground symbol below the cap
|
|
958
|
+
}
|
|
959
|
+
// all-or-nothing per crystal: one dropped cap and one column cap would
|
|
960
|
+
// read worse than the plain columns the pass is improving on
|
|
961
|
+
if (moves.size)
|
|
962
|
+
finalizeMoves(segments, moves, clearBoxes);
|
|
963
|
+
}
|
|
964
|
+
const walk = (start, dir, chain) => {
|
|
965
|
+
let current = start;
|
|
966
|
+
for (;;) {
|
|
967
|
+
const v = vertPins(current);
|
|
968
|
+
const pinN = dir === 'up' ? v.top.number : v.bot.number;
|
|
969
|
+
const net = netByEndpoint.get(epOf(current, pinN));
|
|
970
|
+
if (!net)
|
|
971
|
+
return { kind: 'open' }; // declared no-connect or unused
|
|
972
|
+
if (classOf(net.name) !== 'signal')
|
|
973
|
+
return { kind: 'power' };
|
|
974
|
+
if (net.pins.length !== 2)
|
|
975
|
+
return { kind: 'invalid' }; // a tapped node is not a series chain
|
|
976
|
+
const otherEp = net.pins.map(parseEp).find((e) => e !== null && e.key !== current);
|
|
977
|
+
if (!otherEp)
|
|
978
|
+
return { kind: 'invalid' };
|
|
979
|
+
const opl = placed.get(otherEp.key);
|
|
980
|
+
if (!opl || groupOf.get(otherEp.key) !== gname)
|
|
981
|
+
return { kind: 'invalid' };
|
|
982
|
+
if (chainable(otherEp.key) && !chain.includes(otherEp.key)) {
|
|
983
|
+
const ov = vertPins(otherEp.key);
|
|
984
|
+
// the link must enter through the lead facing the chain, or the
|
|
985
|
+
// drawn run would have to cross the part's own body
|
|
986
|
+
if (otherEp.pin !== (dir === 'up' ? ov.bot.number : ov.top.number))
|
|
987
|
+
return { kind: 'invalid' };
|
|
988
|
+
if (dir === 'up')
|
|
989
|
+
chain.unshift(otherEp.key);
|
|
990
|
+
else
|
|
991
|
+
chain.push(otherEp.key);
|
|
992
|
+
current = otherEp.key;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
const pin = opl.sym.pins.find((p) => p.number === otherEp.pin);
|
|
996
|
+
if (!pin || chain.includes(otherEp.key))
|
|
997
|
+
return { kind: 'invalid' };
|
|
998
|
+
return { kind: 'anchor', ref: otherEp.key, pin };
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
const chained = new Set();
|
|
1002
|
+
for (const m of members) {
|
|
1003
|
+
if (!chainable(m.key) || chained.has(m.key))
|
|
1004
|
+
continue;
|
|
1005
|
+
const chain = [m.key];
|
|
1006
|
+
const topEnd = walk(m.key, 'up', chain);
|
|
1007
|
+
const bottomEnd = walk(chain[chain.length - 1], 'down', chain);
|
|
1008
|
+
for (const ref of chain)
|
|
1009
|
+
chained.add(ref);
|
|
1010
|
+
if (topEnd.kind === 'invalid' || bottomEnd.kind === 'invalid')
|
|
1011
|
+
continue;
|
|
1012
|
+
if (chain.length > 4)
|
|
1013
|
+
continue; // beyond four parts this is a network, not an idiom
|
|
1014
|
+
const anchors = [topEnd, bottomEnd].filter((e) => e.kind === 'anchor');
|
|
1015
|
+
if (anchors.length === 0 && chain.length < 2)
|
|
1016
|
+
continue; // a lone floating part has nothing to align to
|
|
1017
|
+
if (topEnd.kind === 'open' && bottomEnd.kind === 'open')
|
|
1018
|
+
continue;
|
|
1019
|
+
const stubEndOf = (a) => {
|
|
1020
|
+
const at = pinAt(placed.get(a.ref), a.pin);
|
|
1021
|
+
const o = outward(a.pin);
|
|
1022
|
+
return { x: at.x + o.dx * STUB * U, y: at.y + o.dy * STUB * U, o };
|
|
1023
|
+
};
|
|
1024
|
+
let axisX;
|
|
1025
|
+
let cursor; // y of the next TOP pin to place
|
|
1026
|
+
let order = chain;
|
|
1027
|
+
if (topEnd.kind === 'anchor') {
|
|
1028
|
+
const s = stubEndOf(topEnd);
|
|
1029
|
+
if (s.o.dy === -1)
|
|
1030
|
+
continue; // an up-facing pin cannot feed a downward run
|
|
1031
|
+
if (bottomEnd.kind === 'anchor') {
|
|
1032
|
+
const b = stubEndOf(bottomEnd);
|
|
1033
|
+
// both ends must sit on one axis with the second anchor below and
|
|
1034
|
+
// able to receive from above, else leave the columns alone
|
|
1035
|
+
if (!sameCoord(s.x, b.x) || b.y <= s.y || b.o.dy === 1)
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
axisX = s.x;
|
|
1039
|
+
cursor = s.y + CHAIN_GAP;
|
|
1040
|
+
}
|
|
1041
|
+
else if (bottomEnd.kind === 'anchor') {
|
|
1042
|
+
// rail above, anchor below (a pull-up): stack upward from the anchor
|
|
1043
|
+
const s = stubEndOf(bottomEnd);
|
|
1044
|
+
if (s.o.dy === 1)
|
|
1045
|
+
continue; // a down-facing pin cannot feed an upward run
|
|
1046
|
+
axisX = s.x;
|
|
1047
|
+
order = [...chain].reverse();
|
|
1048
|
+
// Bounded lift: when a connection row would sit on a foreign stub's
|
|
1049
|
+
// crossing (axisClear's segment check), raise the whole stack a grid
|
|
1050
|
+
// row at a time rather than shipping the contact or losing the idiom.
|
|
1051
|
+
const netOf = (key, pinN) => netByEndpoint.get(epOf(key, pinN))?.name ?? '';
|
|
1052
|
+
for (let lift = 0; lift < 3; lift++) {
|
|
1053
|
+
let up = s.y - CHAIN_GAP - lift * 2 * U;
|
|
1054
|
+
const moves = new Map();
|
|
1055
|
+
const conn = [{ y: s.y, net: netOf(bottomEnd.ref, bottomEnd.pin.number) }];
|
|
1056
|
+
for (const ref of order) {
|
|
1057
|
+
const v = vertPins(ref);
|
|
1058
|
+
const span = v.top.y - v.bot.y; // symbol-space lead separation
|
|
1059
|
+
const cand = candidateAt(ref, axisX, up - span);
|
|
1060
|
+
moves.set(ref, cand);
|
|
1061
|
+
conn.push({ y: up, net: netOf(ref, v.bot.number) }, { y: up - span, net: netOf(ref, v.top.number) });
|
|
1062
|
+
up = up - span - CHAIN_GAP;
|
|
1063
|
+
}
|
|
1064
|
+
const topY = up + CHAIN_GAP;
|
|
1065
|
+
const topRef = order[order.length - 1];
|
|
1066
|
+
const done = finalizeMoves([{ axisX, ys: [s.y, topY], conn: [...conn, { y: topY, net: netOf(topRef, vertPins(topRef).top.number) }] }], moves, topEnd.kind === 'power' ? [powerEndBox(axisX, topY, -1)] : []);
|
|
1067
|
+
if (done)
|
|
1068
|
+
break;
|
|
1069
|
+
}
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
// both ends are rails: a divider — straighten in place on its own axis
|
|
1074
|
+
const first = placed.get(chain[0]);
|
|
1075
|
+
const v = vertPins(chain[0]);
|
|
1076
|
+
const topAt = pinAt(first, v.top);
|
|
1077
|
+
axisX = topAt.x;
|
|
1078
|
+
cursor = topAt.y;
|
|
1079
|
+
}
|
|
1080
|
+
const cursor0 = cursor;
|
|
1081
|
+
const netOf2 = (key, pinN) => netByEndpoint.get(epOf(key, pinN))?.name ?? '';
|
|
1082
|
+
for (let lift = 0; lift < 3; lift++) {
|
|
1083
|
+
cursor = cursor0 + lift * 2 * U;
|
|
1084
|
+
const moves = new Map();
|
|
1085
|
+
const startY = cursor;
|
|
1086
|
+
const conn = [];
|
|
1087
|
+
let fits = true;
|
|
1088
|
+
for (const ref of order) {
|
|
1089
|
+
const cand = candidateAt(ref, axisX, cursor);
|
|
1090
|
+
moves.set(ref, cand);
|
|
1091
|
+
const v = vertPins(ref);
|
|
1092
|
+
conn.push({ y: cursor, net: netOf2(ref, v.top.number) }, { y: cursor + (v.top.y - v.bot.y), net: netOf2(ref, v.bot.number) });
|
|
1093
|
+
cursor = cursor + (v.top.y - v.bot.y) + CHAIN_GAP;
|
|
1094
|
+
}
|
|
1095
|
+
let axisEndY = cursor - CHAIN_GAP;
|
|
1096
|
+
if (bottomEnd.kind === 'anchor') {
|
|
1097
|
+
// cursor now sits one gap below the last lead; it may not pass the
|
|
1098
|
+
// lower anchor's stub end or the closing wire would run backwards
|
|
1099
|
+
const b = stubEndOf(bottomEnd);
|
|
1100
|
+
if (cursor > b.y + 0.001)
|
|
1101
|
+
fits = false;
|
|
1102
|
+
axisEndY = b.y;
|
|
1103
|
+
}
|
|
1104
|
+
const clearBoxes = [];
|
|
1105
|
+
if (topEnd.kind === 'power')
|
|
1106
|
+
clearBoxes.push(powerEndBox(axisX, startY, -1));
|
|
1107
|
+
if (bottomEnd.kind === 'power')
|
|
1108
|
+
clearBoxes.push(powerEndBox(axisX, cursor - CHAIN_GAP, 1));
|
|
1109
|
+
const endNet = conn.length ? conn[0].net : '';
|
|
1110
|
+
const startConn = { y: cursor0 - CHAIN_GAP, net: topEnd.kind === 'anchor' ? netOf2(topEnd.ref, topEnd.pin.number) : endNet };
|
|
1111
|
+
const lastConn = { y: axisEndY, net: bottomEnd.kind === 'anchor' ? netOf2(bottomEnd.ref, bottomEnd.pin.number) : (conn.length ? conn[conn.length - 1].net : '') };
|
|
1112
|
+
if (fits && finalizeMoves([{ axisX, ys: [cursor0 - CHAIN_GAP, axisEndY], conn: [startConn, ...conn, lastConn] }], moves, clearBoxes))
|
|
1113
|
+
break;
|
|
1114
|
+
if (!fits)
|
|
1115
|
+
break; // lifting only shrinks the room below; no retry can help
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
const memberRefs = [...members.map((m) => m.key), ...capRefs];
|
|
1119
|
+
const cells = memberRefs.map((r) => placed.get(r));
|
|
1120
|
+
if (cells.length) {
|
|
1121
|
+
const minX = Math.min(...cells.map((c) => c.body.minX)) - MARGIN * U;
|
|
1122
|
+
const maxX = Math.max(...cells.map((c) => c.body.maxX)) + MARGIN * U;
|
|
1123
|
+
const minY = Math.min(...cells.map((c) => c.body.minY)) - (MARGIN + 4) * U;
|
|
1124
|
+
const maxY = Math.max(...cells.map((c) => c.body.maxY)) + (MARGIN + 2) * U;
|
|
1125
|
+
groupRects.push({ name: gname, x1: minX, y1: minY, x2: maxX, y2: maxY });
|
|
1126
|
+
groupX = Math.round(maxX / U) + GROUP_GAP;
|
|
1127
|
+
prevGroup = gname;
|
|
1128
|
+
bandsOf.set(gname, bandCount);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return bandsOf;
|
|
1132
|
+
};
|
|
1133
|
+
// ---------- shelf-wrap: reflow the group ribbon into rows (design D12) ----------
|
|
1134
|
+
// Groups tile left-to-right above, which on a design with many subsystems
|
|
1135
|
+
// yields a ribbon: this repo's light controller came out 750 x 83 mm, a 9:1
|
|
1136
|
+
// strip that forces A1 and leaves 85% of the sheet empty. Wrapping that into
|
|
1137
|
+
// rows is what a human drafter does, and it costs nothing in readability as
|
|
1138
|
+
// long as the reading order is preserved — groups keep their declared order
|
|
1139
|
+
// and fill left-to-right, then top-to-bottom, exactly like text.
|
|
1140
|
+
//
|
|
1141
|
+
// Runs BEFORE the wire/label pass so spans are measured on final coordinates:
|
|
1142
|
+
// a shorter sheet turns some label pairs back into real wires.
|
|
1143
|
+
const paperHint = intent.hints?.paper;
|
|
1144
|
+
if (paperHint && !PAPERS.some((p) => p.name === paperHint)) {
|
|
1145
|
+
notes.push(`paper hint "${paperHint}" is not a standard size; deriving paper from content`);
|
|
1146
|
+
}
|
|
1147
|
+
const hinted = paperHint ? PAPERS.find((p) => p.name === paperHint) : undefined;
|
|
1148
|
+
// A hint pins the width budget; otherwise try every sheet, smallest first.
|
|
1149
|
+
const candidates = hinted ? [hinted] : PAPERS;
|
|
1150
|
+
const gap = GROUP_GAP * U;
|
|
1151
|
+
const usableW = (p) => p.w - 2 * FRAME;
|
|
1152
|
+
const usableH = (p) => p.h - 2 * FRAME - TITLE_STRIP;
|
|
1153
|
+
/**
|
|
1154
|
+
* Shelf-wrap the group rects to a width budget; returns per-group offsets.
|
|
1155
|
+
*
|
|
1156
|
+
* Offsets are relative to where the single-row pass already put each group,
|
|
1157
|
+
* never absolute targets: a row that does not wrap gets dx = dy = 0 and its
|
|
1158
|
+
* geometry is bit-for-bit what it was. Re-deriving absolute positions here
|
|
1159
|
+
* would re-round every group's width through the grid and shift
|
|
1160
|
+
* long-standing layouts by a unit for no reason.
|
|
1161
|
+
*/
|
|
1162
|
+
const wrapTo = (budgetW) => {
|
|
1163
|
+
const originX = groupRects[0].x1;
|
|
1164
|
+
const leftExtOf = (name) => groupExtents.get(name)?.left ?? 0;
|
|
1165
|
+
const rightExtOf = (name) => groupExtents.get(name)?.right ?? 0;
|
|
1166
|
+
const deltas = [];
|
|
1167
|
+
let rowOriginX = originX;
|
|
1168
|
+
// Label text on the row's flanks needs budget too: a row filled to the
|
|
1169
|
+
// full usable width hangs its leading group's left-facing labels outside
|
|
1170
|
+
// the frame, where no later shift can reach them (#220, the shelf-wrap
|
|
1171
|
+
// analog of the band budget's reserved extents).
|
|
1172
|
+
let rowLeftExt = leftExtOf(groupRects[0].name);
|
|
1173
|
+
let dyUnits = 0;
|
|
1174
|
+
let rowH = 0;
|
|
1175
|
+
for (const r of groupRects) {
|
|
1176
|
+
// A group wider than the whole budget still starts its own row; it will
|
|
1177
|
+
// overflow, and the caller rejects this paper for it.
|
|
1178
|
+
if (r.x1 > rowOriginX && r.x2 - rowOriginX + rowLeftExt + rightExtOf(r.name) > budgetW) {
|
|
1179
|
+
dyUnits += Math.ceil((rowH + gap) / U);
|
|
1180
|
+
rowOriginX = r.x1;
|
|
1181
|
+
rowLeftExt = leftExtOf(r.name);
|
|
1182
|
+
rowH = 0;
|
|
1183
|
+
}
|
|
1184
|
+
deltas.push({ dx: grid(Math.round((originX - rowOriginX) / U)), dy: dyUnits * U });
|
|
1185
|
+
rowH = Math.max(rowH, r.y2 - r.y1);
|
|
1186
|
+
}
|
|
1187
|
+
const xs = groupRects.flatMap((r, i) => [r.x1 + deltas[i].dx, r.x2 + deltas[i].dx]);
|
|
1188
|
+
const ys = groupRects.flatMap((r, i) => [r.y1 + deltas[i].dy, r.y2 + deltas[i].dy]);
|
|
1189
|
+
return { deltas, w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
|
|
1190
|
+
};
|
|
1191
|
+
/**
|
|
1192
|
+
* Whether the current placement fits sheet `p`, with the group shelf-wrap
|
|
1193
|
+
* deltas that make it fit. `wrap` is null when there is nothing to reflow:
|
|
1194
|
+
* one group is already its own row, and an intent whose parts are all power
|
|
1195
|
+
* symbols has no group rect to measure from at all.
|
|
1196
|
+
*/
|
|
1197
|
+
const fitsOn = (p) => {
|
|
1198
|
+
if (groupRects.length > 1) {
|
|
1199
|
+
const w = wrapTo(usableW(p));
|
|
1200
|
+
return w.w <= usableW(p) && w.h <= usableH(p) ? { paper: p, wrap: w } : null;
|
|
1201
|
+
}
|
|
1202
|
+
const r = groupRects[0];
|
|
1203
|
+
return !r || (r.x2 - r.x1 <= usableW(p) && r.y2 - r.y1 <= usableH(p)) ? { paper: p, wrap: null } : null;
|
|
1204
|
+
};
|
|
1205
|
+
/** The smallest candidate sheet the current placement fits, or null. */
|
|
1206
|
+
const bestFit = () => {
|
|
1207
|
+
for (const p of candidates) {
|
|
1208
|
+
const f = fitsOn(p);
|
|
1209
|
+
if (f)
|
|
1210
|
+
return f;
|
|
1211
|
+
}
|
|
1212
|
+
return null;
|
|
1213
|
+
};
|
|
1214
|
+
/**
|
|
1215
|
+
* Budgeted attempt at one sheet. The width budget alone reshapes a ribbon
|
|
1216
|
+
* into bands, but a group of stacked two-pin parts fills the HEIGHT first
|
|
1217
|
+
* and leaves the landscape width untouched (stickhub reflowed to 347 mm of
|
|
1218
|
+
* A1's 821 usable and still overflowed the bottom). Shorter column budgets
|
|
1219
|
+
* spread the same cells into more side-by-side columns, so walk the height
|
|
1220
|
+
* fractions until the content matches the sheet's aspect or nothing fits.
|
|
1221
|
+
*/
|
|
1222
|
+
const tryPaperBudgeted = (p) => {
|
|
1223
|
+
for (const frac of [1, 0.7, 0.5]) {
|
|
1224
|
+
const b = placeAllGroups(Math.floor(usableW(p) / U), Math.floor((usableH(p) * frac) / U));
|
|
1225
|
+
const f = fitsOn(p);
|
|
1226
|
+
if (f)
|
|
1227
|
+
return { fit: f, bands: b };
|
|
1228
|
+
}
|
|
1229
|
+
return null;
|
|
1230
|
+
};
|
|
1231
|
+
let bands = placeAllGroups(Infinity);
|
|
1232
|
+
let fit = bestFit();
|
|
1233
|
+
if (!fit) {
|
|
1234
|
+
// No sheet holds the natural ribbon even with whole groups wrapped into
|
|
1235
|
+
// rows: some group is by itself wider than the widest usable frame (#219
|
|
1236
|
+
// drew 94 parts as one strip four sizes past the designer's A3, with 367
|
|
1237
|
+
// out-of-frame findings — a sheet that would not plot). Growing the paper
|
|
1238
|
+
// cannot fix that, so instead wrap COLUMNS into bands inside the oversized
|
|
1239
|
+
// groups, targeting the smallest sheet that fits. Each attempt must fit
|
|
1240
|
+
// the sheet whose width it banded to: accepting a narrow banding on a
|
|
1241
|
+
// larger sheet would re-create the empty-ribbon failure, rotated 90°.
|
|
1242
|
+
for (const p of candidates) {
|
|
1243
|
+
const t = tryPaperBudgeted(p);
|
|
1244
|
+
if (t) {
|
|
1245
|
+
bands = t.bands;
|
|
1246
|
+
fit = t.fit;
|
|
1247
|
+
break;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (!fit) {
|
|
1251
|
+
const largest = candidates[candidates.length - 1];
|
|
1252
|
+
bands = placeAllGroups(Math.floor(usableW(largest) / U), Math.floor(usableH(largest) / U));
|
|
1253
|
+
fit = { paper: largest, wrap: groupRects.length > 1 ? wrapTo(usableW(largest)) : null };
|
|
1254
|
+
notes.push(`content does not fit the ${hinted ? 'hinted' : 'largest standard'} sheet (${largest.name}) even with groups and columns wrapped; the drawing will overflow the frame`);
|
|
1255
|
+
}
|
|
1256
|
+
for (const [g, n] of bands) {
|
|
1257
|
+
if (n > 1)
|
|
1258
|
+
notes.push(`group "${g}" was wider than the sheet; its columns wrapped onto ${n} bands`);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
else if (!hinted && fit.paper !== PAPERS[0]) {
|
|
1262
|
+
// ---------- compaction (#220 phase 4) ----------
|
|
1263
|
+
// The natural ribbon FITS a sheet, but mostly with air: a 24-part board
|
|
1264
|
+
// whose parts stack into one full-height strip "fits" A1 while the person
|
|
1265
|
+
// drew the same circuit on A3. When the natural fit uses less than the
|
|
1266
|
+
// checker's utilization floor, retry the smaller sheets, smallest first,
|
|
1267
|
+
// with both budgets, and take the first that holds the reflowed content.
|
|
1268
|
+
// A paper hint pins the sheet and skips this entirely.
|
|
1269
|
+
// Utilization by INK, not bounding box: an L-shaped layout (a tall column
|
|
1270
|
+
// strip plus a wide bank ribbon) spans a bbox that reads "full" while the
|
|
1271
|
+
// sheet is mostly air, and the bbox measure let stickhub sprawl onto A0
|
|
1272
|
+
// uncompacted. The sum of placed cell areas is what is actually drawn.
|
|
1273
|
+
const inkArea = [...placed.values()].reduce((s, p) => s + p.cellW * p.cellH * U * U, 0);
|
|
1274
|
+
const utilOf = (f) => inkArea / (usableW(f.paper) * usableH(f.paper));
|
|
1275
|
+
const naturalUtil = utilOf(fit);
|
|
1276
|
+
if (naturalUtil < COMPACT_UTILIZATION) {
|
|
1277
|
+
const naturalPaper = fit.paper;
|
|
1278
|
+
let compacted = null;
|
|
1279
|
+
let compactedBands = bands;
|
|
1280
|
+
for (const p of candidates) {
|
|
1281
|
+
if (p === naturalPaper)
|
|
1282
|
+
break; // only sheets smaller than the natural fit
|
|
1283
|
+
const t = tryPaperBudgeted(p);
|
|
1284
|
+
if (t) {
|
|
1285
|
+
compacted = t.fit;
|
|
1286
|
+
compactedBands = t.bands;
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (compacted) {
|
|
1291
|
+
bands = compactedBands;
|
|
1292
|
+
fit = compacted;
|
|
1293
|
+
notes.push(`sheet compacted: the natural layout fit ${naturalPaper.name} at ${Math.round(naturalUtil * 100)}% utilization; reflowed onto ${fit.paper.name}`);
|
|
1294
|
+
for (const [g, n] of bands) {
|
|
1295
|
+
if (n > 1)
|
|
1296
|
+
notes.push(`group "${g}" was wider than the sheet; its columns wrapped onto ${n} bands`);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
else {
|
|
1300
|
+
// nothing smaller holds the reflowed content: restore the natural
|
|
1301
|
+
// placement byte for byte
|
|
1302
|
+
bands = placeAllGroups(Infinity);
|
|
1303
|
+
fit = bestFit();
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (fit.wrap) {
|
|
1308
|
+
const wrap = fit.wrap;
|
|
1309
|
+
const rows = new Set(wrap.deltas.map((d) => d.dy)).size;
|
|
1310
|
+
if (rows > 1)
|
|
1311
|
+
notes.push(`groups wrapped onto ${rows} rows to fit the sheet`);
|
|
1312
|
+
groupRects.forEach((r, i) => {
|
|
1313
|
+
const d = wrap.deltas[i];
|
|
1314
|
+
if (!d.dx && !d.dy)
|
|
1315
|
+
return;
|
|
1316
|
+
for (const ref of [...groupOf.entries()].filter(([, g]) => g === r.name).map(([ref]) => ref)) {
|
|
1317
|
+
const pl = placed.get(ref);
|
|
1318
|
+
if (!pl)
|
|
1319
|
+
continue;
|
|
1320
|
+
pl.x += d.dx;
|
|
1321
|
+
pl.y += d.dy;
|
|
1322
|
+
pl.body.minX += d.dx;
|
|
1323
|
+
pl.body.maxX += d.dx;
|
|
1324
|
+
pl.body.minY += d.dy;
|
|
1325
|
+
pl.body.maxY += d.dy;
|
|
1326
|
+
}
|
|
1327
|
+
r.x1 += d.dx;
|
|
1328
|
+
r.x2 += d.dx;
|
|
1329
|
+
r.y1 += d.dy;
|
|
1330
|
+
r.y2 += d.dy;
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
// ---------- stubs, power symbols, labels, wires (design D2/D6a) ----------
|
|
1334
|
+
const wires = [];
|
|
1335
|
+
const labels = [];
|
|
1336
|
+
const junctions = [];
|
|
1337
|
+
const extraSymbols = [];
|
|
1338
|
+
const libSymbols = new Map();
|
|
1339
|
+
const pwrFlags = [];
|
|
1340
|
+
/** Labels sitting at a stub end, with the stub they may ride outward. */
|
|
1341
|
+
const stubbedLabels = [];
|
|
1342
|
+
/**
|
|
1343
|
+
* Wired-net labels with every wire point of their run as fallback anchors.
|
|
1344
|
+
* `pts` is sorted for anchor preference (topmost-leftmost first) and so says
|
|
1345
|
+
* nothing about which point joins which; `segs` keeps the run's segments in
|
|
1346
|
+
* emission order, which is what the interior walk must step along.
|
|
1347
|
+
*/
|
|
1348
|
+
const wiredLabels = [];
|
|
1349
|
+
let wireIdx = new Map();
|
|
1350
|
+
const addWire = (net, x1, y1, x2, y2) => {
|
|
1351
|
+
if (sameCoord(x1, x2) && sameCoord(y1, y2))
|
|
1352
|
+
return; // zero-length once emitted
|
|
1353
|
+
const i = wireIdx.get(net) ?? 0;
|
|
1354
|
+
wireIdx.set(net, i + 1);
|
|
1355
|
+
wires.push({ x1, y1, x2, y2, net, index: i });
|
|
1356
|
+
};
|
|
1357
|
+
let pwrSeq = 0;
|
|
1358
|
+
let flgSeq = 0;
|
|
1359
|
+
const endpointsOf = (net) => {
|
|
1360
|
+
const eps = net.pins
|
|
1361
|
+
// a common (unit-0) pin is drawn on every placed instance of its part;
|
|
1362
|
+
// every appearance is wired to this same net, so the appearances stay
|
|
1363
|
+
// one electrical point and no drawn pin end dangles
|
|
1364
|
+
.flatMap((ep) => {
|
|
1365
|
+
const m = /^([^.]+)\.(.+)$/.exec(ep);
|
|
1366
|
+
return expandEp(m[1], m[2]).map((inst) => {
|
|
1367
|
+
const pl = placed.get(inst.key);
|
|
1368
|
+
const pin = pl?.sym.pins.find((p) => p.number === m[2]);
|
|
1369
|
+
if (!pl || !pin)
|
|
1370
|
+
return null;
|
|
1371
|
+
return { ref: inst.key, pin, at: pinAt(pl, pin) };
|
|
1372
|
+
});
|
|
1373
|
+
})
|
|
1374
|
+
.filter((e) => e !== null)
|
|
1375
|
+
.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }) || a.pin.number.localeCompare(b.pin.number, undefined, { numeric: true }));
|
|
1376
|
+
// Stacked pins are one point on the sheet: KiCad symbols routinely repeat a
|
|
1377
|
+
// pin at the same coordinate (a thermal pad carried as a second GND pin, a
|
|
1378
|
+
// doubled supply pin). Drafting per PIN would stack a stub, a power symbol,
|
|
1379
|
+
// and its value text exactly on top of an identical one — invisible in the
|
|
1380
|
+
// render, an overlap to the checker, redundant to a reviewer. One item per
|
|
1381
|
+
// POINT; connectivity is unchanged because the pins share the point.
|
|
1382
|
+
return eps.filter((e, i) => eps.findIndex((o) => o.at.x === e.at.x && o.at.y === e.at.y) === i);
|
|
1383
|
+
};
|
|
1384
|
+
// power-class nets: per-pin power symbols, rails up, grounds down; one
|
|
1385
|
+
// PWR_FLAG per net without a power_out driver (design D6a). The stub runs in
|
|
1386
|
+
// the pin's OUTWARD direction — a fixed vertical drop would land on the next
|
|
1387
|
+
// pin of a connector-style part (2 grid rows apart) and short two nets.
|
|
1388
|
+
// Horizontal stubs run 4 units so their symbol clears the 2-unit signal
|
|
1389
|
+
// stubs and label anchors of neighbouring rows.
|
|
1390
|
+
const powerBodies = [...placed.values()].map((p) => p.body);
|
|
1391
|
+
/**
|
|
1392
|
+
* True when any of `segs` touches a FOREIGN connection point: a pin, the
|
|
1393
|
+
* stub end any connected pin grows (predicted — stubs of nets sorted later
|
|
1394
|
+
* are not emitted yet), or an already-emitted wire of another net. KiCad
|
|
1395
|
+
* joins wires at coincident endpoints and at an endpoint on a wire's
|
|
1396
|
+
* interior, so any such contact merges nets (I22, #204).
|
|
1397
|
+
*
|
|
1398
|
+
* Used by every pass that decides where a wire may end: the trunk-and-branch
|
|
1399
|
+
* veto, the labelled-stub fallback (the cap-to-ground drop placed a cap whose
|
|
1400
|
+
* own 2-unit stub ended exactly on the neighbouring power pin's stub interior,
|
|
1401
|
+
* so stubs need the check as much as trunks), the power-stub ladder, and the
|
|
1402
|
+
* label nudge. Defined here rather than beside the signal pass because the
|
|
1403
|
+
* power pass below runs first and needs it too (#217).
|
|
1404
|
+
*/
|
|
1405
|
+
const touchesForeign = (segs, netName, ownEps, opts = {}) => {
|
|
1406
|
+
const predictStubs = opts.predictStubs ?? true;
|
|
1407
|
+
for (const opl of placed.values()) {
|
|
1408
|
+
for (const pin of opl.sym.pins) {
|
|
1409
|
+
const ep = `${opl.refDes}.${pin.number}`;
|
|
1410
|
+
const onet = netByEndpoint.get(ep);
|
|
1411
|
+
// A pin with NO net is still a connection point: a wire through it
|
|
1412
|
+
// joins it in KiCad. jetson-agx-thor-baseboard put an L-route corner
|
|
1413
|
+
// exactly on such a pin (J14.46, a single-pin group the intent never
|
|
1414
|
+
// names) and shipped a merged net past every gate. Only the pin's
|
|
1415
|
+
// POINT is guarded for netless pins; there is no stub to predict.
|
|
1416
|
+
if (onet?.name === netName || ownEps.has(ep))
|
|
1417
|
+
continue;
|
|
1418
|
+
const p = pinAt(opl, pin);
|
|
1419
|
+
if (segs.some((c) => pointOnSeg(p.x, p.y, c)))
|
|
1420
|
+
return true;
|
|
1421
|
+
if (!predictStubs || !onet)
|
|
1422
|
+
continue;
|
|
1423
|
+
const o = outward(pin);
|
|
1424
|
+
const len = (netClasses.get(onet.name)?.cls ?? 'signal') !== 'signal' && o.dx !== 0 ? STUB + 2 : STUB;
|
|
1425
|
+
const end = { x: p.x + o.dx * len * U, y: p.y + o.dy * len * U };
|
|
1426
|
+
if (segs.some((c) => pointOnSeg(end.x, end.y, c)))
|
|
1427
|
+
return true;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
for (const w of wires) {
|
|
1431
|
+
if (w.net === netName)
|
|
1432
|
+
continue;
|
|
1433
|
+
if (segs.some((c) => pointOnSeg(w.x1, w.y1, c) ||
|
|
1434
|
+
pointOnSeg(w.x2, w.y2, c) ||
|
|
1435
|
+
pointOnSeg(c.x1, c.y1, w) ||
|
|
1436
|
+
pointOnSeg(c.x2, c.y2, w))) {
|
|
1437
|
+
return true;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
return false;
|
|
1441
|
+
};
|
|
1442
|
+
/** Visible power value texts placed so far, for the collision rules below. */
|
|
1443
|
+
const shownPowerValues = [];
|
|
1444
|
+
const powerValueBox = (net, x, y) => {
|
|
1445
|
+
const w = Math.max(1, net.length) * LABEL_ADVANCE * LABEL_HEIGHT;
|
|
1446
|
+
return { minX: x - w / 2, minY: y - LABEL_HEIGHT / 2, maxX: x + w / 2, maxY: y + LABEL_HEIGHT / 2 };
|
|
1447
|
+
};
|
|
1448
|
+
for (const net of [...powerNets].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1449
|
+
const cls = netClasses.get(net.name).cls;
|
|
1450
|
+
const src = powerSymbolSource(net.name, cls === 'ground' ? 'ground' : 'rail');
|
|
1451
|
+
libSymbols.set(src.libId, src.sourceText);
|
|
1452
|
+
const hasDriver = net.pins.some((ep) => pinLookup(ep)?.etype === 'power_out');
|
|
1453
|
+
const eps = endpointsOf(net);
|
|
1454
|
+
/** One PWR_FLAG per undriven net, on the net's FIRST endpoint — whether
|
|
1455
|
+
* that endpoint drafts as a bank member or a lone symbol. */
|
|
1456
|
+
const maybeFlag = (i, x, y) => {
|
|
1457
|
+
if (i !== 0 || hasDriver)
|
|
1458
|
+
return;
|
|
1459
|
+
const flag = pwrFlagSource();
|
|
1460
|
+
libSymbols.set(flag.libId, flag.sourceText);
|
|
1461
|
+
flgSeq++;
|
|
1462
|
+
extraSymbols.push({
|
|
1463
|
+
ref: `#FLG${String(flgSeq).padStart(2, '0')}`,
|
|
1464
|
+
libId: flag.libId,
|
|
1465
|
+
value: 'PWR_FLAG',
|
|
1466
|
+
footprint: '',
|
|
1467
|
+
at: { x, y, rot: 0 },
|
|
1468
|
+
refAt: { x, y },
|
|
1469
|
+
valueAt: { x, y },
|
|
1470
|
+
hideRef: true,
|
|
1471
|
+
hideValue: true,
|
|
1472
|
+
pinNumbers: ['1'],
|
|
1473
|
+
});
|
|
1474
|
+
pwrFlags.push(net.name);
|
|
1475
|
+
};
|
|
1476
|
+
// ---------- rail-bank trunks (#233, #220 phase 3) ----------
|
|
1477
|
+
// Decap-row caps adjacent on the same power net chain on ONE trunk: a
|
|
1478
|
+
// stub per pin, horizontal joins between consecutive stub ends, a single
|
|
1479
|
+
// power symbol and value at the first end. The human's sixteen-cap VBUS
|
|
1480
|
+
// bank carries two power symbols; the per-pin idiom drew twenty-six.
|
|
1481
|
+
// Every stub and trunk segment must clear foreign points and bodies, and
|
|
1482
|
+
// a member that cannot join cleanly splits the run — a bank never buys
|
|
1483
|
+
// density with a merged net.
|
|
1484
|
+
const consumed = new Set();
|
|
1485
|
+
{
|
|
1486
|
+
const ownEps = new Set(net.pins);
|
|
1487
|
+
// Structural, not name-based: any two-pin part with a vertical pin on
|
|
1488
|
+
// this power net banks — real boards carry their caps under embedded,
|
|
1489
|
+
// renamed symbols the Device:C test never matches, and a pull-up array
|
|
1490
|
+
// on one rail trunk is drawn the same way by hand.
|
|
1491
|
+
const cands = eps
|
|
1492
|
+
.map((ep, i) => ({ ep, i, o: outward(ep.pin) }))
|
|
1493
|
+
.filter((c) => c.o.dy !== 0 && c.o.dx === 0 && (placed.get(c.ep.ref)?.sym.pins.length ?? 0) === 2);
|
|
1494
|
+
const byLine = new Map();
|
|
1495
|
+
for (const c of cands) {
|
|
1496
|
+
const k = `${c.o.dy}|${knum(c.ep.at.y + c.o.dy * STUB * U)}`;
|
|
1497
|
+
byLine.set(k, [...(byLine.get(k) ?? []), c]);
|
|
1498
|
+
}
|
|
1499
|
+
const stubClear = (c) => {
|
|
1500
|
+
const end = { x: c.ep.at.x, y: c.ep.at.y + c.o.dy * STUB * U };
|
|
1501
|
+
return !touchesForeign([{ x1: c.ep.at.x, y1: c.ep.at.y, x2: end.x, y2: end.y }], net.name, ownEps, {
|
|
1502
|
+
predictStubs: false,
|
|
1503
|
+
});
|
|
1504
|
+
};
|
|
1505
|
+
/**
|
|
1506
|
+
* A trunk may not cross the LINE a foreign stub could grow along. The
|
|
1507
|
+
* geometry is `segCrossesStubGrowth` (tested directly); this walks every
|
|
1508
|
+
* foreign pin over it at the signal stub's maximum grown length.
|
|
1509
|
+
*/
|
|
1510
|
+
const crossesForeignStubLine = (seg) => {
|
|
1511
|
+
for (const opl of placed.values()) {
|
|
1512
|
+
for (const pin of opl.sym.pins) {
|
|
1513
|
+
const ep = `${opl.refDes}.${pin.number}`;
|
|
1514
|
+
const onet = netByEndpoint.get(ep);
|
|
1515
|
+
if (!onet || onet.name === net.name)
|
|
1516
|
+
continue;
|
|
1517
|
+
if (segCrossesStubGrowth(seg, pinAt(opl, pin), outward(pin), (STUB + 2) * U, SEG_EPS))
|
|
1518
|
+
return true;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
return false;
|
|
1522
|
+
};
|
|
1523
|
+
const emitBank = (run) => {
|
|
1524
|
+
const dy = run[0].o.dy;
|
|
1525
|
+
const ends = run.map((c) => ({ x: c.ep.at.x, y: c.ep.at.y + dy * STUB * U }));
|
|
1526
|
+
run.forEach((c, j) => {
|
|
1527
|
+
addWire(net.name, c.ep.at.x, c.ep.at.y, ends[j].x, ends[j].y);
|
|
1528
|
+
consumed.add(c.i);
|
|
1529
|
+
});
|
|
1530
|
+
for (let j = 1; j < ends.length; j++) {
|
|
1531
|
+
addWire(net.name, ends[j - 1].x, ends[j - 1].y, ends[j].x, ends[j].y);
|
|
1532
|
+
}
|
|
1533
|
+
const first = ends[0];
|
|
1534
|
+
const valueAt = { x: first.x, y: first.y + dy * 3.556 };
|
|
1535
|
+
const box = powerValueBox(net.name, valueAt.x, valueAt.y);
|
|
1536
|
+
const hideValue = shownPowerValues.some((p) => p.net === net.name && boundsOverlap(p.box, box));
|
|
1537
|
+
if (!hideValue)
|
|
1538
|
+
shownPowerValues.push({ net: net.name, box });
|
|
1539
|
+
pwrSeq++;
|
|
1540
|
+
extraSymbols.push({
|
|
1541
|
+
ref: `#PWR${String(pwrSeq).padStart(2, '0')}`,
|
|
1542
|
+
libId: src.libId,
|
|
1543
|
+
value: net.name,
|
|
1544
|
+
footprint: '',
|
|
1545
|
+
at: { x: first.x, y: first.y, rot: 0 },
|
|
1546
|
+
refAt: { x: first.x, y: first.y },
|
|
1547
|
+
valueAt,
|
|
1548
|
+
hideRef: true,
|
|
1549
|
+
hideValue,
|
|
1550
|
+
pinNumbers: ['1'],
|
|
1551
|
+
});
|
|
1552
|
+
for (const [j, c] of run.entries())
|
|
1553
|
+
maybeFlag(c.i, ends[j].x, ends[j].y);
|
|
1554
|
+
};
|
|
1555
|
+
const joinClear = (prev, c) => {
|
|
1556
|
+
const y = c.ep.at.y + c.o.dy * STUB * U;
|
|
1557
|
+
const seg = { x1: prev.ep.at.x, y1: y, x2: c.ep.at.x, y2: y };
|
|
1558
|
+
return (c.ep.at.x - prev.ep.at.x <= BANK_PITCH_MAX * U &&
|
|
1559
|
+
!powerBodies.some((b) => segCrossesBody(seg.x1, seg.y1, seg.x2, seg.y2, b)) &&
|
|
1560
|
+
!touchesForeign([seg], net.name, ownEps, { predictStubs: true }) &&
|
|
1561
|
+
!crossesForeignStubLine(seg));
|
|
1562
|
+
};
|
|
1563
|
+
for (const line of [...byLine.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([, v]) => v)) {
|
|
1564
|
+
line.sort((a, b) => a.ep.at.x - b.ep.at.x);
|
|
1565
|
+
for (const run of splitBankRuns(line, stubClear, joinClear))
|
|
1566
|
+
emitBank(run);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
eps.forEach((ep, i) => {
|
|
1570
|
+
if (consumed.has(i))
|
|
1571
|
+
return;
|
|
1572
|
+
const o = outward(ep.pin);
|
|
1573
|
+
let len = o.dx !== 0 ? STUB + 2 : STUB;
|
|
1574
|
+
// Fair share of the channel: a stub may never cross the MIDLINE to the
|
|
1575
|
+
// nearest facing foreign pin on its own line, whatever the text pass
|
|
1576
|
+
// wants. Drafting order decides who draws first, and a first-drafted
|
|
1577
|
+
// stub that fills the channel leaves the facing pin no clear rung at
|
|
1578
|
+
// any length — jetson's text-grown 8-unit stub in a 9-unit channel did
|
|
1579
|
+
// exactly that, and the facing GND shipped touching (a refusal).
|
|
1580
|
+
let maxLen = Infinity;
|
|
1581
|
+
for (const opl of placed.values()) {
|
|
1582
|
+
for (const pin of opl.sym.pins) {
|
|
1583
|
+
const fep = `${opl.refDes}.${pin.number}`;
|
|
1584
|
+
if (netByEndpoint.get(fep)?.name === net.name)
|
|
1585
|
+
continue;
|
|
1586
|
+
const p2 = pinAt(opl, pin);
|
|
1587
|
+
if (o.dx !== 0 && sameCoord(p2.y, ep.at.y) && Math.sign(p2.x - ep.at.x) === o.dx) {
|
|
1588
|
+
maxLen = Math.min(maxLen, Math.max(1, Math.floor(Math.abs(p2.x - ep.at.x) / U / 2)));
|
|
1589
|
+
}
|
|
1590
|
+
else if (o.dy !== 0 && sameCoord(p2.x, ep.at.x) && Math.sign(p2.y - ep.at.y) === o.dy) {
|
|
1591
|
+
maxLen = Math.min(maxLen, Math.max(1, Math.floor(Math.abs(p2.y - ep.at.y) / U / 2)));
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (len > maxLen)
|
|
1596
|
+
len = maxLen;
|
|
1597
|
+
const at = (l) => ({ x: ep.at.x + o.dx * l * U, y: ep.at.y + o.dy * l * U });
|
|
1598
|
+
const valueAtOf = (end) => ({
|
|
1599
|
+
x: end.x,
|
|
1600
|
+
y: end.y + (o.dy !== 0 ? o.dy * 3.556 : cls === 'ground' ? 3.556 : -3.556),
|
|
1601
|
+
});
|
|
1602
|
+
// Adjacent power pins collide their value texts two ways, resolved two
|
|
1603
|
+
// ways. The SAME net repeated (a TQFP's VCC pins one row apart) hides
|
|
1604
|
+
// the duplicates: one visible name per cluster carries the same
|
|
1605
|
+
// information. A DIFFERENT net's name may never be hidden, so its
|
|
1606
|
+
// symbol rides its own stub outward, a bounded grid step at a time,
|
|
1607
|
+
// until the text clears — keeping "every gate failure is resolvable
|
|
1608
|
+
// through the IR" true for a collision the IR cannot otherwise reach.
|
|
1609
|
+
let hideValue = false;
|
|
1610
|
+
{
|
|
1611
|
+
const first = valueAtOf(at(len));
|
|
1612
|
+
const firstBox = powerValueBox(net.name, first.x, first.y);
|
|
1613
|
+
if (shownPowerValues.some((p) => p.net === net.name && boundsOverlap(p.box, firstBox))) {
|
|
1614
|
+
hideValue = true;
|
|
1615
|
+
}
|
|
1616
|
+
else {
|
|
1617
|
+
for (let extra = 0; extra < 4; extra++) {
|
|
1618
|
+
const v = valueAtOf(at(len));
|
|
1619
|
+
const b = powerValueBox(net.name, v.x, v.y);
|
|
1620
|
+
if (!shownPowerValues.some((p) => p.net !== net.name && boundsOverlap(p.box, b)))
|
|
1621
|
+
break;
|
|
1622
|
+
if (len + 2 > maxLen)
|
|
1623
|
+
break; // text never buys past the midline
|
|
1624
|
+
const extended = at(len + 2);
|
|
1625
|
+
if (powerBodies.some((bd) => segCrossesBody(ep.at.x, ep.at.y, extended.x, extended.y, bd)))
|
|
1626
|
+
break;
|
|
1627
|
+
len += 2;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* The length chosen above answers a typographic question: where does the
|
|
1633
|
+
* value text stop colliding. It says nothing about where the stub's
|
|
1634
|
+
* ENDPOINT lands, and a power stub that ends on another net's stub is a
|
|
1635
|
+
* shorted rail (#217: cm5_minima put a +5V pin 4 units above a GND pin,
|
|
1636
|
+
* both stubs grew 2 units toward each other, and they met exactly in the
|
|
1637
|
+
* middle — the drawn sheet ties +5V to GND).
|
|
1638
|
+
*
|
|
1639
|
+
* So the text-driven length is only a preference. Try it first, then
|
|
1640
|
+
* lengths either side of it, and take the first whose endpoint touches no
|
|
1641
|
+
* foreign connection point. Shorter is in the ladder deliberately: two
|
|
1642
|
+
* pins facing each other cannot be separated by growing the stub, only by
|
|
1643
|
+
* pulling it back. One unit is the floor — the power symbol still needs
|
|
1644
|
+
* somewhere to sit.
|
|
1645
|
+
*
|
|
1646
|
+
* When nothing clears, keep the preferred length and let the merged-net
|
|
1647
|
+
* gate refuse loudly. Shipping a quiet short is the one outcome barred.
|
|
1648
|
+
*/
|
|
1649
|
+
const ownPinEps = new Set(net.pins);
|
|
1650
|
+
/**
|
|
1651
|
+
* Which bodies a stub of length `l` would cross, by index.
|
|
1652
|
+
*
|
|
1653
|
+
* A pin sits ON its own part's outline, so EVERY length crosses at least
|
|
1654
|
+
* that body, including the preferred one the engine ships today. Treating
|
|
1655
|
+
* any crossing as disqualifying would veto the whole ladder (it did, on
|
|
1656
|
+
* the first attempt at #217). What matters is that moving the stub does
|
|
1657
|
+
* not put it through something the preferred length was already clear of.
|
|
1658
|
+
*/
|
|
1659
|
+
const crossedBy = (l) => {
|
|
1660
|
+
const e = at(l);
|
|
1661
|
+
const out = new Set();
|
|
1662
|
+
powerBodies.forEach((bd, i) => {
|
|
1663
|
+
if (segCrossesBody(ep.at.x, ep.at.y, e.x, e.y, bd))
|
|
1664
|
+
out.add(i);
|
|
1665
|
+
});
|
|
1666
|
+
return out;
|
|
1667
|
+
};
|
|
1668
|
+
const baseCrossed = crossedBy(len);
|
|
1669
|
+
const clearsAt = (l) => {
|
|
1670
|
+
if ([...crossedBy(l)].some((i) => !baseCrossed.has(i)))
|
|
1671
|
+
return false;
|
|
1672
|
+
const e = at(l);
|
|
1673
|
+
return !touchesForeign([{ x1: ep.at.x, y1: ep.at.y, x2: e.x, y2: e.y }], net.name, ownPinEps, {
|
|
1674
|
+
predictStubs: false,
|
|
1675
|
+
});
|
|
1676
|
+
};
|
|
1677
|
+
/**
|
|
1678
|
+
* Whether the value text would still be clear at length `l`. The length
|
|
1679
|
+
* chosen above already answers this for the preferred length; the ladder
|
|
1680
|
+
* has to keep answering it, or a stub moved for electrical reasons drags
|
|
1681
|
+
* its rail name into a neighbouring body (it dragged "+3V3" into R1 on
|
|
1682
|
+
* the pull-up idiom the first time this ladder was written).
|
|
1683
|
+
*
|
|
1684
|
+
* Only a preference: a text collision is a legibility cost the report
|
|
1685
|
+
* names, while a merged net refuses the draft outright. So a rung that is
|
|
1686
|
+
* electrically clear but typographically ugly still beats no rung at all.
|
|
1687
|
+
*/
|
|
1688
|
+
const textClearAt = (l) => {
|
|
1689
|
+
if (hideValue)
|
|
1690
|
+
return true;
|
|
1691
|
+
const v = valueAtOf(at(l));
|
|
1692
|
+
const b = powerValueBox(net.name, v.x, v.y);
|
|
1693
|
+
if (powerBodies.some((bd) => boundsOverlap(b, bd)))
|
|
1694
|
+
return false;
|
|
1695
|
+
return !shownPowerValues.some((p) => p.net !== net.name && boundsOverlap(p.box, b));
|
|
1696
|
+
};
|
|
1697
|
+
if (!clearsAt(len)) {
|
|
1698
|
+
const ladder = [];
|
|
1699
|
+
for (let d = 1; d <= MAX_POWER_STUB_SHIFT; d++) {
|
|
1700
|
+
if (len + d <= maxLen)
|
|
1701
|
+
ladder.push(len + d);
|
|
1702
|
+
if (len - d >= 1)
|
|
1703
|
+
ladder.push(len - d);
|
|
1704
|
+
}
|
|
1705
|
+
// Full retreat, beyond the bounded shift: the TEXT-driven growth above
|
|
1706
|
+
// can carry `len` so far out that every rung within
|
|
1707
|
+
// MAX_POWER_STUB_SHIFT still overlaps the facing pin's stub, and the
|
|
1708
|
+
// electrically clear short lengths sit out of reach (jetson's D2
|
|
1709
|
+
// shipped a 10-unit VCC_IN stub through the facing net's 4-unit stub
|
|
1710
|
+
// and its power symbol that way). Last rungs, so any bounded rung
|
|
1711
|
+
// that clears still wins and existing layouts do not move.
|
|
1712
|
+
for (let l = Math.min(len, STUB + 2); l >= 1; l--) {
|
|
1713
|
+
if (!ladder.includes(l))
|
|
1714
|
+
ladder.push(l);
|
|
1715
|
+
}
|
|
1716
|
+
const freed = ladder.find((l) => clearsAt(l) && textClearAt(l)) ?? ladder.find(clearsAt);
|
|
1717
|
+
if (freed !== undefined)
|
|
1718
|
+
len = freed;
|
|
1719
|
+
}
|
|
1720
|
+
const stubEnd = at(len);
|
|
1721
|
+
const valueAt = valueAtOf(stubEnd);
|
|
1722
|
+
if (!hideValue)
|
|
1723
|
+
shownPowerValues.push({ net: net.name, box: powerValueBox(net.name, valueAt.x, valueAt.y) });
|
|
1724
|
+
addWire(net.name, ep.at.x, ep.at.y, stubEnd.x, stubEnd.y);
|
|
1725
|
+
pwrSeq++;
|
|
1726
|
+
extraSymbols.push({
|
|
1727
|
+
ref: `#PWR${String(pwrSeq).padStart(2, '0')}`,
|
|
1728
|
+
libId: src.libId,
|
|
1729
|
+
value: net.name,
|
|
1730
|
+
footprint: '',
|
|
1731
|
+
at: { x: stubEnd.x, y: stubEnd.y, rot: 0 },
|
|
1732
|
+
refAt: { x: stubEnd.x, y: stubEnd.y },
|
|
1733
|
+
// The bar is drawn on a fixed side of its own pin (rails above, grounds
|
|
1734
|
+
// below), but a stub leaves its pin in whatever direction the pin
|
|
1735
|
+
// faces. Offsetting the value by class alone therefore throws the text
|
|
1736
|
+
// back across the stub and into the part whenever the two disagree —
|
|
1737
|
+
// a rail hanging off a downward pin puts "+5V" on the symbol above it.
|
|
1738
|
+
// The text follows the stub outward, so it always lands on the far
|
|
1739
|
+
// side of the symbol from the part it serves.
|
|
1740
|
+
valueAt,
|
|
1741
|
+
hideRef: true,
|
|
1742
|
+
// the net name IS the flag's meaning: an anonymous bar tells a
|
|
1743
|
+
// reviewer nothing, so the value stays visible like stock power
|
|
1744
|
+
// symbols (the checker verifies it collides with nothing)
|
|
1745
|
+
hideValue,
|
|
1746
|
+
pinNumbers: ['1'],
|
|
1747
|
+
});
|
|
1748
|
+
// the flag's pin sits exactly on the stub END so KiCad's connectivity
|
|
1749
|
+
// (which joins at wire endpoints) sees the power_out driver
|
|
1750
|
+
maybeFlag(i, stubEnd.x, stubEnd.y);
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
// signal nets: local nets wired, everything else labelled at a stub
|
|
1754
|
+
const bodies = [...placed.values()].map((p) => p.body);
|
|
1755
|
+
let wired = 0;
|
|
1756
|
+
let labelled = 0;
|
|
1757
|
+
for (const net of [...signalNets].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1758
|
+
const eps = endpointsOf(net);
|
|
1759
|
+
if (!eps.length)
|
|
1760
|
+
continue;
|
|
1761
|
+
const stubs = eps.map((ep) => {
|
|
1762
|
+
const o = outward(ep.pin);
|
|
1763
|
+
return { ep, end: { x: ep.at.x + o.dx * STUB * U, y: ep.at.y + o.dy * STUB * U }, o };
|
|
1764
|
+
});
|
|
1765
|
+
const groupsTouched = new Set(eps.map((e) => groupOf.get(e.ref)));
|
|
1766
|
+
const spanX = Math.max(...stubs.map((s) => s.end.x)) - Math.min(...stubs.map((s) => s.end.x));
|
|
1767
|
+
const spanY = Math.max(...stubs.map((s) => s.end.y)) - Math.min(...stubs.map((s) => s.end.y));
|
|
1768
|
+
let asWire = groupsTouched.size === 1 && eps.length <= MAX_WIRED_ENDPOINTS && Math.max(spanX, spanY) <= MAX_WIRE_SPAN;
|
|
1769
|
+
if (asWire) {
|
|
1770
|
+
// trunk-and-branch: a vertical trunk with horizontal branches. Several
|
|
1771
|
+
// deterministic trunk positions are tried in order (median stub x, right
|
|
1772
|
+
// of everything, left of everything); the first collision-free routing
|
|
1773
|
+
// wins, and if none exists the net falls back to labels — the engine may
|
|
1774
|
+
// never trip its own wire-through-symbol gate.
|
|
1775
|
+
const xs = stubs.map((s) => s.end.x).sort((a, b) => a - b);
|
|
1776
|
+
const ys = stubs.map((s) => s.end.y);
|
|
1777
|
+
const trunkCandidates = [
|
|
1778
|
+
grid(Math.round(xs[Math.floor(xs.length / 2)] / U)),
|
|
1779
|
+
grid(Math.round(xs[xs.length - 1] / U) + STUB),
|
|
1780
|
+
grid(Math.round(xs[0] / U) - STUB),
|
|
1781
|
+
];
|
|
1782
|
+
let routed = false;
|
|
1783
|
+
for (const trunkX of trunkCandidates) {
|
|
1784
|
+
const candidate = [];
|
|
1785
|
+
for (const s of stubs) {
|
|
1786
|
+
candidate.push({ x1: s.ep.at.x, y1: s.ep.at.y, x2: s.end.x, y2: s.end.y });
|
|
1787
|
+
if (!sameCoord(s.end.x, trunkX))
|
|
1788
|
+
candidate.push({ x1: s.end.x, y1: s.end.y, x2: trunkX, y2: s.end.y });
|
|
1789
|
+
}
|
|
1790
|
+
// the trunk is split at every branch meet: coincident wire ENDPOINTS
|
|
1791
|
+
// are what both KiCad and the geometric netlister join on
|
|
1792
|
+
const meetYs = [...new Map(ys.map((y) => [knum(y), y])).values()].sort((a, b) => a - b);
|
|
1793
|
+
for (let i = 1; i < meetYs.length; i++) {
|
|
1794
|
+
candidate.push({ x1: trunkX, y1: meetYs[i - 1], x2: trunkX, y2: meetYs[i] });
|
|
1795
|
+
}
|
|
1796
|
+
if (candidate.some((c) => bodies.some((b) => segCrossesBody(c.x1, c.y1, c.x2, c.y2, b))))
|
|
1797
|
+
continue;
|
|
1798
|
+
// No candidate segment may touch a foreign connection point (I22,
|
|
1799
|
+
// #204): a trunk routed down a column of neighbouring stub ends
|
|
1800
|
+
// silently merges nets. Mirrors the chain pass's axisClear,
|
|
1801
|
+
// generalized to every candidate segment.
|
|
1802
|
+
if (touchesForeign(candidate, net.name, new Set(net.pins)))
|
|
1803
|
+
continue;
|
|
1804
|
+
for (const c of candidate)
|
|
1805
|
+
addWire(net.name, c.x1, c.y1, c.x2, c.y2);
|
|
1806
|
+
// one label names the wired net (topmost-leftmost wire point): the net
|
|
1807
|
+
// stays identifiable to PINOUT/drift and to a reviewer without a
|
|
1808
|
+
// label-per-pin, matching hand-drafting practice
|
|
1809
|
+
const pts = candidate.flatMap((c) => [
|
|
1810
|
+
{ x: c.x1, y: c.y1 },
|
|
1811
|
+
{ x: c.x2, y: c.y2 },
|
|
1812
|
+
]);
|
|
1813
|
+
pts.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
1814
|
+
labels.push({ name: net.name, x: pts[0].x, y: pts[0].y, rot: 0 });
|
|
1815
|
+
wiredLabels.push({ label: labels.length - 1, pts, segs: candidate.map((c) => ({ ...c })) });
|
|
1816
|
+
wired++;
|
|
1817
|
+
if (eps.length > 2) {
|
|
1818
|
+
for (const s of stubs) {
|
|
1819
|
+
const meet = sameCoord(s.end.x, trunkX) ? s.end : { x: trunkX, y: s.end.y };
|
|
1820
|
+
if (meet.y > Math.min(...ys) && meet.y < Math.max(...ys))
|
|
1821
|
+
junctions.push(meet);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
routed = true;
|
|
1825
|
+
break;
|
|
1826
|
+
}
|
|
1827
|
+
if (!routed)
|
|
1828
|
+
asWire = false;
|
|
1829
|
+
}
|
|
1830
|
+
if (!asWire) {
|
|
1831
|
+
for (const s of stubs) {
|
|
1832
|
+
// A stub is a wire too: its endpoint resting on a foreign net's wire
|
|
1833
|
+
// or connection point merges nets exactly like a trunk would (the
|
|
1834
|
+
// cap-to-ground drop's 2-unit stub ended on the neighbouring power
|
|
1835
|
+
// pin's stub interior — I22's third face). Grow the stub a grid unit
|
|
1836
|
+
// at a time until the endpoint is clear; the interior then CROSSES
|
|
1837
|
+
// the foreign wire mid-segment, which does not connect. If no length
|
|
1838
|
+
// clears, emit the plain stub and let the merged-net gate refuse
|
|
1839
|
+
// loudly rather than ship the contact.
|
|
1840
|
+
let end = s.end;
|
|
1841
|
+
const own = new Set(net.pins);
|
|
1842
|
+
// Rungs in preference order: the classic 0..2 extensions first so
|
|
1843
|
+
// clear cases stay byte-identical, then deeper extensions, then a
|
|
1844
|
+
// one-unit retreat. A stub that ships with NO clear rung still ends
|
|
1845
|
+
// touching a foreign wire and the merge gate refuses the draft, so
|
|
1846
|
+
// every extra rung here is a board that drafts instead of refusing
|
|
1847
|
+
// (jetson's twelve wire-contact refusals were exactly this fallback).
|
|
1848
|
+
for (const len of [STUB, STUB + 1, STUB + 2, STUB + 3, STUB + 4, STUB + 5, STUB + 6, 1]) {
|
|
1849
|
+
const cand = {
|
|
1850
|
+
x: s.ep.at.x + s.o.dx * len * U,
|
|
1851
|
+
y: s.ep.at.y + s.o.dy * len * U,
|
|
1852
|
+
};
|
|
1853
|
+
if (!touchesForeign([{ x1: s.ep.at.x, y1: s.ep.at.y, x2: cand.x, y2: cand.y }], net.name, own)) {
|
|
1854
|
+
end = cand;
|
|
1855
|
+
break;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
addWire(net.name, s.ep.at.x, s.ep.at.y, end.x, end.y);
|
|
1859
|
+
// labels are always horizontal (drafting standard): leftward pins read
|
|
1860
|
+
// outward to the left, everything else extends to the right
|
|
1861
|
+
labels.push({ name: net.name, x: end.x, y: end.y, rot: s.o.dx === -1 ? 180 : 0 });
|
|
1862
|
+
stubbedLabels.push({ label: labels.length - 1, wire: wires.length - 1, o: s.o, pins: net.pins });
|
|
1863
|
+
labelled++;
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
// ---------- member symbols with collision-free text slots ----------
|
|
1868
|
+
// Built BEFORE the label de-collision pass so the pass can treat every
|
|
1869
|
+
// visible ref/value text as an obstacle: the checker measures text-vs-text
|
|
1870
|
+
// collisions at error severity, so a box the checker will see must be a box
|
|
1871
|
+
// the avoider saw first.
|
|
1872
|
+
const emitSymbols = [];
|
|
1873
|
+
/** Emit entry with its placement, for the slot-refinement pass below (two
|
|
1874
|
+
* unit instances share one refdes, so `ref` alone no longer keys `placed`). */
|
|
1875
|
+
const emitPairs = [];
|
|
1876
|
+
/** What KiCad renders for the reference: a multi-unit instance shows its
|
|
1877
|
+
* unit letter (U1A, U1B), so width metrics must measure the rendered text. */
|
|
1878
|
+
const displayRefOf = (pl) => pl.unit !== null ? `${pl.refDes}${String.fromCharCode(64 + Math.min(pl.unit, 26))}` : pl.refDes;
|
|
1879
|
+
for (const [, pl] of [...placed.entries()].sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }))) {
|
|
1880
|
+
const pinSides = new Set(pl.sym.pins.map((p) => {
|
|
1881
|
+
const o = outward(p);
|
|
1882
|
+
return o.dx === -1 ? 'left' : o.dx === 1 ? 'right' : o.dy === -1 ? 'top' : 'bottom';
|
|
1883
|
+
}));
|
|
1884
|
+
const cy = (pl.body.minY + pl.body.maxY) / 2;
|
|
1885
|
+
const cx = (pl.body.minX + pl.body.maxX) / 2;
|
|
1886
|
+
const textW = Math.max(displayRefOf(pl).length, pl.part.value.length) * 0.8 * 1.27;
|
|
1887
|
+
let refAt;
|
|
1888
|
+
let valueAt;
|
|
1889
|
+
if (!pinSides.has('top')) {
|
|
1890
|
+
refAt = { x: cx, y: pl.body.minY - 2.54 };
|
|
1891
|
+
// value stacks above the ref when the bottom also carries pins, and sits
|
|
1892
|
+
// below the body otherwise
|
|
1893
|
+
valueAt = pinSides.has('bottom') ? { x: cx, y: pl.body.minY - 5.08 } : { x: cx, y: pl.body.maxY + 2.54 };
|
|
1894
|
+
}
|
|
1895
|
+
else if (pl.body.maxX - pl.body.minX >= textW + 2.54 &&
|
|
1896
|
+
pl.body.maxY - pl.body.minY >= 7.62) {
|
|
1897
|
+
// pins on top AND a body big enough to hold its own name: a TQFP-class
|
|
1898
|
+
// part carries pins on all four sides, so every outside slot lands on
|
|
1899
|
+
// some pin's stub or label; the body interior is the one guaranteed-free
|
|
1900
|
+
// area, and it is where KiCad's own large symbols put their text
|
|
1901
|
+
refAt = { x: cx, y: cy - 1.27 };
|
|
1902
|
+
valueAt = { x: cx, y: cy + 1.27 };
|
|
1903
|
+
}
|
|
1904
|
+
else {
|
|
1905
|
+
refAt = { x: pl.body.maxX + textW / 2 + 1.27, y: cy - 1.27 };
|
|
1906
|
+
valueAt = { x: pl.body.maxX + textW / 2 + 1.27, y: cy + 1.27 };
|
|
1907
|
+
}
|
|
1908
|
+
const sym = {
|
|
1909
|
+
ref: pl.refDes,
|
|
1910
|
+
libId: pl.sym.libId,
|
|
1911
|
+
value: pl.part.value,
|
|
1912
|
+
footprint: pl.part.footprint ?? '',
|
|
1913
|
+
at: { x: pl.x, y: pl.y, rot: 0 },
|
|
1914
|
+
refAt,
|
|
1915
|
+
valueAt,
|
|
1916
|
+
pinNumbers: pl.sym.pins.map((p) => p.number),
|
|
1917
|
+
...(pl.unit !== null ? { unit: pl.unit } : {}),
|
|
1918
|
+
};
|
|
1919
|
+
emitSymbols.push(sym);
|
|
1920
|
+
emitPairs.push({ sym, pl });
|
|
1921
|
+
libSymbols.set(pl.sym.libId, pl.sym.sourceText);
|
|
1922
|
+
}
|
|
1923
|
+
/** Centered text box, matching the checker's `textBounds` metrics. */
|
|
1924
|
+
const centeredTextBox = (s, x, y) => {
|
|
1925
|
+
const w = Math.max(1, s.length) * LABEL_ADVANCE * LABEL_HEIGHT;
|
|
1926
|
+
return { minX: x - w / 2, minY: y - LABEL_HEIGHT / 2, maxX: x + w / 2, maxY: y + LABEL_HEIGHT / 2 };
|
|
1927
|
+
};
|
|
1928
|
+
/** Text-box-vs-segment overlap; hoisted so slot refinement below and the
|
|
1929
|
+
* label pass share one metric. */
|
|
1930
|
+
const segHitsBoxEarly = (w, b) => Math.min(w.x1, w.x2) < b.maxX - 0.01 &&
|
|
1931
|
+
Math.max(w.x1, w.x2) > b.minX + 0.01 &&
|
|
1932
|
+
Math.min(w.y1, w.y2) < b.maxY - 0.01 &&
|
|
1933
|
+
Math.max(w.y1, w.y2) > b.minY + 0.01;
|
|
1934
|
+
// ---------- symbol-field slot refinement (I23, #210) ----------
|
|
1935
|
+
// The heuristic slots above consult nothing: attempt-07 ended ERC-clean
|
|
1936
|
+
// with 8 error-severity findings that were exactly these ref/value fields
|
|
1937
|
+
// sitting on wires and neighbouring bodies, with no IR lever to move them.
|
|
1938
|
+
// Re-slot each dirty pair down a deterministic ladder; the first slot whose
|
|
1939
|
+
// boxes clear every wire, every FOREIGN body, and all field text placed so
|
|
1940
|
+
// far wins. Where the heuristic is already clean the output is
|
|
1941
|
+
// byte-identical; where nothing clears, the heuristic stays so the checker
|
|
1942
|
+
// still reports the collision honestly.
|
|
1943
|
+
{
|
|
1944
|
+
const fieldBoxes = extraSymbols
|
|
1945
|
+
.filter((s) => !s.hideValue)
|
|
1946
|
+
.map((s) => centeredTextBox(s.value, s.valueAt.x, s.valueAt.y));
|
|
1947
|
+
for (const { sym, pl } of emitPairs) {
|
|
1948
|
+
const dref = displayRefOf(pl);
|
|
1949
|
+
const cx = (pl.body.minX + pl.body.maxX) / 2;
|
|
1950
|
+
const cy = (pl.body.minY + pl.body.maxY) / 2;
|
|
1951
|
+
const textW = Math.max(dref.length, sym.value.length) * 0.8 * 1.27;
|
|
1952
|
+
const pairClear = (r, v) => {
|
|
1953
|
+
for (const b of [centeredTextBox(dref, r.x, r.y), centeredTextBox(sym.value, v.x, v.y)]) {
|
|
1954
|
+
if (wires.some((w) => segHitsBoxEarly(w, b)))
|
|
1955
|
+
return false;
|
|
1956
|
+
for (const op of placed.values()) {
|
|
1957
|
+
if (op !== pl && boundsOverlap(b, op.body))
|
|
1958
|
+
return false;
|
|
1959
|
+
}
|
|
1960
|
+
if (fieldBoxes.some((t) => boundsOverlap(t, b)))
|
|
1961
|
+
return false;
|
|
1962
|
+
}
|
|
1963
|
+
return true;
|
|
1964
|
+
};
|
|
1965
|
+
if (!pairClear(sym.refAt, sym.valueAt)) {
|
|
1966
|
+
const ladder = [];
|
|
1967
|
+
for (const extra of [0, 2.54]) {
|
|
1968
|
+
ladder.push([{ x: cx, y: pl.body.maxY + 2.54 + extra }, { x: cx, y: pl.body.maxY + 5.08 + extra }], [{ x: cx, y: pl.body.minY - 5.08 - extra }, { x: cx, y: pl.body.minY - 2.54 - extra }], [
|
|
1969
|
+
{ x: pl.body.maxX + textW / 2 + 1.27 + extra, y: cy - 1.27 },
|
|
1970
|
+
{ x: pl.body.maxX + textW / 2 + 1.27 + extra, y: cy + 1.27 },
|
|
1971
|
+
], [
|
|
1972
|
+
{ x: pl.body.minX - textW / 2 - 1.27 - extra, y: cy - 1.27 },
|
|
1973
|
+
{ x: pl.body.minX - textW / 2 - 1.27 - extra, y: cy + 1.27 },
|
|
1974
|
+
]);
|
|
1975
|
+
}
|
|
1976
|
+
for (const [r, v] of ladder) {
|
|
1977
|
+
if (pairClear(r, v)) {
|
|
1978
|
+
sym.refAt = r;
|
|
1979
|
+
sym.valueAt = v;
|
|
1980
|
+
break;
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
fieldBoxes.push(centeredTextBox(dref, sym.refAt.x, sym.refAt.y), centeredTextBox(sym.value, sym.valueAt.x, sym.valueAt.y));
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
/** Every visible ref/value text the checker will measure. */
|
|
1988
|
+
const textObstacles = [
|
|
1989
|
+
...emitPairs.flatMap(({ sym: s, pl }) => [centeredTextBox(displayRefOf(pl), s.refAt.x, s.refAt.y), centeredTextBox(s.value, s.valueAt.x, s.valueAt.y)]),
|
|
1990
|
+
...extraSymbols.filter((s) => !s.hideValue).map((s) => centeredTextBox(s.value, s.valueAt.x, s.valueAt.y)),
|
|
1991
|
+
];
|
|
1992
|
+
// Nets are drafted in name order, so a net can only avoid what is already on
|
|
1993
|
+
// the sheet: "COMP" cannot see the trunk "COMP_Z" is about to run through the
|
|
1994
|
+
// very point its label occupies. This pass runs once the routing is complete
|
|
1995
|
+
// and walks each stub-anchored label outward a grid unit at a time until its
|
|
1996
|
+
// text box clears every foreign wire, body, and visible ref/value text. The
|
|
1997
|
+
// anchor rides the stub it extends, so the label stays attached and
|
|
1998
|
+
// connectivity never changes.
|
|
1999
|
+
const segHitsBox = (w, b) => Math.min(w.x1, w.x2) < b.maxX - 0.01 &&
|
|
2000
|
+
Math.max(w.x1, w.x2) > b.minX + 0.01 &&
|
|
2001
|
+
Math.min(w.y1, w.y2) < b.maxY - 0.01 &&
|
|
2002
|
+
Math.max(w.y1, w.y2) > b.minY + 0.01;
|
|
2003
|
+
/** Is (x, y) on the (axis-aligned) segment, endpoints included? */
|
|
2004
|
+
const segContains = (w, x, y) => x >= Math.min(w.x1, w.x2) - 0.01 &&
|
|
2005
|
+
x <= Math.max(w.x1, w.x2) + 0.01 &&
|
|
2006
|
+
y >= Math.min(w.y1, w.y2) - 0.01 &&
|
|
2007
|
+
y <= Math.max(w.y1, w.y2) + 0.01 &&
|
|
2008
|
+
(Math.abs(w.x1 - w.x2) < 0.01 ? Math.abs(x - w.x1) <= 0.01 : Math.abs(y - w.y1) <= 0.01);
|
|
2009
|
+
// Wired-net labels first: the single label naming a wired run used to be
|
|
2010
|
+
// pinned at the topmost-leftmost wire point with no clearance check, and on
|
|
2011
|
+
// a drop chain that point is the anchor corner — the text immediately lies
|
|
2012
|
+
// across the chain's vertical run. The label may sit at ANY point of its own
|
|
2013
|
+
// net's wires, so walk the run's points and take the first whose text box
|
|
2014
|
+
// clears everything the checker will measure; wires the point itself lies on
|
|
2015
|
+
// are the label's own attachment and never count as collisions.
|
|
2016
|
+
for (const rec of wiredLabels) {
|
|
2017
|
+
const lb = labels[rec.label];
|
|
2018
|
+
const clearWired = (x, y) => {
|
|
2019
|
+
const box = labelTextBox(lb.name, x, y, 0);
|
|
2020
|
+
if (bodies.some((b) => boundsOverlap(box, b)))
|
|
2021
|
+
return false;
|
|
2022
|
+
if (textObstacles.some((b) => boundsOverlap(box, b)))
|
|
2023
|
+
return false;
|
|
2024
|
+
if (wires.some((w) => !segContains(w, x, y) && segHitsBox(w, box)))
|
|
2025
|
+
return false;
|
|
2026
|
+
return !labels.some((o, i) => i !== rec.label && o.name !== lb.name && boundsOverlap(box, labelTextBox(o.name, o.x, o.y, o.rot)));
|
|
2027
|
+
};
|
|
2028
|
+
if (clearWired(lb.x, lb.y))
|
|
2029
|
+
continue;
|
|
2030
|
+
const alt = rec.pts.find((p) => clearWired(p.x, p.y));
|
|
2031
|
+
if (alt) {
|
|
2032
|
+
lb.x = alt.x;
|
|
2033
|
+
lb.y = alt.y;
|
|
2034
|
+
continue;
|
|
2035
|
+
}
|
|
2036
|
+
// No segment endpoint clears, but the label may sit at ANY point of its
|
|
2037
|
+
// own net's wires: walk the interior grid points of each segment too
|
|
2038
|
+
// (#220 phase 2). Endpoints stay the first choice so a run that used to
|
|
2039
|
+
// clear keeps its exact label point.
|
|
2040
|
+
//
|
|
2041
|
+
// Walk `segs`, never `pts`: `pts` is sorted for anchor preference, so
|
|
2042
|
+
// pairing it up interpolates between two points that share no wire and
|
|
2043
|
+
// anchors the label in open sheet, where KiCad attaches nothing and the
|
|
2044
|
+
// net silently carries whatever name KiCad invents for it instead of the
|
|
2045
|
+
// one the IR gave it. Three corpus nets shipped exactly that way —
|
|
2046
|
+
// Net-(F201-Pad1), Net-(U8-BIN) and Net-(U8-RIN), each anchored on no
|
|
2047
|
+
// wire of its own run.
|
|
2048
|
+
const inner = interiorGridPoints(rec.segs, U).find((p) => clearWired(p.x, p.y));
|
|
2049
|
+
if (inner) {
|
|
2050
|
+
lb.x = inner.x;
|
|
2051
|
+
lb.y = inner.y;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
for (const rec of stubbedLabels) {
|
|
2055
|
+
const lb = labels[rec.label];
|
|
2056
|
+
const stub = wires[rec.wire];
|
|
2057
|
+
const clearAt = (x, y, rot = lb.rot) => {
|
|
2058
|
+
const box = labelTextBox(lb.name, x, y, rot);
|
|
2059
|
+
if (bodies.some((b) => boundsOverlap(box, b)))
|
|
2060
|
+
return false;
|
|
2061
|
+
if (textObstacles.some((b) => boundsOverlap(box, b)))
|
|
2062
|
+
return false;
|
|
2063
|
+
if (wires.some((w, i) => i !== rec.wire && segHitsBox(w, box)))
|
|
2064
|
+
return false;
|
|
2065
|
+
// Foreign labels are part of what a label must clear, not just bodies and
|
|
2066
|
+
// wires. Without this the pass declares a point clear that another net's
|
|
2067
|
+
// label already holds, both labels stay put, and `findMergedNets` then
|
|
2068
|
+
// refuses the draft for a collision the avoider was never looking for —
|
|
2069
|
+
// an engine state no IR can steer out of, because the IR does not choose
|
|
2070
|
+
// coordinates. Read live from `labels`, so already-nudged neighbours are
|
|
2071
|
+
// seen at their final positions and immovable wired-net labels (which
|
|
2072
|
+
// carry no stub to ride) are seen at all.
|
|
2073
|
+
return !labels.some((o, i) => i !== rec.label &&
|
|
2074
|
+
o.name !== lb.name &&
|
|
2075
|
+
boundsOverlap(box, labelTextBox(o.name, o.x, o.y, o.rot)));
|
|
2076
|
+
};
|
|
2077
|
+
/**
|
|
2078
|
+
* Does another net's label sit on exactly this point? That is the fatal
|
|
2079
|
+
* case — KiCad fuses the two nets — as opposed to merely overlapping text.
|
|
2080
|
+
*/
|
|
2081
|
+
const mergesAt = (x, y) => labels.some((o, i) => i !== rec.label && o.name !== lb.name && sameCoord(o.x, x) && sameCoord(o.y, y));
|
|
2082
|
+
/**
|
|
2083
|
+
* Riding a label outward drags the stub's ENDPOINT with it (`rideTo` moves
|
|
2084
|
+
* both). Every test above this asks a typographic question — does the text
|
|
2085
|
+
* box clear a body, a wire, another label — and none asks the electrical
|
|
2086
|
+
* one, so the pass could answer "the text is clear here" about a point that
|
|
2087
|
+
* sits on another net's wire and silently tie the two together (#217:
|
|
2088
|
+
* interf_u rode /PC-RD's stub from 2 units to 4 and parked its end on
|
|
2089
|
+
* /WR_REG's trunk).
|
|
2090
|
+
*
|
|
2091
|
+
* A candidate must therefore be electrically clear as well as legible.
|
|
2092
|
+
* The stub's own wire needs no exclusion: `touchesForeign` skips wires of
|
|
2093
|
+
* the same net, and this one is the net's own.
|
|
2094
|
+
*/
|
|
2095
|
+
const wireClearAt = (x, y) => !touchesForeign([{ x1: stub.x1, y1: stub.y1, x2: x, y2: y }], lb.name, new Set(rec.pins), {
|
|
2096
|
+
predictStubs: false,
|
|
2097
|
+
});
|
|
2098
|
+
const rideTo = (x, y) => {
|
|
2099
|
+
stub.x2 = x;
|
|
2100
|
+
stub.y2 = y;
|
|
2101
|
+
lb.x = x;
|
|
2102
|
+
lb.y = y;
|
|
2103
|
+
};
|
|
2104
|
+
if (clearAt(lb.x, lb.y))
|
|
2105
|
+
continue;
|
|
2106
|
+
/** Candidate points along the stub, nearest first. */
|
|
2107
|
+
const candidates = [];
|
|
2108
|
+
for (let extra = 1; extra <= MAX_LABEL_NUDGE; extra++) {
|
|
2109
|
+
const x = lb.x + rec.o.dx * extra * U;
|
|
2110
|
+
const y = lb.y + rec.o.dy * extra * U;
|
|
2111
|
+
// an extension that would run the stub through a symbol is no better
|
|
2112
|
+
// than the collision it fixes
|
|
2113
|
+
if (bodies.some((b) => segCrossesBody(stub.x1, stub.y1, x, y, b)))
|
|
2114
|
+
break;
|
|
2115
|
+
candidates.push({ x, y });
|
|
2116
|
+
}
|
|
2117
|
+
// Last rung: pull the stub BACK to one unit. Riding outward moves a
|
|
2118
|
+
// facing pair's text toward each other, so two long names in a tight
|
|
2119
|
+
// channel can never separate that way — but each is under a grid unit
|
|
2120
|
+
// deep into the other, and one unit of retreat clears it (#220 phase 2).
|
|
2121
|
+
{
|
|
2122
|
+
const shortened = { x: stub.x1 + rec.o.dx * U, y: stub.y1 + rec.o.dy * U };
|
|
2123
|
+
if (Math.abs(lb.x - stub.x1) + Math.abs(lb.y - stub.y1) > U + 0.01)
|
|
2124
|
+
candidates.push(shortened);
|
|
2125
|
+
}
|
|
2126
|
+
const clear = candidates.find((c) => clearAt(c.x, c.y) && wireClearAt(c.x, c.y));
|
|
2127
|
+
if (clear) {
|
|
2128
|
+
rideTo(clear.x, clear.y);
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2131
|
+
// A vertical stub may flip its text to the other side of the anchor: a
|
|
2132
|
+
// trunk running parallel beside the stub blocks every rung on one side
|
|
2133
|
+
// while the other side is empty (#220 phase 2). The anchor point itself is
|
|
2134
|
+
// unchanged, so this is purely typographic — but the flipped box no longer
|
|
2135
|
+
// overlaps a same-point foreign label, so the merge check must be explicit.
|
|
2136
|
+
if (rec.o.dy !== 0) {
|
|
2137
|
+
const flipRot = lb.rot === 0 ? 180 : 0;
|
|
2138
|
+
const flip = [{ x: lb.x, y: lb.y }, ...candidates].find((c) => clearAt(c.x, c.y, flipRot) &&
|
|
2139
|
+
!mergesAt(c.x, c.y) &&
|
|
2140
|
+
(sameCoord(c.x, lb.x) && sameCoord(c.y, lb.y) ? true : wireClearAt(c.x, c.y)));
|
|
2141
|
+
if (flip) {
|
|
2142
|
+
lb.rot = flipRot;
|
|
2143
|
+
rideTo(flip.x, flip.y);
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
// Nothing fully clear within the nudge budget. Overlapping text is a
|
|
2148
|
+
// legibility cost the sheet can carry and the report will name; a shared
|
|
2149
|
+
// point is a merged net and refuses the whole draft. So when the label is
|
|
2150
|
+
// currently ON another net's point, take the nearest candidate that at
|
|
2151
|
+
// least breaks the coincidence — trading a refusal for a flagged blemish.
|
|
2152
|
+
// A label that merely overlaps is left alone: moving it would buy nothing
|
|
2153
|
+
// and the emitted sheet must stay a function of the IR alone.
|
|
2154
|
+
if (!mergesAt(lb.x, lb.y))
|
|
2155
|
+
continue;
|
|
2156
|
+
// Same precedence as above, one rung down: this is already the consolation
|
|
2157
|
+
// move for a label sitting on another net's point, so it may accept
|
|
2158
|
+
// overlapping text, but it still may not trade one merge for another.
|
|
2159
|
+
const unmerged = candidates.find((c) => !mergesAt(c.x, c.y) && wireClearAt(c.x, c.y));
|
|
2160
|
+
if (unmerged)
|
|
2161
|
+
rideTo(unmerged.x, unmerged.y);
|
|
2162
|
+
}
|
|
2163
|
+
// ---------- power-value sweep (#220 phase 2) ----------
|
|
2164
|
+
// The power pass placed its value text before any signal label existed, and
|
|
2165
|
+
// the label ride above can fail to clear in a dense row — whichever mover
|
|
2166
|
+
// ran last was blind to the other, and cm5_minima's residual error findings
|
|
2167
|
+
// were exactly "#PWR Value and label X overlap". The value text is the one
|
|
2168
|
+
// item on the sheet with no electrical meaning, so it moves LAST, with the
|
|
2169
|
+
// finished drawing as its obstacle set: slide it outward along its stub
|
|
2170
|
+
// axis, then allow a small lateral step, to the first slot the checker will
|
|
2171
|
+
// measure as clean. Nothing clear keeps the placed slot so the report stays
|
|
2172
|
+
// honest, and a value that is already clean does not move at all.
|
|
2173
|
+
{
|
|
2174
|
+
const labelBoxesFinal = labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot));
|
|
2175
|
+
const memberText = emitPairs.flatMap(({ sym: s, pl }) => [
|
|
2176
|
+
centeredTextBox(displayRefOf(pl), s.refAt.x, s.refAt.y),
|
|
2177
|
+
centeredTextBox(s.value, s.valueAt.x, s.valueAt.y),
|
|
2178
|
+
]);
|
|
2179
|
+
const valueEntries = extraSymbols.filter((s) => !s.hideValue);
|
|
2180
|
+
const liveBoxes = new Map(valueEntries.map((s) => [s, powerValueBox(s.value, s.valueAt.x, s.valueAt.y)]));
|
|
2181
|
+
const clearFor = (self, b) => !bodies.some((bd) => boundsOverlap(b, bd)) &&
|
|
2182
|
+
!wires.some((w) => segHitsBoxEarly(w, b)) &&
|
|
2183
|
+
!labelBoxesFinal.some((lb) => boundsOverlap(lb, b)) &&
|
|
2184
|
+
!memberText.some((t) => boundsOverlap(t, b)) &&
|
|
2185
|
+
![...liveBoxes].some(([o, ob]) => o !== self && boundsOverlap(ob, b));
|
|
2186
|
+
for (const s of valueEntries) {
|
|
2187
|
+
if (clearFor(s, liveBoxes.get(s)))
|
|
2188
|
+
continue;
|
|
2189
|
+
// outward = the side of the symbol the text was already offset to
|
|
2190
|
+
const dir = Math.sign(s.valueAt.y - s.at.y) || -1;
|
|
2191
|
+
const cands = [];
|
|
2192
|
+
for (let k = 1; k <= 8; k++)
|
|
2193
|
+
cands.push({ x: s.valueAt.x, y: s.valueAt.y + dir * k * U });
|
|
2194
|
+
for (let k = 0; k <= 8; k++) {
|
|
2195
|
+
for (const lx of [U, -U, 2 * U, -2 * U, 3 * U, -3 * U, 4 * U, -4 * U]) {
|
|
2196
|
+
cands.push({ x: s.valueAt.x + lx, y: s.valueAt.y + dir * k * U });
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
const found = cands.find((c) => clearFor(s, powerValueBox(s.value, c.x, c.y)));
|
|
2200
|
+
if (found) {
|
|
2201
|
+
s.valueAt = { x: found.x, y: found.y };
|
|
2202
|
+
liveBoxes.set(s, powerValueBox(s.value, found.x, found.y));
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
// junctions: any point where three or more wire ends meet
|
|
2207
|
+
const endCount = new Map();
|
|
2208
|
+
for (const w of wires) {
|
|
2209
|
+
for (const [x, y] of [[w.x1, w.y1], [w.x2, w.y2]]) {
|
|
2210
|
+
const k = pointKey(x, y);
|
|
2211
|
+
const e = endCount.get(k) ?? { x, y, n: 0 };
|
|
2212
|
+
e.n++;
|
|
2213
|
+
endCount.set(k, e);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
for (const e of endCount.values())
|
|
2217
|
+
if (e.n >= 3)
|
|
2218
|
+
junctions.push({ x: e.x, y: e.y });
|
|
2219
|
+
const uniqJunctions = [...new Map(junctions.map((j) => [pointKey(j.x, j.y), j])).values()];
|
|
2220
|
+
// no-connect markers (design D6a); a common pin's marker lands on every
|
|
2221
|
+
// placed appearance, mirroring how the wiring passes treat such pins
|
|
2222
|
+
const noConnects = [];
|
|
2223
|
+
for (const ep of intent.noConnect ?? []) {
|
|
2224
|
+
const m = /^([^.]+)\.(.+)$/.exec(ep);
|
|
2225
|
+
if (!m)
|
|
2226
|
+
continue;
|
|
2227
|
+
for (const inst of expandEp(m[1], m[2])) {
|
|
2228
|
+
const pl = placed.get(inst.key);
|
|
2229
|
+
const pin = pl?.sym.pins.find((p) => p.number === m[2]);
|
|
2230
|
+
if (pl && pin)
|
|
2231
|
+
noConnects.push(pinAt(pl, pin));
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
// ---------- sheet: content-derived paper, balanced placement ----------
|
|
2235
|
+
const allX = [...groupRects.map((r) => r.x1), ...groupRects.map((r) => r.x2)];
|
|
2236
|
+
const allY = [...groupRects.map((r) => r.y1), ...groupRects.map((r) => r.y2)];
|
|
2237
|
+
const contentW = allX.length ? Math.max(...allX) - Math.min(...allX) : 0;
|
|
2238
|
+
const contentH = allY.length ? Math.max(...allY) - Math.min(...allY) : 0;
|
|
2239
|
+
// The sheet was already decided by the wrap-and-band pass above: `fit` names
|
|
2240
|
+
// the smallest candidate the final group rects fit (or the largest, noted,
|
|
2241
|
+
// when nothing holds them). Re-deriving it from content here could only
|
|
2242
|
+
// disagree with the budget the columns were banded to.
|
|
2243
|
+
const paper = fit.paper;
|
|
2244
|
+
// offset so content sits centered in the usable area (whitespace balance,
|
|
2245
|
+
// design D11), snapped to the grid so origins stay grid-true
|
|
2246
|
+
const minX = allX.length ? Math.min(...allX) : 0;
|
|
2247
|
+
const minY = allY.length ? Math.min(...allY) : 0;
|
|
2248
|
+
const availW = paper.w - 2 * FRAME;
|
|
2249
|
+
const availH = paper.h - 2 * FRAME - TITLE_STRIP;
|
|
2250
|
+
let dx = grid(Math.round((FRAME + Math.max(0, (availW - contentW) / 2) - minX) / U));
|
|
2251
|
+
let dy = grid(Math.round((FRAME + 4 * U + Math.max(0, (availH - contentH) / 2) - minY) / U));
|
|
2252
|
+
// The group rects measure bodies plus margins; label TEXT extends past them
|
|
2253
|
+
// at the sheet-facing edges, and on a sheet banded near the full usable
|
|
2254
|
+
// width the centered offset leaves that text outside the frame (#220
|
|
2255
|
+
// phase 1). Clamp the shift against the true extent, label boxes included:
|
|
2256
|
+
// a whole-unit correction keeps the grid, fires only when text would cross
|
|
2257
|
+
// the frame, and an extent wider than the window keeps the centered offset
|
|
2258
|
+
// (that overflow was already noted by the fit pass).
|
|
2259
|
+
const textBoxes = [
|
|
2260
|
+
...labels.map((l) => labelTextBox(l.name, l.x, l.y, l.rot)),
|
|
2261
|
+
// power VALUE text is measured by the checker too, and the sweep above
|
|
2262
|
+
// may have slid it past its group rect's margin (pic_programmer put a
|
|
2263
|
+
// rail name 1 mm over the top edge of a compacted sheet)
|
|
2264
|
+
...extraSymbols.filter((s) => !s.hideValue).map((s) => powerValueBox(s.value, s.valueAt.x, s.valueAt.y)),
|
|
2265
|
+
];
|
|
2266
|
+
const fullMinX = Math.min(minX, ...textBoxes.map((b) => b.minX));
|
|
2267
|
+
const fullMaxX = Math.max(minX + contentW, ...textBoxes.map((b) => b.maxX));
|
|
2268
|
+
const fullMinY = Math.min(minY, ...textBoxes.map((b) => b.minY));
|
|
2269
|
+
const fullMaxY = Math.max(minY + contentH, ...textBoxes.map((b) => b.maxY));
|
|
2270
|
+
// Both edges are corrected, far edge first, so the whole-unit rounding of
|
|
2271
|
+
// the far-edge shift can never leave the near edge (the checker-visible
|
|
2272
|
+
// frame line) outside: the near-edge correction runs last and wins. When
|
|
2273
|
+
// the span nearly fills the window, the far edge may keep up to one unit
|
|
2274
|
+
// of overhang into the engine's conservative title strip; the strip is
|
|
2275
|
+
// wider than the checker's reserved corner, so that overhang is invisible.
|
|
2276
|
+
const clampShift = (d, lo0, hi0, lo, hi) => {
|
|
2277
|
+
if (hi0 - lo0 > hi - lo)
|
|
2278
|
+
return d;
|
|
2279
|
+
if (hi0 + d > hi)
|
|
2280
|
+
d -= Math.ceil((hi0 + d - hi) / U - 1e-9) * U;
|
|
2281
|
+
if (lo0 + d < lo)
|
|
2282
|
+
d += Math.ceil((lo - lo0 - d) / U - 1e-9) * U;
|
|
2283
|
+
return d;
|
|
2284
|
+
};
|
|
2285
|
+
dx = clampShift(dx, fullMinX, fullMaxX, FRAME, paper.w - FRAME);
|
|
2286
|
+
// the bottom edge is the engine's own usable bottom, ABOVE the title strip:
|
|
2287
|
+
// content that fills the sheet's height exactly would otherwise carry the
|
|
2288
|
+
// centering pass's 4-unit downward offset into the reserved corner
|
|
2289
|
+
dy = clampShift(dy, fullMinY, fullMaxY, FRAME, paper.h - FRAME - TITLE_STRIP);
|
|
2290
|
+
const shift = (o) => {
|
|
2291
|
+
if (o.x !== undefined)
|
|
2292
|
+
o.x += dx;
|
|
2293
|
+
if (o.y !== undefined)
|
|
2294
|
+
o.y += dy;
|
|
2295
|
+
if (o.x1 !== undefined)
|
|
2296
|
+
o.x1 += dx;
|
|
2297
|
+
if (o.y1 !== undefined)
|
|
2298
|
+
o.y1 += dy;
|
|
2299
|
+
if (o.x2 !== undefined)
|
|
2300
|
+
o.x2 += dx;
|
|
2301
|
+
if (o.y2 !== undefined)
|
|
2302
|
+
o.y2 += dy;
|
|
2303
|
+
return o;
|
|
2304
|
+
};
|
|
2305
|
+
for (const s of [...emitSymbols, ...extraSymbols]) {
|
|
2306
|
+
s.at.x += dx;
|
|
2307
|
+
s.at.y += dy;
|
|
2308
|
+
shift(s.refAt);
|
|
2309
|
+
shift(s.valueAt);
|
|
2310
|
+
}
|
|
2311
|
+
wires.forEach(shift);
|
|
2312
|
+
labels.forEach(shift);
|
|
2313
|
+
uniqJunctions.forEach(shift);
|
|
2314
|
+
noConnects.forEach(shift);
|
|
2315
|
+
groupRects.forEach(shift);
|
|
2316
|
+
// Two labels of DIFFERENT nets at one point is a merged net, not a cosmetic
|
|
2317
|
+
// overlap: KiCad resolves co-located labels to a single net and reports
|
|
2318
|
+
// `Both A and B are attached to the same items; A will be used in the
|
|
2319
|
+
// netlist` — as a warning. A live run drew ISET (charge-current program) and
|
|
2320
|
+
// NTC (thermistor input) onto the same node of a BQ24040 that way, which
|
|
2321
|
+
// would have shipped a board whose charge current is not set by its
|
|
2322
|
+
// programming resistor and whose temperature cutoff does not work.
|
|
2323
|
+
//
|
|
2324
|
+
// The engine computes every coordinate, so this is ours to catch, and it is
|
|
2325
|
+
// strictly worse than the failures we do gate: an unreadable sheet stops the
|
|
2326
|
+
// pipeline loudly, while a merged net passes ERC-as-warning and flows into
|
|
2327
|
+
// layout and fabrication outputs. Reported as a hard finding — the netlist
|
|
2328
|
+
// the IR declared is not the netlist that got drawn.
|
|
2329
|
+
const mergedNets = [
|
|
2330
|
+
...findMergedNets(labels).map((m) => ({ ...m, via: 'labels' })),
|
|
2331
|
+
...findWireContactMerges(wires, labels).map((m) => ({ ...m, via: 'wires' })),
|
|
2332
|
+
];
|
|
2333
|
+
// Overlapping label TEXT is the other half of the same pass and deliberately
|
|
2334
|
+
// not a gate. The de-collision loop clears what it can and, where it cannot,
|
|
2335
|
+
// prefers a legible-but-overlapping position over a merged net. What survives
|
|
2336
|
+
// is counted against a budget and named in the report, so a sheet never ships
|
|
2337
|
+
// a blemish silently and never stalls a run over one either.
|
|
2338
|
+
const labelOverlaps = findLabelOverlaps(labels);
|
|
2339
|
+
const labelOverlapBudgetExceeded = labels.length > 0 && labelOverlaps.length / labels.length > LABEL_OVERLAP_BUDGET;
|
|
2340
|
+
if (labelOverlaps.length) {
|
|
2341
|
+
const pct = ((labelOverlaps.length / Math.max(1, labels.length)) * 100).toFixed(1);
|
|
2342
|
+
const where = labelOverlaps
|
|
2343
|
+
.slice(0, 8)
|
|
2344
|
+
.map((o) => `${o.nets.join('/')} at (${o.x}, ${o.y})`)
|
|
2345
|
+
.join('; ');
|
|
2346
|
+
notes.push(`${labelOverlapBudgetExceeded ? 'LABEL OVERLAP BUDGET EXCEEDED: ' : ''}` +
|
|
2347
|
+
`${labelOverlaps.length} of ${labels.length} label(s) (${pct}%, budget ` +
|
|
2348
|
+
`${(LABEL_OVERLAP_BUDGET * 100).toFixed(1)}%) overlap a foreign net's label text. ` +
|
|
2349
|
+
`The netlist is unaffected — these are legibility defects, listed so they can be ` +
|
|
2350
|
+
`fixed or accepted deliberately: ${where}` +
|
|
2351
|
+
`${labelOverlaps.length > 8 ? `; and ${labelOverlaps.length - 8} more` : ''}`);
|
|
2352
|
+
}
|
|
2353
|
+
const model = {
|
|
2354
|
+
projectName,
|
|
2355
|
+
paper: paper.name,
|
|
2356
|
+
title: { title: projectName, date: today, rev: 'A' },
|
|
2357
|
+
libSymbols: [...libSymbols.entries()].map(([libId, sourceText]) => ({ libId, sourceText })),
|
|
2358
|
+
symbols: [...emitSymbols, ...extraSymbols],
|
|
2359
|
+
wires,
|
|
2360
|
+
junctions: uniqJunctions,
|
|
2361
|
+
labels,
|
|
2362
|
+
noConnects,
|
|
2363
|
+
rectangles: groupRects.map((r) => ({ x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2, stroke: 'solid', name: r.name })),
|
|
2364
|
+
captions: groupRects.map((r) => ({ text: r.name, x: r.x1 + 2, y: r.y1 + 2, name: r.name })),
|
|
2365
|
+
};
|
|
2366
|
+
const report = {
|
|
2367
|
+
groups: groupNames.map((g) => ({
|
|
2368
|
+
name: g,
|
|
2369
|
+
// instance keys fold back to refdes (a dual opamp is one member, not two)
|
|
2370
|
+
members: [
|
|
2371
|
+
...new Set([...groupOf.entries()].filter(([, gg]) => gg === g).map(([k]) => placed.get(k)?.refDes ?? k)),
|
|
2372
|
+
].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })),
|
|
2373
|
+
})),
|
|
2374
|
+
netClasses: [...netClasses.entries()]
|
|
2375
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
2376
|
+
.map(([name, c]) => ({ name, class: c.cls, overridden: c.overridden, basis: c.basis })),
|
|
2377
|
+
wireCount: wires.length,
|
|
2378
|
+
labelCount: labels.length,
|
|
2379
|
+
pwrFlags,
|
|
2380
|
+
noConnects: noConnects.length,
|
|
2381
|
+
paper: paper.name,
|
|
2382
|
+
notes,
|
|
2383
|
+
mergedNets,
|
|
2384
|
+
labelOverlaps,
|
|
2385
|
+
labelOverlapBudgetExceeded,
|
|
2386
|
+
};
|
|
2387
|
+
return { model, report };
|
|
2388
|
+
}
|
|
2389
|
+
//# sourceMappingURL=engine.js.map
|