@pcbjs/step 0.0.0 → 0.0.1-next.9029beb783788cfbb471cc9d57e1b47864c6eaa6

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuxuan Zhang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,49 @@
1
+ import { type StepModel } from './mesh.js';
2
+ import { type Rect, type StepTransform } from './place.js';
3
+ /** A pad in the footprint frame: centre and size in mm, x right and y down; `drill` for a through-hole pad. */
4
+ export interface AlignPad {
5
+ x: number;
6
+ y: number;
7
+ w: number;
8
+ h: number;
9
+ drill?: number;
10
+ }
11
+ /** The footprint's pads, its courtyard and its declared body height, which tells a standing model from one lying down. */
12
+ export interface AlignTarget {
13
+ pads: AlignPad[];
14
+ courtyard?: Rect;
15
+ height?: number;
16
+ }
17
+ /** Transforms kept when they already fit, such as the part's current one; the file's own frame is always tried. */
18
+ export interface AlignOptions {
19
+ keep?: StepTransform[];
20
+ }
21
+ /** The unit a file seems to be drawn in: the factor to millimetres and its name. */
22
+ export interface UnitGuess {
23
+ scale: number;
24
+ name: string;
25
+ }
26
+ export interface AlignResult {
27
+ /** KiCad's model transform to use, offset in mm and rotate in degrees */
28
+ transform: StepTransform;
29
+ /** 0 to 1: the share of pins on pads times the share of pads with a pin, lower when another orientation fits as well */
30
+ confidence: number;
31
+ unit: UnitGuess;
32
+ /** the model axis that ends up pointing up, such as +z, and the turn about z in degrees */
33
+ up: string;
34
+ turn: number;
35
+ /** pins found, pins on a pad, pads the pins were matched against */
36
+ pins: number;
37
+ matched: number;
38
+ pads: number;
39
+ /** the mean distance in mm from a matched pin to its pad centre */
40
+ error: number;
41
+ /** a remark for the user, such as another orientation fitting equally well */
42
+ note?: string;
43
+ /** set when a transform to keep already fitted: 'current' for the part's own, 'file' for the file's own frame */
44
+ kept?: 'current' | 'file';
45
+ }
46
+ /** A model's unit from its largest extent against the footprint's size: mm within a factor of three, else the closest unit. */
47
+ export declare function guessUnit(extent: number, target?: number): UnitGuess;
48
+ /** Fit a model to a footprint: by its pins on the pads, else by its box in the courtyard. */
49
+ export declare function alignModel(model: StepModel, target: AlignTarget, opts?: AlignOptions): AlignResult;
package/dist/align.js ADDED
@@ -0,0 +1,348 @@
1
+ // Fits KiCad's model transform so the model's pins, read from a height map seen from below, land on the footprint's pads.
2
+ import { bounds } from './mesh.js';
3
+ import { apply, heightAgrees, modelMatrix, mul, rotX, rotY, rotZ } from './place.js';
4
+ const UNITS = [
5
+ { scale: 1, name: 'mm' }, { scale: 25.4, name: 'inches' }, { scale: 1000, name: 'metres' }, { scale: 2.54, name: 'tenths of an inch' },
6
+ { scale: 10, name: 'centimetres' }, { scale: 0.001, name: 'micrometres' }, { scale: 0.0254, name: 'mils' }, { scale: 1 / 25.4, name: 'mm read as inches' },
7
+ ];
8
+ /** A model's unit from its largest extent against the footprint's size: mm within a factor of three, else the closest unit. */
9
+ export function guessUnit(extent, target) {
10
+ if (!(extent > 0))
11
+ return UNITS[0];
12
+ if (!target || !(target > 0))
13
+ return extent < 0.2 ? UNITS[2] : extent > 300 ? UNITS[5] : UNITS[0];
14
+ const ratio = extent / target;
15
+ if (ratio >= 0.33 && ratio <= 3)
16
+ return UNITS[0];
17
+ return UNITS.reduce((a, u) => (Math.abs(Math.log(u.scale * ratio / 1.1)) < Math.abs(Math.log(a.scale * ratio / 1.1)) ? u : a));
18
+ }
19
+ const DEG = Math.PI / 180;
20
+ const UPS = [
21
+ { name: '+z', m: rotX(0) }, { name: '-z', m: rotX(Math.PI) }, { name: '+y', m: rotX(Math.PI / 2) },
22
+ { name: '-y', m: rotX(-Math.PI / 2) }, { name: '+x', m: rotY(-Math.PI / 2) }, { name: '-x', m: rotY(Math.PI / 2) },
23
+ ];
24
+ /** The model seen from below after `m`: its seat, its XY box and the pin islands, each as a centre and a cell count. */
25
+ function viewFromBelow(model, m, tht) {
26
+ const tris = [];
27
+ const box = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
28
+ let zlo = Infinity, zhi = -Infinity;
29
+ const meshes = model.meshes.map(me => {
30
+ const p = new Float64Array(me.positions.length);
31
+ for (let i = 0; i < p.length; i += 3) {
32
+ const q = apply(m, [me.positions[i], me.positions[i + 1], me.positions[i + 2]]);
33
+ p[i] = q[0];
34
+ p[i + 1] = q[1];
35
+ p[i + 2] = q[2];
36
+ box.x0 = Math.min(box.x0, q[0]);
37
+ box.x1 = Math.max(box.x1, q[0]);
38
+ box.y0 = Math.min(box.y0, q[1]);
39
+ box.y1 = Math.max(box.y1, q[1]);
40
+ zlo = Math.min(zlo, q[2]);
41
+ zhi = Math.max(zhi, q[2]);
42
+ }
43
+ return { p, ix: me.indices };
44
+ });
45
+ if (!Number.isFinite(zlo))
46
+ return null;
47
+ const size = Math.max(box.x1 - box.x0, box.y1 - box.y0, 1e-3);
48
+ const cell = Math.min(0.2, Math.max(0.03, size / 250));
49
+ const nx = Math.ceil((box.x1 - box.x0) / cell) + 1, ny = Math.ceil((box.y1 - box.y0) / cell) + 1;
50
+ const z = new Float32Array(nx * ny).fill(Infinity);
51
+ for (const { p, ix } of meshes)
52
+ for (let t = 0; t < ix.length; t += 3) {
53
+ const a = ix[t] * 3, b = ix[t + 1] * 3, c = ix[t + 2] * 3;
54
+ const ax = p[a], ay = p[a + 1], bx = p[b], by = p[b + 1], cx = p[c], cy = p[c + 1];
55
+ const d = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy);
56
+ if (Math.abs(d) < 1e-12)
57
+ continue;
58
+ const i0 = Math.max(0, Math.floor((Math.min(ax, bx, cx) - box.x0) / cell)), i1 = Math.min(nx - 1, Math.ceil((Math.max(ax, bx, cx) - box.x0) / cell));
59
+ const j0 = Math.max(0, Math.floor((Math.min(ay, by, cy) - box.y0) / cell)), j1 = Math.min(ny - 1, Math.ceil((Math.max(ay, by, cy) - box.y0) / cell));
60
+ for (let j = j0; j <= j1; j++)
61
+ for (let i = i0; i <= i1; i++) {
62
+ const x = box.x0 + (i + 0.5) * cell, y = box.y0 + (j + 0.5) * cell;
63
+ const l1 = ((by - cy) * (x - cx) + (cx - bx) * (y - cy)) / d, l2 = ((cy - ay) * (x - cx) + (ax - cx) * (y - cy)) / d, l3 = 1 - l1 - l2;
64
+ if (l1 < -1e-6 || l2 < -1e-6 || l3 < -1e-6)
65
+ continue;
66
+ const zz = l1 * p[a + 2] + l2 * p[b + 2] + l3 * p[c + 2];
67
+ if (zz < z[j * nx + i])
68
+ z[j * nx + i] = zz;
69
+ }
70
+ }
71
+ const covered = [...z].filter(Number.isFinite).sort((p, q) => p - q);
72
+ if (!covered.length)
73
+ return null;
74
+ const lowest = covered[0], height = zhi - zlo;
75
+ // THT pins hang over 0.3 mm below the lowest tenth of the underside and the part sits on the rest; SMD sits on its pins
76
+ const limit = tht ? covered[Math.floor(covered.length * 0.1)] - 0.3 : lowest + Math.max(0.03, 0.02 * height);
77
+ const seat = tht ? covered.find(v => v >= limit) ?? lowest : lowest;
78
+ const isPin = (k) => z[k] < limit;
79
+ const seen = new Uint8Array(nx * ny);
80
+ const pins = [];
81
+ for (let k = 0; k < z.length; k++) {
82
+ if (seen[k] || !isPin(k))
83
+ continue;
84
+ let sx = 0, sy = 0, n = 0;
85
+ const stack = [k];
86
+ seen[k] = 1;
87
+ while (stack.length) {
88
+ const q = stack.pop(), i = q % nx, j = (q - i) / nx;
89
+ sx += i;
90
+ sy += j;
91
+ n++;
92
+ for (let dj = -1; dj <= 1; dj++)
93
+ for (let di = -1; di <= 1; di++) {
94
+ const ii = i + di, jj = j + dj;
95
+ if (ii < 0 || jj < 0 || ii >= nx || jj >= ny)
96
+ continue;
97
+ const r = jj * nx + ii;
98
+ if (!seen[r] && isPin(r)) {
99
+ seen[r] = 1;
100
+ stack.push(r);
101
+ }
102
+ }
103
+ }
104
+ if (n >= 2)
105
+ pins.push({ x: box.x0 + (sx / n + 0.5) * cell, y: box.y0 + (sy / n + 0.5) * cell, n });
106
+ }
107
+ // one island over most of the underside is a flat bottom, not pins
108
+ const area = covered.length;
109
+ return { pins: pins.filter(p => p.n < area * 0.5), seat, lowest, top: zhi, box };
110
+ }
111
+ /** How close the two point sets come, 0 to 1, with a millimetre of grace: for a footprint drawn near but not on the real part. */
112
+ function softFit(pins, pads, t) {
113
+ const near = (x, y, set, dx, dy) => Math.exp(-Math.min(...set.map(q => (q.x + dx - x) ** 2 + (q.y + dy - y) ** 2)) / 2);
114
+ const a = pads.reduce((s, q) => s + near(q.x, q.y, pins, t[0], t[1]), 0) / pads.length;
115
+ const b = pins.reduce((s, p) => s + near(p.x + t[0], p.y + t[1], pads, 0, 0), 0) / pins.length;
116
+ return (a + b) / 2;
117
+ }
118
+ /** How well pins moved by t land on the pads: the share of pins on a pad times the share of pads with a pin. */
119
+ function fitAt(pins, pads, t) {
120
+ let matched = 0, err = 0;
121
+ const hit = new Uint8Array(pads.length);
122
+ for (const p of pins) {
123
+ const x = p.x + t[0], y = p.y + t[1];
124
+ let best = -1, bd = Infinity;
125
+ pads.forEach((q, k) => { const d = Math.hypot(q.x - x, q.y - y); if (d < bd) {
126
+ bd = d;
127
+ best = k;
128
+ } });
129
+ if (best >= 0 && bd <= pads[best].tol) {
130
+ matched++;
131
+ err += bd;
132
+ hit[best] = 1;
133
+ }
134
+ }
135
+ const padsHit = hit.reduce((a, v) => a + v, 0);
136
+ const score = pins.length && pads.length ? (matched / pins.length) * (padsHit / pads.length) : 0;
137
+ return { score, soft: pins.length && pads.length ? softFit(pins, pads, t) : 0, t, matched, error: matched ? err / matched : 0 };
138
+ }
139
+ /** Move t so matched pins sit on their pads on average, weighted by area so a sliver of a contact counts little. */
140
+ function refine(pins, pads, t) {
141
+ for (let round = 0; round < 4; round++) {
142
+ let dx = 0, dy = 0, n = 0;
143
+ for (const p of pins) {
144
+ const x = p.x + t[0], y = p.y + t[1];
145
+ let best = -1, bd = Infinity;
146
+ pads.forEach((q, k) => { const d = Math.hypot(q.x - x, q.y - y); if (d < bd) {
147
+ bd = d;
148
+ best = k;
149
+ } });
150
+ if (best >= 0 && bd <= pads[best].tol) {
151
+ dx += (pads[best].x - x) * p.n;
152
+ dy += (pads[best].y - y) * p.n;
153
+ n += p.n;
154
+ }
155
+ }
156
+ if (!n)
157
+ break;
158
+ t = [t[0] + dx / n, t[1] + dy / n];
159
+ }
160
+ return t;
161
+ }
162
+ /** KiCad rotate angles, each a multiple of 90, whose model matrix is m; the one with the fewest turns. */
163
+ function kicadAngles(m) {
164
+ const A = [0, 90, 180, -90];
165
+ let best = [0, 0, 0], bestN = 4;
166
+ for (const rx of A)
167
+ for (const ry of A)
168
+ for (const rz of A) {
169
+ const k = modelMatrix({ offset: [0, 0, 0], rotate: [rx, ry, rz], scale: [1, 1, 1] });
170
+ if (k.some((v, i) => Math.abs(v - m[i]) > 1e-6))
171
+ continue;
172
+ const n = [rx, ry, rz].filter(Boolean).length;
173
+ if (n < bestN) {
174
+ best = [rx, ry, rz];
175
+ bestN = n;
176
+ }
177
+ }
178
+ return best;
179
+ }
180
+ const round2 = (v) => { const r = Math.round(v * 100) / 100; return Math.abs(r) < 0.015 ? 0 : r; };
181
+ /** The smallest spacing of the pad centres along one axis, 0 when they all share it. */
182
+ function pitchOf(pads, k) {
183
+ const v = [...new Set(pads.map(p => Math.round(p[k] * 1000) / 1000))].sort((a, b) => a - b);
184
+ let best = 0;
185
+ for (let i = 1; i < v.length; i++) {
186
+ const d = v[i] - v[i - 1];
187
+ if (d > 0.05 && (!best || d < best))
188
+ best = d;
189
+ }
190
+ return best;
191
+ }
192
+ /** An offset snapped to 0, else to a multiple of the pad pitch or of half of it, when one is within 0.1 mm. */
193
+ function snap(v, pitch) {
194
+ if (Math.abs(v) <= 0.1)
195
+ return 0;
196
+ for (const step of pitch ? [pitch, pitch / 2] : []) {
197
+ const k = Math.round(v / step) * step;
198
+ if (Math.abs(k - v) <= 0.1)
199
+ return k;
200
+ }
201
+ return v;
202
+ }
203
+ /** Whether every pin that lands on a pad at t sits on that pad's copper, with 0.1 mm to spare. */
204
+ function onCopper(pins, pads, t) {
205
+ for (const p of pins) {
206
+ const x = p.x + t[0], y = p.y + t[1];
207
+ let best = -1, bd = Infinity;
208
+ pads.forEach((q, k) => { const d = Math.hypot(q.x - x, q.y - y); if (d < bd) {
209
+ bd = d;
210
+ best = k;
211
+ } });
212
+ if (best < 0 || bd > pads[best].tol)
213
+ continue;
214
+ const q = pads[best];
215
+ if (Math.abs(q.x - x) > q.w / 2 + 0.1 || Math.abs(q.y - y) > q.h / 2 + 0.1)
216
+ return false;
217
+ }
218
+ return true;
219
+ }
220
+ /** Fit a model to a footprint: by its pins on the pads, else by its box in the courtyard. */
221
+ export function alignModel(model, target, opts = {}) {
222
+ const b = bounds(model);
223
+ const extent = Math.max(b.max[0] - b.min[0], b.max[1] - b.min[1], b.max[2] - b.min[2]);
224
+ const pb = target.pads.reduce((a, p) => ({ x0: Math.min(a.x0, p.x - p.w / 2), y0: Math.min(a.y0, p.y - p.h / 2), x1: Math.max(a.x1, p.x + p.w / 2), y1: Math.max(a.y1, p.y + p.h / 2) }), { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity });
225
+ const footBox = target.courtyard ?? (target.pads.length ? pb : undefined);
226
+ const unit = guessUnit(extent, footBox ? Math.max(footBox.x1 - footBox.x0, footBox.y1 - footBox.y0) : undefined);
227
+ const s = unit.scale;
228
+ const tht = target.pads.some(p => p.drill);
229
+ // pads in the model frame, y up; a through-hole part is matched on its drilled pads only
230
+ const pads = target.pads.filter(p => !tht || p.drill).map(p => ({ x: p.x, y: -p.y, w: p.w, h: p.h, tol: Math.max(0.35, 0.5 * Math.max(p.w, p.h)) }));
231
+ const padBox = pads.length ? { x: (pb.x0 + pb.x1) / 2, y: -(pb.y0 + pb.y1) / 2 } : { x: 0, y: 0 };
232
+ const court = footBox ? { x0: footBox.x0, x1: footBox.x1, y0: -footBox.y1, y1: -footBox.y0 } : undefined;
233
+ const height = target.height !== undefined && target.height > 0 ? target.height : undefined;
234
+ const key = (f) => f.score + 0.25 * f.soft;
235
+ const scoreOf = (pins, fit, box, t) => {
236
+ // pins near pads, in about the right number, count for a footprint drawn from a guess
237
+ const count = pins.length && pads.length ? Math.min(pins.length, pads.length) / Math.max(pins.length, pads.length) : 0;
238
+ let score = 0.75 * fit.score + 0.25 * fit.soft * count;
239
+ if (court) {
240
+ const over = Math.max(0, court.x0 - (box.x0 + t[0]), box.x1 + t[0] - court.x1, court.y0 - (box.y0 + t[1]), box.y1 + t[1] - court.y1);
241
+ const fill = Math.exp(-(Math.abs(Math.log((box.x1 - box.x0 + 1e-3) / (court.x1 - court.x0 + 1e-3))) + Math.abs(Math.log((box.y1 - box.y0 + 1e-3) / (court.y1 - court.y0 + 1e-3)))));
242
+ // pins decide when there are any; the courtyard breaks ties and decides alone without pins
243
+ score = score > 0 ? score * (0.8 + 0.2 / (1 + Math.max(0, over - 1))) + 0.05 * fill : 0.2 * fill / (1 + over);
244
+ }
245
+ else if (score === 0)
246
+ score = 0.1;
247
+ return score;
248
+ };
249
+ const cands = [];
250
+ for (const [ui, up] of UPS.entries()) {
251
+ const base = mul(up.m, [s, 0, 0, 0, 0, s, 0, 0, 0, 0, s, 0, 0, 0, 0, 1]);
252
+ const view = viewFromBelow(model, base, tht);
253
+ if (!view)
254
+ continue;
255
+ for (const turn of [0, 90, 180, 270]) {
256
+ const r = rotZ(turn * DEG);
257
+ const pins = view.pins.map(p => { const q = apply(r, [p.x, p.y, 0]); return { x: q[0], y: q[1], n: p.n }; });
258
+ const corners = [[view.box.x0, view.box.y0], [view.box.x1, view.box.y1]].map(([x, y]) => apply(r, [x, y, 0]));
259
+ const box = { x0: Math.min(corners[0][0], corners[1][0]), x1: Math.max(corners[0][0], corners[1][0]), y0: Math.min(corners[0][1], corners[1][1]), y1: Math.max(corners[0][1], corners[1][1]) };
260
+ const centre = [padBox.x - (box.x0 + box.x1) / 2, padBox.y - (box.y0 + box.y1) / 2];
261
+ let fit = { score: 0, soft: 0, t: centre, matched: 0, error: 0 };
262
+ if (pins.length && pads.length) {
263
+ const starts = [centre];
264
+ const pinBox = pins.reduce((a, p) => ({ x0: Math.min(a.x0, p.x), x1: Math.max(a.x1, p.x), y0: Math.min(a.y0, p.y), y1: Math.max(a.y1, p.y) }), { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity });
265
+ const padPts = pads.reduce((a, p) => ({ x0: Math.min(a.x0, p.x), x1: Math.max(a.x1, p.x), y0: Math.min(a.y0, p.y), y1: Math.max(a.y1, p.y) }), { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity });
266
+ starts.push([(padPts.x0 + padPts.x1 - pinBox.x0 - pinBox.x1) / 2, (padPts.y0 + padPts.y1 - pinBox.y0 - pinBox.y1) / 2]);
267
+ for (const p of pins.slice(0, 3))
268
+ for (const q of pads)
269
+ starts.push([q.x - p.x, q.y - p.y]);
270
+ for (const st of starts) {
271
+ const f = fitAt(pins, pads, st);
272
+ if (key(f) > key(fit) + 1e-9)
273
+ fit = f;
274
+ }
275
+ if (fit.score > 0)
276
+ fit = fitAt(pins, pads, refine(pins, pads, fit.t));
277
+ }
278
+ let score = scoreOf(pins, fit, box, fit.score > 0 || fit.soft > 0 ? fit.t : centre);
279
+ const t = score > 0 && (fit.score > 0 || fit.soft > 0) ? fit.t : centre;
280
+ // the file's own orientation first when two fit alike
281
+ score -= (ui * 4 + turn / 90) * 1e-6;
282
+ cands.push({ score, up: up.name, turn, m: mul(r, up.m), t, view, fit, pins: pins.length, box: { x0: box.x0 + t[0], x1: box.x1 + t[0], y0: box.y0 + t[1], y1: box.y1 + t[1] } });
283
+ }
284
+ }
285
+ if (!cands.length)
286
+ throw new Error('the model has no geometry to align');
287
+ cands.sort((p, q) => q.score - p.score);
288
+ // the file as drawn wins unless most pins land on pads well ahead of any upright fit; without pins the height decides
289
+ const tall = (c) => c.view.top - c.view.seat;
290
+ const upright = cands.filter(c => c.up === '+z');
291
+ const clear = (c) => c.fit.score >= 0.5 && c.fit.score >= (upright[0]?.fit.score ?? 0) + 0.3;
292
+ let best = cands[0];
293
+ if (best.up !== '+z' && upright.length && !clear(best)) {
294
+ best = upright[0];
295
+ if (height !== undefined && !heightAgrees(tall(best), height))
296
+ best = cands.find(c => heightAgrees(tall(c), height) && c.score >= upright[0].score * 0.8) ?? best;
297
+ }
298
+ // offsets within 0.1 mm of 0, or of the pad pitch, are that
299
+ const px = pitchOf(pads, 'x'), py = pitchOf(pads, 'y');
300
+ let t = [snap(best.t[0], px), snap(best.t[1], py)];
301
+ // keep the file's z = 0 when it is on the board, or when THT pins pass through it under a standoff body such as a TO-220
302
+ let z = -best.view.seat;
303
+ if (best.up === '+z' && s === 1 && (Math.abs(best.view.seat) <= 0.1 || (tht && best.view.lowest < -0.3 && best.view.seat >= -0.1)))
304
+ z = 0;
305
+ // another orientation that fits as well but puts the body elsewhere is worth a look
306
+ const rival = cands.find(c => c !== best && c.up === best.up && c.score >= best.score * 0.97 && Math.max(Math.abs(c.box.x0 - best.box.x0), Math.abs(c.box.x1 - best.box.x1), Math.abs(c.box.y0 - best.box.y0), Math.abs(c.box.y1 - best.box.y1)) > 0.5);
307
+ let confidence = Math.max(0, Math.min(1, best.score));
308
+ if (rival)
309
+ confidence *= 0.6;
310
+ const found = {
311
+ transform: { offset: [round2(t[0]), round2(t[1]), round2(z)], rotate: kicadAngles(best.m), scale: [s, s, s] },
312
+ confidence: Math.round(confidence * 100) / 100, unit, up: best.up, turn: best.turn,
313
+ pins: best.pins, matched: best.fit.matched, pads: pads.length, error: Math.round(fitAt(best.view.pins.map(p => { const q = apply(rotZ(best.turn * DEG), [p.x, p.y, 0]); return { x: q[0], y: q[1], n: p.n }; }), pads, t).error * 100) / 100,
314
+ ...(rival ? { note: `turned ${(rival.turn - best.turn + 360) % 360} degrees about z fits as well: check pin 1 in the shot` } : {}),
315
+ };
316
+ // a kept transform stays when it fits as well as the search: as many pins on pad copper, or without pins its box on the pads
317
+ const ident = { offset: [0, 0, 0], rotate: [0, 0, 0], scale: [1, 1, 1] };
318
+ const keeps = [...(opts.keep ?? []).map(k => [k, 'current']), [ident, 'file']];
319
+ for (const [k, kind] of keeps) {
320
+ const view = viewFromBelow(model, modelMatrix(k), tht);
321
+ if (!view)
322
+ continue;
323
+ const seated = Math.abs(view.seat) <= 0.1 || (tht && view.lowest < -0.3 && view.seat >= -0.1);
324
+ if (!seated || (height !== undefined && !heightAgrees(view.top - Math.max(0, view.seat), height) && heightAgrees(tall(best), height)))
325
+ continue;
326
+ let fit = { score: 0, soft: 0, t: [0, 0], matched: 0, error: 0 };
327
+ if (best.fit.matched > 0) {
328
+ if (!view.pins.length)
329
+ continue;
330
+ fit = fitAt(view.pins, pads, [0, 0]);
331
+ if (fit.matched < Math.ceil(0.85 * best.fit.matched) || fit.score < 0.85 * best.fit.score || !onCopper(view.pins, pads, [0, 0]))
332
+ continue;
333
+ }
334
+ else {
335
+ const c = { x: (view.box.x0 + view.box.x1) / 2, y: (view.box.y0 + view.box.y1) / 2 };
336
+ if (pads.length && (Math.abs(c.x - padBox.x) > 0.5 || Math.abs(c.y - padBox.y) > 0.5))
337
+ continue;
338
+ }
339
+ const score = Math.max(0, Math.min(1, scoreOf(view.pins, fit, view.box, [0, 0])));
340
+ return {
341
+ transform: { offset: [...k.offset], rotate: [...k.rotate], scale: [...k.scale] },
342
+ confidence: Math.round(score * (rival ? 0.6 : 1) * 100) / 100, unit: kind === 'file' ? UNITS[0] : unit, up: '+z', turn: 0,
343
+ pins: view.pins.length, matched: fit.matched, pads: pads.length, error: Math.round(fit.error * 100) / 100, kept: kind,
344
+ ...(rival ? { note: found.note } : {}),
345
+ };
346
+ }
347
+ return found;
348
+ }
@@ -0,0 +1,4 @@
1
+ import type { StepModel } from './mesh.js';
2
+ export declare function meshToBinary(model: StepModel): Uint8Array;
3
+ /** Decode the binary of meshToBinary; normals come back unit length to a byte's precision. */
4
+ export declare function meshFromBinary(data: ArrayBuffer | ArrayBufferView): StepModel;
package/dist/binary.js ADDED
@@ -0,0 +1,83 @@
1
+ const MAGIC = 0x4d424350; // "PCBM" read as a little-endian u32
2
+ const VERSION = 1;
3
+ const pad4 = (n) => (n + 3) & ~3;
4
+ export function meshToBinary(model) {
5
+ let size = 12;
6
+ for (const m of model.meshes) {
7
+ const n = m.positions.length / 3, wide = n > 0xffff;
8
+ size += 24 + n * 12 + pad4(n * 3) + pad4(m.indices.length * (wide ? 4 : 2));
9
+ }
10
+ const buf = new ArrayBuffer(size);
11
+ const dv = new DataView(buf);
12
+ dv.setUint32(0, MAGIC, true);
13
+ dv.setUint16(4, VERSION, true);
14
+ dv.setUint16(6, 0, true);
15
+ dv.setUint32(8, model.meshes.length, true);
16
+ let off = 12;
17
+ for (const m of model.meshes) {
18
+ const n = m.positions.length / 3, wide = n > 0xffff;
19
+ dv.setFloat32(off, m.color[0], true);
20
+ dv.setFloat32(off + 4, m.color[1], true);
21
+ dv.setFloat32(off + 8, m.color[2], true);
22
+ dv.setUint32(off + 12, n, true);
23
+ dv.setUint32(off + 16, m.indices.length, true);
24
+ dv.setUint32(off + 20, wide ? 4 : 2, true);
25
+ off += 24;
26
+ for (let i = 0; i < n * 3; i++)
27
+ dv.setFloat32(off + i * 4, m.positions[i], true);
28
+ off += n * 12;
29
+ for (let i = 0; i < n * 3; i++)
30
+ dv.setInt8(off + i, Math.max(-127, Math.min(127, Math.round((m.normals[i] ?? 0) * 127))));
31
+ off += pad4(n * 3);
32
+ if (wide)
33
+ for (let i = 0; i < m.indices.length; i++)
34
+ dv.setUint32(off + i * 4, m.indices[i], true);
35
+ else
36
+ for (let i = 0; i < m.indices.length; i++)
37
+ dv.setUint16(off + i * 2, m.indices[i], true);
38
+ off += pad4(m.indices.length * (wide ? 4 : 2));
39
+ }
40
+ return new Uint8Array(buf);
41
+ }
42
+ /** Decode the binary of meshToBinary; normals come back unit length to a byte's precision. */
43
+ export function meshFromBinary(data) {
44
+ const view = data instanceof ArrayBuffer ? new DataView(data) : new DataView(data.buffer, data.byteOffset, data.byteLength);
45
+ if (view.byteLength < 12 || view.getUint32(0, true) !== MAGIC)
46
+ throw new Error('not a pcbjs mesh: the header is missing');
47
+ const version = view.getUint16(4, true);
48
+ if (version !== VERSION)
49
+ throw new Error(`pcbjs mesh version ${version}, this reader knows ${VERSION}`);
50
+ const count = view.getUint32(8, true);
51
+ const meshes = [];
52
+ let off = 12;
53
+ const need = (n) => { if (off + n > view.byteLength)
54
+ throw new Error('pcbjs mesh is truncated'); };
55
+ for (let k = 0; k < count; k++) {
56
+ need(24);
57
+ const color = [view.getFloat32(off, true), view.getFloat32(off + 4, true), view.getFloat32(off + 8, true)];
58
+ const n = view.getUint32(off + 12, true), ni = view.getUint32(off + 16, true), indexSize = view.getUint32(off + 20, true);
59
+ if (indexSize !== 2 && indexSize !== 4)
60
+ throw new Error(`pcbjs mesh has an index size of ${indexSize}`);
61
+ off += 24;
62
+ need(n * 12 + pad4(n * 3) + pad4(ni * indexSize));
63
+ const positions = new Float32Array(n * 3);
64
+ for (let i = 0; i < n * 3; i++)
65
+ positions[i] = view.getFloat32(off + i * 4, true);
66
+ off += n * 12;
67
+ const normals = new Float32Array(n * 3);
68
+ for (let i = 0; i < n; i++) {
69
+ const x = view.getInt8(off + i * 3), y = view.getInt8(off + i * 3 + 1), z = view.getInt8(off + i * 3 + 2);
70
+ const l = Math.hypot(x, y, z) || 1;
71
+ normals[i * 3] = x / l;
72
+ normals[i * 3 + 1] = y / l;
73
+ normals[i * 3 + 2] = z / l;
74
+ }
75
+ off += pad4(n * 3);
76
+ const indices = new Uint32Array(ni);
77
+ for (let i = 0; i < ni; i++)
78
+ indices[i] = indexSize === 4 ? view.getUint32(off + i * 4, true) : view.getUint16(off + i * 2, true);
79
+ off += pad4(ni * indexSize);
80
+ meshes.push({ positions, normals, indices, color });
81
+ }
82
+ return { meshes };
83
+ }
@@ -0,0 +1,7 @@
1
+ /** A model file format: STEP, glTF as JSON or binary, or VRML 2 as KiCad's packages3D library ships it. */
2
+ export type ModelFormat = 'step' | 'gltf' | 'glb' | 'wrl';
3
+ export declare const MODEL_EXTENSIONS: Record<ModelFormat, string[]>;
4
+ export declare function formatOfName(name: string): ModelFormat | null;
5
+ /** The format by the first bytes; a gzipped file is VRML, as `.wrz` is. */
6
+ export declare function formatOfContent(b: Uint8Array): ModelFormat | null;
7
+ export declare function modelFormat(name: string | undefined, content?: Uint8Array): ModelFormat | null;
package/dist/format.js ADDED
@@ -0,0 +1,30 @@
1
+ export const MODEL_EXTENSIONS = { step: ['.step', '.stp'], gltf: ['.gltf'], glb: ['.glb'], wrl: ['.wrl', '.vrml'] };
2
+ export function formatOfName(name) {
3
+ const m = /\.[a-z0-9]+$/i.exec(name.split(/[?#]/)[0]);
4
+ if (!m)
5
+ return null;
6
+ const ext = m[0].toLowerCase();
7
+ for (const [f, exts] of Object.entries(MODEL_EXTENSIONS))
8
+ if (exts.includes(ext))
9
+ return f;
10
+ return null;
11
+ }
12
+ const text = (b, n) => String.fromCharCode(...b.subarray(0, Math.min(n, b.length)));
13
+ /** The format by the first bytes; a gzipped file is VRML, as `.wrz` is. */
14
+ export function formatOfContent(b) {
15
+ if (b.length >= 4 && text(b, 4) === 'glTF')
16
+ return 'glb';
17
+ if (b.length >= 2 && b[0] === 0x1f && b[1] === 0x8b)
18
+ return 'wrl';
19
+ const head = text(b, 256).replace(/^/, '').trimStart();
20
+ if (head.startsWith('ISO-10303-21'))
21
+ return 'step';
22
+ if (head.startsWith('#VRML'))
23
+ return 'wrl';
24
+ if (head.startsWith('{') && /"asset"|"meshes"|"accessors"/.test(text(b, 4096)))
25
+ return 'gltf';
26
+ return null;
27
+ }
28
+ export function modelFormat(name, content) {
29
+ return (name ? formatOfName(name) : null) ?? (content ? formatOfContent(content) : null);
30
+ }
package/dist/gltf.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { type StepModel } from './mesh.js';
2
+ /** `readFile` loads a buffer stored in a file beside the model, by its URI. */
3
+ export interface GltfOptions {
4
+ readFile?: (uri: string) => Uint8Array;
5
+ }
6
+ /** Read a glTF or GLB file's content into meshes in millimetres, z up. */
7
+ export declare function gltfToMesh(content: Uint8Array, opts?: GltfOptions): StepModel;