calculate-packing 0.0.1

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) 2025 tscircuit
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.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # calculate-packing
2
+
3
+ **calculate-packing** is a small TypeScript library that ships the placement /
4
+ packing algorithm used by the [tscircuit tool-chain](https://github.com/tscircuit/tscircuit) for automatically laying out PCB components.
5
+
6
+ The solver turns a user-supplied `PackInput` (components, pads & strategy
7
+ settings) into a collision-free `PackOutput` while
8
+
9
+ - honouring a configurable clearance (`minGap`)
10
+ - keeping pads that share the same `networkId` close together
11
+ - minimising overall trace length
12
+
13
+ Internally the algorithm:
14
+
15
+ 1. sorts components (largest → smallest)
16
+ 2. keeps an outline (union of inflated component AABBs) of the already packed
17
+ island(s)
18
+ 3. probes outline segments for the point with the shortest distance to any pad
19
+ on the same network
20
+ 4. evaluates the four orthogonal rotations of the candidate component and
21
+ chooses the cheapest non-overlapping one
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ bun add calculate-packing # or npm i / yarn add
27
+ ```
28
+
29
+ ## Quick start
30
+
31
+ ```ts
32
+ import { pack, PackInput } from "calculate-packing"
33
+
34
+ const input: PackInput = {
35
+ components: [
36
+ {
37
+ componentId: "C1",
38
+ pads: [
39
+ {
40
+ padId: "C1_1",
41
+ networkId: "GND",
42
+ type: "rect",
43
+ offset: { x: -0.6, y: 0 },
44
+ size: { x: 1.2, y: 1 },
45
+ },
46
+ {
47
+ padId: "C1_2",
48
+ networkId: "VCC",
49
+ type: "rect",
50
+ offset: { x: +0.6, y: 0 },
51
+ size: { x: 1.2, y: 1 },
52
+ },
53
+ ],
54
+ },
55
+ /* …more components… */
56
+ ],
57
+ minGap: 0.25,
58
+ packOrderStrategy: "largest_to_smallest",
59
+ packPlacementStrategy: "shortest_connection_along_outline",
60
+ }
61
+
62
+ const result = pack(input)
63
+ console.log(result.components) // → positioned & rotated components
64
+ ```
65
+
66
+ See `tests/` for more elaborate examples (SVG snapshots, circuit-json fixtures).
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ bun install # install deps
72
+ bun test # run unit & SVG snapshot tests
73
+ bunx tsc --noEmit # type-check
74
+ ```
75
+
76
+ ### Repo layout
77
+
78
+ • `lib/PackSolver` – high-level packing solver
79
+ • `lib/geometry` – computational-geometry helpers
80
+ • `lib/math` – low-level math utilities
81
+ • `tests/` – unit & snapshot tests
82
+
83
+ ## Public API
84
+
85
+ | export | purpose |
86
+ | ---------------------------------- | ----------------------------------------- |
87
+ | `pack()` | run the solver |
88
+ | `convertPackOutputToPackInput()` | strip solver-only fields |
89
+ | `convertCircuitJsonToPackOutput()` | circuit-json → PackOutput helper |
90
+ | `getGraphicsFromPackOutput()` | build a `graphics-debug` scene for review |
91
+
92
+ Everything else is internal and may change without notice.
93
+
94
+ ## License
95
+
96
+ MIT – see [LICENSE](./LICENSE).
@@ -0,0 +1,153 @@
1
+ import { GraphicsObject, Point } from 'graphics-debug';
2
+ import { CircuitJson } from 'circuit-json';
3
+
4
+ type ComponentId = string;
5
+ type PadId = string;
6
+ type NetworkId = string;
7
+ interface InputPad {
8
+ padId: string;
9
+ networkId: string;
10
+ type: "rect";
11
+ offset: {
12
+ x: number;
13
+ y: number;
14
+ };
15
+ size: {
16
+ x: number;
17
+ y: number;
18
+ };
19
+ }
20
+ interface OutputPad extends InputPad {
21
+ absoluteCenter: {
22
+ x: number;
23
+ y: number;
24
+ };
25
+ }
26
+ interface InputComponent {
27
+ componentId: string;
28
+ pads: InputPad[];
29
+ }
30
+ interface PackedComponent extends InputComponent {
31
+ center: {
32
+ x: number;
33
+ y: number;
34
+ };
35
+ ccwRotationOffset: number;
36
+ pads: OutputPad[];
37
+ }
38
+ interface PackInput {
39
+ components: InputComponent[];
40
+ minGap: number;
41
+ packOrderStrategy: "largest_to_smallest";
42
+ packPlacementStrategy: "shortest_connection_along_outline";
43
+ disconnectedPackDirection?: "left" | "right" | "up" | "down";
44
+ packFirst?: ComponentId[];
45
+ }
46
+ interface PackOutput extends PackInput {
47
+ components: PackedComponent[];
48
+ }
49
+
50
+ /**
51
+ * The pack algorithm performs the following steps:
52
+ * 1. Sort the components using the packOrderStrategy
53
+ * 2. Select the next component to pack
54
+ * 3. If the first component, pack at center (0,0) and go to step 2
55
+ * 4. Compute the outline of all packed components with a gap of minGap + max(pad.width, pad.height)/2
56
+ * 5. Find the point along the outline that minimizes the distance of the pad
57
+ * centers within a networkId. If no shared pads, pack to the defaultPackDirection
58
+ * 6. Add the component at the selected point, with it's pad at the position
59
+ * minimizing the distance between the pad centers
60
+ * 7. To determine the component rotation, find the minimum distance between pad
61
+ * centers for the remaining pads at each possible rotation (making sure that
62
+ * we never pack such that two pads overlap)
63
+ * 8. Go to step 2 until all components are packed
64
+ */
65
+ declare const pack: (input: PackInput) => PackOutput;
66
+
67
+ declare class BaseSolver {
68
+ MAX_ITERATIONS: number;
69
+ solved: boolean;
70
+ failed: boolean;
71
+ iterations: number;
72
+ progress: number;
73
+ error: string | null;
74
+ activeSubSolver?: BaseSolver | null;
75
+ failedSubSolvers?: BaseSolver[];
76
+ timeToSolve?: number;
77
+ stats: Record<string, any>;
78
+ _setupDone: boolean;
79
+ setup(): void;
80
+ /** DO NOT OVERRIDE! Override _step() instead */
81
+ step(): void;
82
+ _setup(): void;
83
+ _step(): void;
84
+ getConstructorParams(): void;
85
+ solve(): void;
86
+ visualize(): GraphicsObject;
87
+ /**
88
+ * Called when the solver is about to fail, but we want to see if we have an
89
+ * "acceptable" or "passable" solution. Mostly used for optimizers that
90
+ * have an aggressive early stopping criterion.
91
+ */
92
+ tryFinalAcceptance(): void;
93
+ /**
94
+ * A lightweight version of the visualize method that can be used to stream
95
+ * progress
96
+ */
97
+ preview(): GraphicsObject;
98
+ }
99
+
100
+ /**
101
+ * The pack algorithm performs the following steps:
102
+ * 1. Sort the components using the packOrderStrategy
103
+ * 2. Select the next component to pack
104
+ * 3. If the first component, pack at center (0,0) and go to step 2
105
+ * 4. Compute the outline of all packed components with a gap of minGap + max(pad.width, pad.height)/2
106
+ * 5. Find the point along the outline that minimizes the distance of the pad
107
+ * centers within a networkId. If no shared pads, pack to the defaultPackDirection
108
+ * 6. Add the component at the selected point, with it's pad at the position
109
+ * minimizing the distance between the pad centers
110
+ * 7. To determine the component rotation, find the minimum distance between pad
111
+ * centers for the remaining pads at each possible rotation (making sure that
112
+ * we never pack such that two pads overlap)
113
+ * 8. Go to step 2 until all components are packed
114
+ */
115
+ declare class PackSolver extends BaseSolver {
116
+ packInput: PackInput;
117
+ unpackedComponentQueue: InputComponent[];
118
+ packedComponents: PackedComponent[];
119
+ lastBestPointsResult?: {
120
+ bestPoints: (Point & {
121
+ networkId: NetworkId;
122
+ })[];
123
+ distance: number;
124
+ };
125
+ constructor(input: PackInput);
126
+ _setup(): void;
127
+ _step(): void;
128
+ getConstructorParams(): PackInput[];
129
+ /** Visualize the current packing state – components are omitted, only the outline is shown. */
130
+ visualize(): GraphicsObject;
131
+ getResult(): PackedComponent[];
132
+ }
133
+
134
+ declare const convertCircuitJsonToPackOutput: (circuitJson: CircuitJson) => PackOutput;
135
+
136
+ declare const getGraphicsFromPackOutput: (packOutput: PackOutput) => GraphicsObject;
137
+
138
+ /**
139
+ * Strip all “output only” properties (those added by the pack() solver)
140
+ * so the result can be fed back into pack() again or compared against an
141
+ * original PackInput. Everything else must be preserved verbatim.
142
+ *
143
+ * NOTE:
144
+ * – PackInput.components is an array of **InputComponent**,
145
+ * while PackOutput.components is an array of **PackedComponent**.
146
+ * We therefore have to:
147
+ * • copy componentId
148
+ * • copy each pad but drop `absoluteCenter`
149
+ * • drop `center` and `ccwRotationOffset`
150
+ */
151
+ declare const convertPackOutputToPackInput: (packed: PackOutput) => PackInput;
152
+
153
+ export { type ComponentId, type InputComponent, type InputPad, type NetworkId, type OutputPad, type PackInput, type PackOutput, PackSolver, type PackedComponent, type PadId, convertCircuitJsonToPackOutput, convertPackOutputToPackInput, getGraphicsFromPackOutput, pack };
package/dist/index.js ADDED
@@ -0,0 +1,712 @@
1
+ // lib/solver-utils/BaseSolver.ts
2
+ var BaseSolver = class {
3
+ MAX_ITERATIONS = 1e3;
4
+ solved = false;
5
+ failed = false;
6
+ iterations = 0;
7
+ progress = 0;
8
+ error = null;
9
+ activeSubSolver;
10
+ failedSubSolvers;
11
+ timeToSolve;
12
+ stats = {};
13
+ _setupDone = false;
14
+ setup() {
15
+ if (this._setupDone) return;
16
+ this._setup();
17
+ this._setupDone = true;
18
+ }
19
+ /** DO NOT OVERRIDE! Override _step() instead */
20
+ step() {
21
+ if (!this._setupDone) {
22
+ this.setup();
23
+ }
24
+ if (this.solved) return;
25
+ if (this.failed) return;
26
+ this.iterations++;
27
+ try {
28
+ this._step();
29
+ } catch (e) {
30
+ this.error = `${this.constructor.name} error: ${e}`;
31
+ console.error(this.error);
32
+ this.failed = true;
33
+ throw e;
34
+ }
35
+ if (!this.solved && this.iterations > this.MAX_ITERATIONS) {
36
+ this.tryFinalAcceptance();
37
+ }
38
+ if (!this.solved && this.iterations > this.MAX_ITERATIONS) {
39
+ this.error = `${this.constructor.name} ran out of iterations`;
40
+ console.error(this.error);
41
+ this.failed = true;
42
+ }
43
+ if ("computeProgress" in this) {
44
+ this.progress = this.computeProgress();
45
+ }
46
+ }
47
+ _setup() {
48
+ }
49
+ _step() {
50
+ }
51
+ getConstructorParams() {
52
+ throw new Error("getConstructorParams not implemented");
53
+ }
54
+ solve() {
55
+ const startTime = Date.now();
56
+ while (!this.solved && !this.failed) {
57
+ this.step();
58
+ }
59
+ const endTime = Date.now();
60
+ this.timeToSolve = endTime - startTime;
61
+ }
62
+ visualize() {
63
+ return {
64
+ lines: [],
65
+ points: [],
66
+ rects: [],
67
+ circles: []
68
+ };
69
+ }
70
+ /**
71
+ * Called when the solver is about to fail, but we want to see if we have an
72
+ * "acceptable" or "passable" solution. Mostly used for optimizers that
73
+ * have an aggressive early stopping criterion.
74
+ */
75
+ tryFinalAcceptance() {
76
+ }
77
+ /**
78
+ * A lightweight version of the visualize method that can be used to stream
79
+ * progress
80
+ */
81
+ preview() {
82
+ return {
83
+ lines: [],
84
+ points: [],
85
+ rects: [],
86
+ circles: []
87
+ };
88
+ }
89
+ };
90
+
91
+ // lib/math/rotatePoint.ts
92
+ var rotatePoint = (point, angle, origin = { x: 0, y: 0 }) => {
93
+ const cos = Math.cos(angle);
94
+ const sin = Math.sin(angle);
95
+ const dx = point.x - origin.x;
96
+ const dy = point.y - origin.y;
97
+ return {
98
+ x: origin.x + dx * cos - dy * sin,
99
+ y: origin.y + dx * sin + dy * cos
100
+ };
101
+ };
102
+
103
+ // lib/geometry/getComponentBounds.ts
104
+ var getComponentBounds = (component, minGap = 0) => {
105
+ const bounds = {
106
+ minX: Infinity,
107
+ maxX: -Infinity,
108
+ minY: Infinity,
109
+ maxY: -Infinity
110
+ };
111
+ component.pads.forEach((pad) => {
112
+ const hw = pad.size.x / 2;
113
+ const hh = pad.size.y / 2;
114
+ const localCorners = [
115
+ { x: pad.offset.x - hw, y: pad.offset.y - hh },
116
+ { x: pad.offset.x + hw, y: pad.offset.y - hh },
117
+ { x: pad.offset.x + hw, y: pad.offset.y + hh },
118
+ { x: pad.offset.x - hw, y: pad.offset.y + hh }
119
+ ];
120
+ localCorners.forEach((corner) => {
121
+ const world = rotatePoint(corner, component.ccwRotationOffset);
122
+ const x = world.x + component.center.x;
123
+ const y = world.y + component.center.y;
124
+ bounds.minX = Math.min(bounds.minX, x);
125
+ bounds.maxX = Math.max(bounds.maxX, x);
126
+ bounds.minY = Math.min(bounds.minY, y);
127
+ bounds.maxY = Math.max(bounds.maxY, y);
128
+ });
129
+ });
130
+ return {
131
+ minX: bounds.minX - minGap,
132
+ maxX: bounds.maxX + minGap,
133
+ minY: bounds.minY - minGap,
134
+ maxY: bounds.maxY + minGap
135
+ };
136
+ };
137
+
138
+ // lib/constructOutlinesFromPackedComponents.ts
139
+ import Flatten from "@flatten-js/core";
140
+ var constructOutlinesFromPackedComponents = (components, opts = {}) => {
141
+ const { minGap = 0 } = opts;
142
+ if (components.length === 0) return [];
143
+ const rectPolys = components.map((c) => {
144
+ const b = getComponentBounds(c, minGap);
145
+ return new Flatten.Polygon([
146
+ [b.minX, b.minY],
147
+ [b.maxX, b.minY],
148
+ [b.maxX, b.maxY],
149
+ [b.minX, b.maxY]
150
+ ]);
151
+ });
152
+ if (rectPolys.length === 0) return [];
153
+ let union = rectPolys[0];
154
+ for (let i = 1; i < rectPolys.length; i++) {
155
+ union = Flatten.BooleanOperations.unify(union, rectPolys[i]);
156
+ }
157
+ const outlines = [];
158
+ for (const face of union.faces) {
159
+ if (face.isHole) continue;
160
+ const outline = [];
161
+ let edge = face.first;
162
+ if (!edge) continue;
163
+ do {
164
+ const shp = edge.shape;
165
+ const ps = shp.start ?? shp.ps;
166
+ const pe = shp.end ?? shp.pe;
167
+ outline.push([
168
+ { x: ps.x, y: ps.y },
169
+ { x: pe.x, y: pe.y }
170
+ ]);
171
+ edge = edge.next;
172
+ } while (edge !== face.first);
173
+ outlines.push(outline);
174
+ }
175
+ return outlines;
176
+ };
177
+
178
+ // lib/testing/createColorMapFromStrings.ts
179
+ var createColorMapFromStrings = (strings) => {
180
+ const colorMap = {};
181
+ for (let i = 0; i < strings.length; i++) {
182
+ colorMap[strings[i]] = `hsl(${i * 300 / strings.length}, 100%, 50%)`;
183
+ }
184
+ return colorMap;
185
+ };
186
+
187
+ // lib/testing/getGraphicsFromPackOutput.ts
188
+ var getGraphicsFromPackOutput = (packOutput) => {
189
+ const rects = [];
190
+ const lines = [];
191
+ const allNetworkIds = Array.from(
192
+ new Set(
193
+ packOutput.components.flatMap((c) => c.pads.map((p) => p.networkId))
194
+ )
195
+ );
196
+ const colorMap = createColorMapFromStrings(allNetworkIds);
197
+ for (const component of packOutput.components) {
198
+ const bounds = getComponentBounds(component);
199
+ const width = bounds.maxX - bounds.minX;
200
+ const height = bounds.maxY - bounds.minY;
201
+ const rect = {
202
+ center: { x: component.center.x, y: component.center.y },
203
+ width,
204
+ height,
205
+ fill: "rgba(0,0,0,0.25)",
206
+ label: component.componentId
207
+ };
208
+ rects.push(rect);
209
+ for (const pad of component.pads) {
210
+ const { absoluteCenter, size, padId, networkId } = pad;
211
+ const padRect = {
212
+ center: { x: absoluteCenter.x, y: absoluteCenter.y },
213
+ width: size.x,
214
+ height: size.y,
215
+ label: `${padId} ${networkId}`,
216
+ fill: "rgba(255,0,0,0.8)"
217
+ };
218
+ rects.push(padRect);
219
+ }
220
+ }
221
+ for (const netId of allNetworkIds) {
222
+ const padsOnNet = packOutput.components.flatMap(
223
+ (c) => c.pads.filter((p) => p.networkId === netId)
224
+ );
225
+ for (let i = 0; i < padsOnNet.length; i++) {
226
+ for (let j = i + 1; j < padsOnNet.length; j++) {
227
+ lines.push({
228
+ points: [padsOnNet[i].absoluteCenter, padsOnNet[j].absoluteCenter],
229
+ strokeColor: colorMap[netId]
230
+ });
231
+ }
232
+ }
233
+ }
234
+ return {
235
+ coordinateSystem: "cartesian",
236
+ rects,
237
+ lines
238
+ };
239
+ };
240
+
241
+ // lib/PackSolver/setPackedComponentPadCenters.ts
242
+ var setPackedComponentPadCenters = (packedComponent) => {
243
+ packedComponent.pads = packedComponent.pads.map((pad) => ({
244
+ ...pad,
245
+ absoluteCenter: (() => {
246
+ const rotated = rotatePoint(pad.offset, packedComponent.ccwRotationOffset);
247
+ return {
248
+ x: packedComponent.center.x + rotated.x,
249
+ y: packedComponent.center.y + rotated.y
250
+ };
251
+ })()
252
+ }));
253
+ };
254
+
255
+ // lib/PackSolver/getSegmentsFromPad.ts
256
+ var getSegmentsFromPad = (pad, { padding = 0 } = {}) => {
257
+ const segments = [];
258
+ const { x, y } = pad.absoluteCenter;
259
+ const { x: w, y: h } = pad.size;
260
+ segments.push([
261
+ { x: x - w / 2 - padding, y: y - h / 2 - padding },
262
+ { x: x + w / 2 + padding, y: y - h / 2 - padding }
263
+ ]);
264
+ segments.push([
265
+ { x: x + w / 2 + padding, y: y - h / 2 - padding },
266
+ { x: x + w / 2 + padding, y: y + h / 2 + padding }
267
+ ]);
268
+ segments.push([
269
+ { x: x + w / 2 + padding, y: y + h / 2 + padding },
270
+ { x: x - w / 2 - padding, y: y + h / 2 + padding }
271
+ ]);
272
+ segments.push([
273
+ { x: x - w / 2 - padding, y: y + h / 2 + padding },
274
+ { x: x - w / 2 - padding, y: y - h / 2 - padding }
275
+ ]);
276
+ return segments;
277
+ };
278
+
279
+ // lib/math/computeNearestPointOnSegmentForSegmentSet.ts
280
+ var sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
281
+ var add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
282
+ var mul = (a, s) => ({ x: a.x * s, y: a.y * s });
283
+ var dot = (a, b) => a.x * b.x + a.y * b.y;
284
+ var clamp = (v, lo = 0, hi = 1) => Math.max(lo, Math.min(hi, v));
285
+ function closestPointOnSegAToSegB(segA, segB) {
286
+ const [p, q] = segA;
287
+ const [r, s] = segB;
288
+ const u = sub(q, p);
289
+ const v = sub(s, r);
290
+ const w0 = sub(p, r);
291
+ const a = dot(u, u);
292
+ const b = dot(u, v);
293
+ const c = dot(v, v);
294
+ const d = dot(u, w0);
295
+ const e = dot(v, w0);
296
+ const EPS = 1e-12;
297
+ const D = a * c - b * b;
298
+ let sN;
299
+ let tN;
300
+ let sD = D;
301
+ let tD = D;
302
+ if (D < EPS) {
303
+ sN = 0;
304
+ sD = 1;
305
+ tN = e;
306
+ tD = c;
307
+ } else {
308
+ sN = b * e - c * d;
309
+ tN = a * e - b * d;
310
+ if (sN < 0) {
311
+ sN = 0;
312
+ tN = e;
313
+ tD = c;
314
+ } else if (sN > sD) {
315
+ sN = sD;
316
+ tN = e + b;
317
+ tD = c;
318
+ }
319
+ }
320
+ if (tN < 0) {
321
+ tN = 0;
322
+ sN = clamp(-d, 0, a);
323
+ sD = a;
324
+ } else if (tN > tD) {
325
+ tN = tD;
326
+ sN = clamp(-d + b, 0, a);
327
+ sD = a;
328
+ }
329
+ const sParam = sD > EPS ? sN / sD : 0;
330
+ const closestA = add(p, mul(u, sParam));
331
+ const tParam = tD > EPS ? tN / tD : 0;
332
+ const closestB = add(r, mul(v, tParam));
333
+ const diff = sub(closestA, closestB);
334
+ return { pointA: closestA, paramA: sParam, dist2: dot(diff, diff) };
335
+ }
336
+ function computeNearestPointOnSegmentForSegmentSet(segmentA, segmentSet) {
337
+ if (!segmentSet.length)
338
+ throw new Error("segmentSet must contain at least one segment");
339
+ let bestPoint = segmentA[0];
340
+ let bestDist2 = Number.POSITIVE_INFINITY;
341
+ for (const segB of segmentSet) {
342
+ const { pointA, dist2 } = closestPointOnSegAToSegB(segmentA, segB);
343
+ if (dist2 < bestDist2) {
344
+ bestDist2 = dist2;
345
+ bestPoint = pointA;
346
+ if (bestDist2 === 0) break;
347
+ }
348
+ }
349
+ return { nearestPoint: bestPoint, dist: Math.sqrt(bestDist2) };
350
+ }
351
+
352
+ // lib/PackSolver/PackSolver.ts
353
+ var PackSolver = class extends BaseSolver {
354
+ packInput;
355
+ unpackedComponentQueue;
356
+ packedComponents;
357
+ lastBestPointsResult;
358
+ constructor(input) {
359
+ super();
360
+ this.packInput = input;
361
+ }
362
+ _setup() {
363
+ const { components, packOrderStrategy } = this.packInput;
364
+ this.unpackedComponentQueue = [...components].sort((a, b) => {
365
+ if (packOrderStrategy === "largest_to_smallest") {
366
+ return b.pads.length - a.pads.length;
367
+ }
368
+ return a.pads.length - b.pads.length;
369
+ });
370
+ this.packedComponents = [];
371
+ }
372
+ _step() {
373
+ if (this.solved) return;
374
+ const { minGap = 0, disconnectedPackDirection = "right" } = this.packInput;
375
+ if (this.unpackedComponentQueue.length === 0) {
376
+ this.solved = true;
377
+ return;
378
+ }
379
+ const next = this.unpackedComponentQueue.shift();
380
+ if (!next) {
381
+ this.solved = true;
382
+ return;
383
+ }
384
+ const newPackedComponent = {
385
+ ...next,
386
+ center: { x: 0, y: 0 },
387
+ ccwRotationOffset: 0,
388
+ pads: next.pads.map((p) => ({
389
+ ...p,
390
+ absoluteCenter: { x: 0, y: 0 }
391
+ }))
392
+ };
393
+ if (this.packedComponents.length === 0) {
394
+ newPackedComponent.center = { x: 0, y: 0 };
395
+ setPackedComponentPadCenters(newPackedComponent);
396
+ this.packedComponents.push(newPackedComponent);
397
+ return;
398
+ }
399
+ const padMargins = newPackedComponent.pads.map(
400
+ (p) => Math.max(p.size.x, p.size.y) / 2
401
+ );
402
+ const additionalGap = Math.max(...padMargins);
403
+ const outlines = constructOutlinesFromPackedComponents(
404
+ this.packedComponents,
405
+ { minGap: minGap + additionalGap }
406
+ );
407
+ const networkIdsInPackedComponents = new Set(
408
+ this.packedComponents.flatMap((c) => c.pads.map((p) => p.networkId))
409
+ );
410
+ const networkIdsInNewPackedComponent = new Set(
411
+ newPackedComponent.pads.map((p) => p.networkId)
412
+ );
413
+ const sharedNetworkIds = new Set(
414
+ [...networkIdsInPackedComponents].filter(
415
+ (id) => networkIdsInNewPackedComponent.has(id)
416
+ )
417
+ );
418
+ if (sharedNetworkIds.size === 0) {
419
+ throw new Error(
420
+ "Use the disconnectedPackDirection to place the new component"
421
+ );
422
+ }
423
+ const networkIdToAlreadyPackedSegments = /* @__PURE__ */ new Map();
424
+ for (const sharedNetworkId of sharedNetworkIds) {
425
+ networkIdToAlreadyPackedSegments.set(sharedNetworkId, []);
426
+ for (const packedComponent of this.packedComponents) {
427
+ for (const pad of packedComponent.pads) {
428
+ if (pad.networkId !== sharedNetworkId) continue;
429
+ const segments = getSegmentsFromPad(pad);
430
+ networkIdToAlreadyPackedSegments.set(sharedNetworkId, segments);
431
+ }
432
+ }
433
+ }
434
+ let smallestDistance = Number.POSITIVE_INFINITY;
435
+ let bestPoints = [];
436
+ for (const outline of outlines) {
437
+ for (const outlineSegment of outline) {
438
+ for (const sharedNetworkId of sharedNetworkIds) {
439
+ const alreadyPackedSegments = networkIdToAlreadyPackedSegments.get(sharedNetworkId);
440
+ if (!alreadyPackedSegments) continue;
441
+ const {
442
+ nearestPoint: nearestPointOnOutlineToAlreadyPackedSegments,
443
+ dist: outlineToAlreadyPackedSegmentsDist
444
+ } = computeNearestPointOnSegmentForSegmentSet(
445
+ outlineSegment,
446
+ alreadyPackedSegments
447
+ );
448
+ if (outlineToAlreadyPackedSegmentsDist < smallestDistance + 1e-6) {
449
+ if (outlineToAlreadyPackedSegmentsDist < smallestDistance - 1e-6) {
450
+ bestPoints = [
451
+ {
452
+ ...nearestPointOnOutlineToAlreadyPackedSegments,
453
+ networkId: sharedNetworkId
454
+ }
455
+ ];
456
+ smallestDistance = outlineToAlreadyPackedSegmentsDist;
457
+ } else {
458
+ bestPoints.push({
459
+ ...nearestPointOnOutlineToAlreadyPackedSegments,
460
+ networkId: sharedNetworkId
461
+ });
462
+ }
463
+ }
464
+ }
465
+ }
466
+ }
467
+ this.lastBestPointsResult = {
468
+ bestPoints,
469
+ distance: smallestDistance
470
+ };
471
+ for (const bestPoint of bestPoints) {
472
+ const networkId = bestPoint.networkId;
473
+ const newPadsConnectedToNetworkId = newPackedComponent.pads.filter(
474
+ (p) => p.networkId === networkId
475
+ );
476
+ const candidateAngles = [0, Math.PI / 2, Math.PI, 3 * Math.PI / 2];
477
+ let bestCandidate = null;
478
+ const packedPads = this.packedComponents.flatMap((c) => c.pads);
479
+ for (const angle of candidateAngles) {
480
+ const firstPad = newPadsConnectedToNetworkId[0];
481
+ if (!firstPad) continue;
482
+ const rotatedOffset = rotatePoint(firstPad.offset, angle);
483
+ const candidateCenter = {
484
+ x: bestPoint.x - rotatedOffset.x,
485
+ y: bestPoint.y - rotatedOffset.y
486
+ };
487
+ const transformedPads = newPackedComponent.pads.map((p) => {
488
+ const ro = rotatePoint(p.offset, angle);
489
+ return {
490
+ ...p,
491
+ absoluteCenter: {
492
+ x: candidateCenter.x + ro.x,
493
+ y: candidateCenter.y + ro.y
494
+ }
495
+ };
496
+ });
497
+ const tempComponent = {
498
+ ...newPackedComponent,
499
+ center: candidateCenter,
500
+ ccwRotationOffset: angle
501
+ };
502
+ const candBounds = getComponentBounds(tempComponent, 0);
503
+ const overlapsWithPackedComponent = this.packedComponents.some((pc) => {
504
+ const pcBounds = getComponentBounds(pc, 0);
505
+ return candBounds.minX < pcBounds.maxX && candBounds.maxX > pcBounds.minX && candBounds.minY < pcBounds.maxY && candBounds.maxY > pcBounds.minY;
506
+ });
507
+ if (overlapsWithPackedComponent) continue;
508
+ let cost = 0;
509
+ for (const tp of transformedPads) {
510
+ const sameNetPads = packedPads.filter(
511
+ (pp) => pp.networkId === tp.networkId
512
+ );
513
+ if (!sameNetPads.length) continue;
514
+ let bestD = Infinity;
515
+ for (const pp of sameNetPads) {
516
+ const dx = tp.absoluteCenter.x - pp.absoluteCenter.x;
517
+ const dy = tp.absoluteCenter.y - pp.absoluteCenter.y;
518
+ const d = Math.hypot(dx, dy);
519
+ if (d < bestD) bestD = d;
520
+ }
521
+ cost += bestD;
522
+ }
523
+ if (!bestCandidate || cost < bestCandidate.cost) {
524
+ bestCandidate = { center: candidateCenter, angle, cost };
525
+ }
526
+ }
527
+ if (bestCandidate) {
528
+ newPackedComponent.center = bestCandidate.center;
529
+ newPackedComponent.ccwRotationOffset = bestCandidate.angle;
530
+ } else {
531
+ console.log("no valid rotation found");
532
+ const firstPad = newPadsConnectedToNetworkId[0];
533
+ const candidateCenter = {
534
+ x: bestPoint.x - firstPad.offset.x,
535
+ y: bestPoint.y - firstPad.offset.y
536
+ };
537
+ newPackedComponent.center = candidateCenter;
538
+ newPackedComponent.ccwRotationOffset = 0;
539
+ }
540
+ setPackedComponentPadCenters(newPackedComponent);
541
+ }
542
+ setPackedComponentPadCenters(newPackedComponent);
543
+ this.packedComponents.push(newPackedComponent);
544
+ }
545
+ getConstructorParams() {
546
+ return [this.packInput];
547
+ }
548
+ /** Visualize the current packing state – components are omitted, only the outline is shown. */
549
+ visualize() {
550
+ const graphics = getGraphicsFromPackOutput({
551
+ components: this.packedComponents ?? [],
552
+ minGap: this.packInput.minGap,
553
+ packOrderStrategy: this.packInput.packOrderStrategy,
554
+ packPlacementStrategy: this.packInput.packPlacementStrategy,
555
+ disconnectedPackDirection: this.packInput.disconnectedPackDirection
556
+ });
557
+ graphics.points ??= [];
558
+ graphics.lines ??= [];
559
+ const outlines = constructOutlinesFromPackedComponents(
560
+ this.packedComponents ?? [],
561
+ {
562
+ minGap: this.packInput.minGap
563
+ }
564
+ );
565
+ graphics.lines.push(
566
+ ...outlines.flatMap(
567
+ (outline) => outline.map(
568
+ ([p1, p2]) => ({
569
+ points: [p1, p2],
570
+ stroke: "#ff4444"
571
+ })
572
+ )
573
+ )
574
+ );
575
+ if (this.lastBestPointsResult) {
576
+ for (const bestPoint of this.lastBestPointsResult.bestPoints) {
577
+ graphics.points.push({
578
+ x: bestPoint.x,
579
+ y: bestPoint.y,
580
+ label: `bestPoint
581
+ networkId: ${bestPoint.networkId}
582
+ d=${this.lastBestPointsResult.distance}`
583
+ });
584
+ }
585
+ }
586
+ return graphics;
587
+ }
588
+ getResult() {
589
+ return this.packedComponents;
590
+ }
591
+ };
592
+
593
+ // lib/pack.ts
594
+ var pack = (input) => {
595
+ console.log("pack", input);
596
+ const solver = new PackSolver(input);
597
+ solver.solve();
598
+ return {
599
+ ...input,
600
+ components: solver.packedComponents
601
+ };
602
+ };
603
+
604
+ // lib/testing/convertCircuitJsonToPackOutput.ts
605
+ import { cju } from "@tscircuit/circuit-json-util";
606
+ var convertCircuitJsonToPackOutput = (circuitJson) => {
607
+ const packOutput = {
608
+ components: [],
609
+ minGap: 0,
610
+ packOrderStrategy: "largest_to_smallest",
611
+ packPlacementStrategy: "shortest_connection_along_outline"
612
+ };
613
+ const db = cju(circuitJson);
614
+ const pcbComponents = db.pcb_component.list();
615
+ let unnamedCounter = 0;
616
+ const getNetworkId = (pcbPortId) => {
617
+ if (pcbPortId) {
618
+ const pcbPort = db.pcb_port.get(pcbPortId);
619
+ if (pcbPort) {
620
+ const sourcePort = db.source_port.get(pcbPort.source_port_id);
621
+ if (sourcePort?.subcircuit_connectivity_map_key) {
622
+ return sourcePort.subcircuit_connectivity_map_key;
623
+ }
624
+ }
625
+ }
626
+ return `unnamed${unnamedCounter++}`;
627
+ };
628
+ for (const pcbComponent of pcbComponents) {
629
+ const pads = [];
630
+ const platedHoles = db.pcb_plated_hole.list({
631
+ pcb_component_id: pcbComponent.pcb_component_id
632
+ });
633
+ for (const platedHole of platedHoles) {
634
+ const sx = platedHole.rect_pad_width ?? platedHole.outer_diameter ?? platedHole.hole_diameter ?? 0;
635
+ const sy = platedHole.rect_pad_height ?? platedHole.outer_diameter ?? platedHole.hole_diameter ?? 0;
636
+ const networkId = getNetworkId(platedHole.pcb_port_id);
637
+ const pad = {
638
+ padId: platedHole.pcb_plated_hole_id,
639
+ networkId,
640
+ type: "rect",
641
+ offset: {
642
+ x: platedHole.x - pcbComponent.center.x,
643
+ y: platedHole.y - pcbComponent.center.y
644
+ },
645
+ size: { x: sx, y: sy },
646
+ absoluteCenter: {
647
+ x: platedHole.x,
648
+ y: platedHole.y
649
+ }
650
+ };
651
+ pads.push(pad);
652
+ }
653
+ const smtPads = db.pcb_smtpad.list({
654
+ pcb_component_id: pcbComponent.pcb_component_id
655
+ });
656
+ for (const smtPad of smtPads) {
657
+ if (smtPad.shape === "polygon") {
658
+ throw new Error("Polygon pads are not supported in pack layout yet");
659
+ }
660
+ const networkId = getNetworkId(smtPad.pcb_port_id);
661
+ const pad = {
662
+ padId: smtPad.pcb_smtpad_id,
663
+ networkId,
664
+ type: "rect",
665
+ offset: {
666
+ x: smtPad.x - pcbComponent.center.x,
667
+ y: smtPad.y - pcbComponent.center.y
668
+ },
669
+ size: {
670
+ x: smtPad.width ?? 0,
671
+ y: smtPad.height ?? 0
672
+ },
673
+ absoluteCenter: {
674
+ x: smtPad.x,
675
+ y: smtPad.y
676
+ }
677
+ };
678
+ pads.push(pad);
679
+ }
680
+ const packedComponent = {
681
+ componentId: pcbComponent.pcb_component_id,
682
+ pads,
683
+ center: {
684
+ x: pcbComponent.center.x,
685
+ y: pcbComponent.center.y
686
+ },
687
+ ccwRotationOffset: 0
688
+ };
689
+ packOutput.components.push(packedComponent);
690
+ }
691
+ return packOutput;
692
+ };
693
+
694
+ // lib/plumbing/convertPackOutputToPackInput.ts
695
+ var convertPackOutputToPackInput = (packed) => {
696
+ const strippedComponents = packed.components.map((pc) => ({
697
+ componentId: pc.componentId,
698
+ pads: pc.pads.map(({ absoluteCenter: _ac, ...rest }) => rest)
699
+ }));
700
+ return {
701
+ ...packed,
702
+ // overwrite the components field with strippedComponents
703
+ components: strippedComponents
704
+ };
705
+ };
706
+ export {
707
+ PackSolver,
708
+ convertCircuitJsonToPackOutput,
709
+ convertPackOutputToPackInput,
710
+ getGraphicsFromPackOutput,
711
+ pack
712
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "calculate-packing",
3
+ "main": "dist/index.js",
4
+ "type": "module",
5
+ "version": "0.0.1",
6
+ "description": "Calculate a packing layout with support for different strategy configurations",
7
+ "scripts": {
8
+ "start": "cosmos",
9
+ "format": "biome format --write .",
10
+ "build": "tsup-node lib/index.ts --format esm --dts --outDir dist",
11
+ "build:site": "cosmos-export"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "devDependencies": {
17
+ "@biomejs/biome": "^2.1.1",
18
+ "@flatten-js/core": "^1.6.2",
19
+ "@react-hook/resize-observer": "^2.0.2",
20
+ "@tscircuit/circuit-json-util": "^0.0.52",
21
+ "@tscircuit/footprinter": "^0.0.203",
22
+ "@tscircuit/math-utils": "^0.0.19",
23
+ "@types/bun": "latest",
24
+ "@types/react": "^19.1.8",
25
+ "@types/react-dom": "^19.1.6",
26
+ "bun-match-svg": "^0.0.12",
27
+ "circuit-json": "^0.0.220",
28
+ "graphics-debug": "^0.0.60",
29
+ "react": "^19.1.0",
30
+ "react-cosmos": "^7.0.0",
31
+ "react-cosmos-plugin-vite": "^7.0.0",
32
+ "react-dom": "^19.1.0",
33
+ "tsup": "^8.5.0",
34
+ "vite": "^7.0.5"
35
+ },
36
+ "peerDependencies": {
37
+ "typescript": "^5",
38
+ "@tscircuit/circuit-json-util": "*"
39
+ }
40
+ }