@tscircuit/copper-pour-solver 0.0.9 → 0.0.11

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/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ interface InputPourRegion {
11
11
  padMargin: number;
12
12
  traceMargin: number;
13
13
  board_edge_margin?: number;
14
+ cutout_margin?: number;
14
15
  }
15
16
  interface BaseInputPad {
16
17
  padId: string;
@@ -32,7 +33,11 @@ interface InputTracePad extends BaseInputPad {
32
33
  width: number;
33
34
  segments: Point[];
34
35
  }
35
- type InputPad = InputRectPad | InputCircularPad | InputTracePad;
36
+ interface InputPolygonPad extends BaseInputPad {
37
+ shape: "polygon";
38
+ points: Point[];
39
+ }
40
+ type InputPad = InputRectPad | InputCircularPad | InputTracePad | InputPolygonPad;
36
41
  interface InputProblem {
37
42
  regionsForPour: InputPourRegion[];
38
43
  pads: InputPad[];
@@ -54,6 +59,7 @@ declare const convertCircuitJsonToInputProblem: (circuitJson: AnyCircuitElement[
54
59
  pad_margin: number;
55
60
  trace_margin: number;
56
61
  board_edge_margin?: number;
62
+ cutout_margin?: number;
57
63
  }) => InputProblem;
58
64
 
59
- export { type BaseInputPad, CopperPourPipelineSolver, type InputCircularPad, type InputPad, type InputPourRegion, type InputProblem, type InputRectPad, type InputTracePad, type PipelineOutput, convertCircuitJsonToInputProblem };
65
+ export { type BaseInputPad, CopperPourPipelineSolver, type InputCircularPad, type InputPad, type InputPolygonPad, type InputPourRegion, type InputProblem, type InputRectPad, type InputTracePad, type PipelineOutput, convertCircuitJsonToInputProblem };
package/dist/index.js CHANGED
@@ -57,9 +57,10 @@ var circleToPolygon = (circle, numSegments = 32) => {
57
57
  var isRectPad = (pad) => pad.shape === "rect";
58
58
  var isTracePad = (pad) => pad.shape === "trace";
59
59
  var isCircularPad = (pad) => pad.shape === "circle";
60
+ var isPolygonPad = (pad) => pad.shape === "polygon";
60
61
  var processObstaclesForPour = (pads, pourConnectivityKey, margins, boardOutline) => {
61
62
  const polygonsToSubtract = [];
62
- const { padMargin, traceMargin, board_edge_margin } = margins;
63
+ const { padMargin, traceMargin, board_edge_margin, cutoutMargin } = margins;
63
64
  if (boardOutline && boardOutline.length > 0 && board_edge_margin && board_edge_margin > 0) {
64
65
  const boardPoly = new Flatten3.Polygon(
65
66
  boardOutline.map((p) => Flatten3.point(p.x, p.y))
@@ -123,9 +124,18 @@ var processObstaclesForPour = (pads, pourConnectivityKey, margins, boardOutline)
123
124
  if (isOnNet) {
124
125
  continue;
125
126
  }
127
+ const isHoleOrCutout = pad.connectivityKey.startsWith("hole:") || pad.connectivityKey.startsWith("cutout:");
126
128
  if (isCircularPad(pad)) {
127
- const isHole = pad.connectivityKey.startsWith("hole:");
128
- const margin = isHole ? 0 : padMargin;
129
+ const margin = isHoleOrCutout ? cutoutMargin ?? 0 : padMargin;
130
+ if (isHoleOrCutout) {
131
+ console.log(
132
+ `Applying cutout margin to circular pad/hole/cutout: ${pad.padId}, margin: ${margin}`
133
+ );
134
+ } else {
135
+ console.log(
136
+ `Applying pad margin to circular pad: ${pad.padId}, margin: ${margin}`
137
+ );
138
+ }
129
139
  const circle = new Flatten3.Circle(
130
140
  new Flatten3.Point(pad.x, pad.y),
131
141
  pad.radius + margin
@@ -134,16 +144,83 @@ var processObstaclesForPour = (pads, pourConnectivityKey, margins, boardOutline)
134
144
  continue;
135
145
  }
136
146
  if (isRectPad(pad)) {
147
+ const margin = isHoleOrCutout ? cutoutMargin ?? 0 : padMargin;
148
+ if (isHoleOrCutout) {
149
+ console.log(
150
+ `Applying cutout margin to rect pad/cutout: ${pad.padId}, margin: ${margin}`
151
+ );
152
+ } else {
153
+ console.log(
154
+ `Applying pad margin to rect pad: ${pad.padId}, margin: ${margin}`
155
+ );
156
+ }
137
157
  const { bounds } = pad;
138
158
  const b = new Flatten3.Box(
139
- bounds.minX - padMargin,
140
- bounds.minY - padMargin,
141
- bounds.maxX + padMargin,
142
- bounds.maxY + padMargin
159
+ bounds.minX - margin,
160
+ bounds.minY - margin,
161
+ bounds.maxX + margin,
162
+ bounds.maxY + margin
143
163
  );
144
164
  polygonsToSubtract.push(new Flatten3.Polygon(b.toPoints()));
145
165
  continue;
146
166
  }
167
+ if (isPolygonPad(pad)) {
168
+ const margin = isHoleOrCutout ? cutoutMargin ?? 0 : 0;
169
+ if (isHoleOrCutout) {
170
+ console.log(
171
+ `Applying cutout margin to polygon cutout: ${pad.padId}, margin: ${margin}`
172
+ );
173
+ }
174
+ const seen = /* @__PURE__ */ new Set();
175
+ const uniquePoints = pad.points.filter((p) => {
176
+ const key = `${p.x},${p.y}`;
177
+ if (seen.has(key)) {
178
+ console.log(
179
+ `Duplicate point detected and removed for ${pad.padId}: (${p.x}, ${p.y})`
180
+ );
181
+ return false;
182
+ }
183
+ seen.add(key);
184
+ return true;
185
+ });
186
+ if (uniquePoints.length < 3) continue;
187
+ const polygon = new Flatten3.Polygon(
188
+ uniquePoints.map((p) => Flatten3.point(p.x, p.y))
189
+ );
190
+ if (Math.abs(polygon.area()) < 1e-9) continue;
191
+ if (margin <= 0) {
192
+ polygonsToSubtract.push(polygon);
193
+ continue;
194
+ }
195
+ if (polygon.area() > 0) {
196
+ polygon.reverse();
197
+ }
198
+ const offsetLines = [];
199
+ const polygonVertices = polygon.vertices;
200
+ for (let i = 0; i < polygonVertices.length; i++) {
201
+ const p1 = polygonVertices[i];
202
+ const p2 = polygonVertices[(i + 1) % polygonVertices.length];
203
+ const segment = Flatten3.segment(p1, p2);
204
+ if (segment.length === 0) continue;
205
+ const line = Flatten3.line(segment.start, segment.end);
206
+ const norm = line.norm;
207
+ const offsetLine = line.translate(norm.multiply(-margin));
208
+ offsetLines.push(offsetLine);
209
+ }
210
+ const newPolygonPoints = [];
211
+ for (let i = 0; i < offsetLines.length; i++) {
212
+ const line1 = offsetLines[i];
213
+ const line2 = offsetLines[(i + 1) % offsetLines.length];
214
+ const ip = line1.intersect(line2);
215
+ if (ip.length > 0) {
216
+ newPolygonPoints.push(ip[0]);
217
+ }
218
+ }
219
+ if (newPolygonPoints.length >= 3) {
220
+ polygonsToSubtract.push(new Flatten3.Polygon(newPolygonPoints));
221
+ }
222
+ continue;
223
+ }
147
224
  if (isTracePad(pad)) {
148
225
  for (const segment of pad.segments) {
149
226
  const circle = new Flatten3.Circle(
@@ -254,7 +331,8 @@ var CopperPourPipelineSolver = class extends BasePipelineSolver {
254
331
  {
255
332
  padMargin: region.padMargin,
256
333
  traceMargin: region.traceMargin,
257
- board_edge_margin: region.board_edge_margin
334
+ board_edge_margin: region.board_edge_margin,
335
+ cutoutMargin: region.cutout_margin
258
336
  },
259
337
  region.outline
260
338
  );
@@ -294,6 +372,9 @@ var convertCircuitJsonToInputProblem = (circuitJson, options) => {
294
372
  const source_traces = circuitJson.filter(
295
373
  (e) => e.type === "source_trace"
296
374
  );
375
+ const pcb_traces = circuitJson.filter(
376
+ (e) => e.type === "pcb_trace"
377
+ );
297
378
  const source_nets = circuitJson.filter(
298
379
  (e) => e.type === "source_net"
299
380
  );
@@ -333,6 +414,14 @@ var convertCircuitJsonToInputProblem = (circuitJson, options) => {
333
414
  ...sourceTraceIdToConnectivityKey,
334
415
  ...sourceNetIdToConnectivityKey
335
416
  };
417
+ const pcbTraceIdToConnectivityKey = Object.fromEntries(
418
+ pcb_traces.map(
419
+ (trace) => [
420
+ trace.pcb_trace_id,
421
+ trace.source_trace_id ? idToConnectivityKey[trace.source_trace_id] : void 0
422
+ ]
423
+ ).filter((entry) => Boolean(entry[1]))
424
+ );
336
425
  const pads = [];
337
426
  for (const elm of circuitJson) {
338
427
  if (elm.type === "pcb_smtpad") {
@@ -399,25 +488,100 @@ var convertCircuitJsonToInputProblem = (circuitJson, options) => {
399
488
  y: hole.y,
400
489
  radius: hole.hole_diameter / 2
401
490
  });
491
+ } else if (elm.type === "pcb_cutout") {
492
+ const cutout = elm;
493
+ console.log(
494
+ `Processing cutout: ${cutout.pcb_cutout_id} (shape: ${cutout.shape})`
495
+ );
496
+ if (cutout.shape === "rect") {
497
+ pads.push({
498
+ shape: "rect",
499
+ padId: cutout.pcb_cutout_id,
500
+ layer: options.layer,
501
+ // through-all
502
+ connectivityKey: `cutout:${cutout.pcb_cutout_id}`,
503
+ bounds: {
504
+ minX: cutout.center.x - cutout.width / 2,
505
+ minY: cutout.center.y - cutout.height / 2,
506
+ maxX: cutout.center.x + cutout.width / 2,
507
+ maxY: cutout.center.y + cutout.height / 2
508
+ }
509
+ });
510
+ } else if (cutout.shape === "circle") {
511
+ pads.push({
512
+ shape: "circle",
513
+ padId: cutout.pcb_cutout_id,
514
+ layer: options.layer,
515
+ // through-all
516
+ connectivityKey: `cutout:${cutout.pcb_cutout_id}`,
517
+ x: cutout.center.x,
518
+ y: cutout.center.y,
519
+ radius: cutout.radius
520
+ });
521
+ } else if (cutout.shape === "polygon") {
522
+ console.log(
523
+ `Polygon cutout points for ${cutout.pcb_cutout_id}: ${JSON.stringify(cutout.points)}`
524
+ );
525
+ pads.push({
526
+ shape: "polygon",
527
+ padId: cutout.pcb_cutout_id,
528
+ layer: options.layer,
529
+ // through-all
530
+ connectivityKey: `cutout:${cutout.pcb_cutout_id}`,
531
+ points: cutout.points
532
+ });
533
+ }
534
+ } else if (elm.type === "pcb_via") {
535
+ const via = elm;
536
+ if (!via.layers.includes(options.layer)) continue;
537
+ let connectivityKey;
538
+ if (via.pcb_trace_id) {
539
+ connectivityKey = pcbTraceIdToConnectivityKey[via.pcb_trace_id];
540
+ }
541
+ if (!connectivityKey) {
542
+ connectivityKey = `unconnected-via:${via.pcb_via_id}`;
543
+ }
544
+ pads.push({
545
+ shape: "circle",
546
+ padId: via.pcb_via_id,
547
+ layer: options.layer,
548
+ connectivityKey,
549
+ x: via.x,
550
+ y: via.y,
551
+ radius: via.outer_diameter / 2
552
+ });
402
553
  } else if (elm.type === "pcb_trace") {
403
554
  const trace = elm;
404
- const first_wire = trace.route.find(
405
- (r) => r.route_type === "wire" && r.layer
406
- );
407
- if (!first_wire) continue;
408
- if (first_wire.route_type === "via") continue;
409
- if (first_wire.layer !== options.layer) continue;
410
555
  if (!trace.source_trace_id) continue;
411
556
  const connectivityKey = idToConnectivityKey[trace.source_trace_id];
412
557
  if (!connectivityKey) continue;
413
- pads.push({
414
- shape: "trace",
415
- padId: trace.pcb_trace_id,
416
- layer: first_wire.layer,
417
- connectivityKey,
418
- segments: trace.route.map((r) => ({ x: r.x, y: r.y })),
419
- width: first_wire.width
420
- });
558
+ let currentSegmentGroup = [];
559
+ let currentWidth = null;
560
+ const commitGroup = () => {
561
+ if (currentSegmentGroup.length > 1) {
562
+ pads.push({
563
+ shape: "trace",
564
+ padId: `${trace.pcb_trace_id}-${pads.length}`,
565
+ layer: options.layer,
566
+ connectivityKey,
567
+ segments: currentSegmentGroup,
568
+ width: currentWidth
569
+ });
570
+ }
571
+ currentSegmentGroup = [];
572
+ currentWidth = null;
573
+ };
574
+ for (const r of trace.route) {
575
+ const ri = r;
576
+ const isWireOnLayer = ri.route_type === "wire" && ri.layer === options.layer;
577
+ if (isWireOnLayer) {
578
+ if (currentWidth === null) currentWidth = ri.width;
579
+ currentSegmentGroup.push({ x: ri.x, y: ri.y });
580
+ } else {
581
+ commitGroup();
582
+ }
583
+ }
584
+ commitGroup();
421
585
  }
422
586
  }
423
587
  const { width, height } = pcb_board;
@@ -435,7 +599,8 @@ var convertCircuitJsonToInputProblem = (circuitJson, options) => {
435
599
  connectivityKey: options.pour_connectivity_key,
436
600
  padMargin: options.pad_margin,
437
601
  traceMargin: options.trace_margin,
438
- board_edge_margin: options.board_edge_margin ?? 0
602
+ board_edge_margin: options.board_edge_margin ?? 0,
603
+ cutout_margin: options.cutout_margin
439
604
  }
440
605
  ];
441
606
  return {
@@ -7,6 +7,8 @@ import type {
7
7
  PcbPort,
8
8
  PcbSmtPad,
9
9
  PcbTrace,
10
+ PcbVia,
11
+ Point,
10
12
  SourceNet,
11
13
  SourcePort,
12
14
  SourceTrace,
@@ -14,6 +16,7 @@ import type {
14
16
  import type {
15
17
  InputCircularPad,
16
18
  InputPad,
19
+ InputPolygonPad,
17
20
  InputProblem,
18
21
  InputRectPad,
19
22
  InputTracePad,
@@ -27,6 +30,7 @@ export const convertCircuitJsonToInputProblem = (
27
30
  pad_margin: number
28
31
  trace_margin: number
29
32
  board_edge_margin?: number
33
+ cutout_margin?: number
30
34
  },
31
35
  ): InputProblem => {
32
36
  const source_ports = circuitJson.filter(
@@ -38,6 +42,9 @@ export const convertCircuitJsonToInputProblem = (
38
42
  const source_traces = circuitJson.filter(
39
43
  (e) => e.type === "source_trace",
40
44
  ) as SourceTrace[]
45
+ const pcb_traces = circuitJson.filter(
46
+ (e) => e.type === "pcb_trace",
47
+ ) as PcbTrace[]
41
48
  const source_nets = circuitJson.filter(
42
49
  (e) => e.type === "source_net",
43
50
  ) as SourceNet[]
@@ -87,6 +94,21 @@ export const convertCircuitJsonToInputProblem = (
87
94
  ...sourceNetIdToConnectivityKey,
88
95
  }
89
96
 
97
+ const pcbTraceIdToConnectivityKey: Record<string, string> =
98
+ Object.fromEntries(
99
+ pcb_traces
100
+ .map(
101
+ (trace) =>
102
+ [
103
+ trace.pcb_trace_id,
104
+ trace.source_trace_id
105
+ ? idToConnectivityKey[trace.source_trace_id]
106
+ : undefined,
107
+ ] as const,
108
+ )
109
+ .filter((entry): entry is [string, string] => Boolean(entry[1])),
110
+ )
111
+
90
112
  const pads: InputPad[] = []
91
113
 
92
114
  for (const elm of circuitJson) {
@@ -160,27 +182,104 @@ export const convertCircuitJsonToInputProblem = (
160
182
  y: hole.y,
161
183
  radius: hole.hole_diameter / 2,
162
184
  } as InputCircularPad)
163
- } else if (elm.type === "pcb_trace") {
164
- const trace = elm as PcbTrace
165
- const first_wire = trace.route.find(
166
- (r: any) => r.route_type === "wire" && r.layer,
185
+ } else if (elm.type === "pcb_cutout") {
186
+ const cutout = elm as any
187
+ console.log(
188
+ `Processing cutout: ${cutout.pcb_cutout_id} (shape: ${cutout.shape})`,
167
189
  )
168
- if (!first_wire) continue
169
- if (first_wire.route_type === "via") continue
170
- if (first_wire.layer !== options.layer) continue
190
+ if (cutout.shape === "rect") {
191
+ pads.push({
192
+ shape: "rect",
193
+ padId: cutout.pcb_cutout_id,
194
+ layer: options.layer, // through-all
195
+ connectivityKey: `cutout:${cutout.pcb_cutout_id}`,
196
+ bounds: {
197
+ minX: cutout.center.x - cutout.width / 2,
198
+ minY: cutout.center.y - cutout.height / 2,
199
+ maxX: cutout.center.x + cutout.width / 2,
200
+ maxY: cutout.center.y + cutout.height / 2,
201
+ },
202
+ } as InputRectPad)
203
+ } else if (cutout.shape === "circle") {
204
+ pads.push({
205
+ shape: "circle",
206
+ padId: cutout.pcb_cutout_id,
207
+ layer: options.layer, // through-all
208
+ connectivityKey: `cutout:${cutout.pcb_cutout_id}`,
209
+ x: cutout.center.x,
210
+ y: cutout.center.y,
211
+ radius: cutout.radius,
212
+ } as InputCircularPad)
213
+ } else if (cutout.shape === "polygon") {
214
+ console.log(
215
+ `Polygon cutout points for ${cutout.pcb_cutout_id}: ${JSON.stringify(cutout.points)}`,
216
+ )
217
+ pads.push({
218
+ shape: "polygon",
219
+ padId: cutout.pcb_cutout_id,
220
+ layer: options.layer, // through-all
221
+ connectivityKey: `cutout:${cutout.pcb_cutout_id}`,
222
+ points: cutout.points,
223
+ } as InputPolygonPad)
224
+ }
225
+ } else if (elm.type === "pcb_via") {
226
+ const via = elm as PcbVia
227
+ if (!via.layers.includes(options.layer)) continue
228
+
229
+ let connectivityKey: string | undefined
230
+ if (via.pcb_trace_id) {
231
+ connectivityKey = pcbTraceIdToConnectivityKey[via.pcb_trace_id]
232
+ }
171
233
 
234
+ if (!connectivityKey) {
235
+ connectivityKey = `unconnected-via:${via.pcb_via_id}`
236
+ }
237
+
238
+ pads.push({
239
+ shape: "circle",
240
+ padId: via.pcb_via_id,
241
+ layer: options.layer,
242
+ connectivityKey,
243
+ x: via.x,
244
+ y: via.y,
245
+ radius: via.outer_diameter / 2,
246
+ } as InputCircularPad)
247
+ } else if (elm.type === "pcb_trace") {
248
+ const trace = elm as PcbTrace
172
249
  if (!trace.source_trace_id) continue
173
250
  const connectivityKey = idToConnectivityKey[trace.source_trace_id]
174
251
  if (!connectivityKey) continue
175
252
 
176
- pads.push({
177
- shape: "trace",
178
- padId: trace.pcb_trace_id,
179
- layer: first_wire.layer!,
180
- connectivityKey,
181
- segments: trace.route.map((r: any) => ({ x: r.x, y: r.y })),
182
- width: first_wire.width,
183
- } as InputTracePad)
253
+ let currentSegmentGroup: Point[] = []
254
+ let currentWidth: number | null = null
255
+
256
+ const commitGroup = () => {
257
+ if (currentSegmentGroup.length > 1) {
258
+ pads.push({
259
+ shape: "trace",
260
+ padId: `${trace.pcb_trace_id}-${pads.length}`,
261
+ layer: options.layer,
262
+ connectivityKey,
263
+ segments: currentSegmentGroup,
264
+ width: currentWidth!,
265
+ } as InputTracePad)
266
+ }
267
+ currentSegmentGroup = []
268
+ currentWidth = null
269
+ }
270
+
271
+ for (const r of trace.route) {
272
+ const ri = r as any
273
+ const isWireOnLayer =
274
+ ri.route_type === "wire" && ri.layer === options.layer
275
+ if (isWireOnLayer) {
276
+ if (currentWidth === null) currentWidth = ri.width
277
+ currentSegmentGroup.push({ x: ri.x, y: ri.y })
278
+ } else {
279
+ commitGroup()
280
+ }
281
+ }
282
+ commitGroup()
184
283
  }
185
284
  }
186
285
 
@@ -200,6 +299,7 @@ export const convertCircuitJsonToInputProblem = (
200
299
  padMargin: options.pad_margin,
201
300
  traceMargin: options.trace_margin,
202
301
  board_edge_margin: options.board_edge_margin ?? 0,
302
+ cutout_margin: options.cutout_margin,
203
303
  },
204
304
  ]
205
305
 
@@ -29,6 +29,7 @@ export class CopperPourPipelineSolver extends BasePipelineSolver<InputProblem> {
29
29
  padMargin: region.padMargin,
30
30
  traceMargin: region.traceMargin,
31
31
  board_edge_margin: region.board_edge_margin,
32
+ cutoutMargin: region.cutout_margin,
32
33
  },
33
34
  region.outline,
34
35
  )
@@ -3,6 +3,7 @@ import type { Point } from "@tscircuit/math-utils"
3
3
  import type {
4
4
  InputCircularPad,
5
5
  InputPad,
6
+ InputPolygonPad,
6
7
  InputRectPad,
7
8
  InputTracePad,
8
9
  } from "lib/types"
@@ -17,6 +18,8 @@ const isTracePad = (pad: InputPad): pad is InputTracePad =>
17
18
  pad.shape === "trace"
18
19
  const isCircularPad = (pad: InputPad): pad is InputCircularPad =>
19
20
  pad.shape === "circle"
21
+ const isPolygonPad = (pad: InputPad): pad is InputPolygonPad =>
22
+ pad.shape === "polygon"
20
23
 
21
24
  export const processObstaclesForPour = (
22
25
  pads: InputPad[],
@@ -25,12 +28,13 @@ export const processObstaclesForPour = (
25
28
  padMargin: number
26
29
  traceMargin: number
27
30
  board_edge_margin?: number
31
+ cutoutMargin?: number
28
32
  },
29
33
  boardOutline?: Point[],
30
34
  ): ProcessedObstacles => {
31
35
  const polygonsToSubtract: Flatten.Polygon[] = []
32
36
 
33
- const { padMargin, traceMargin, board_edge_margin } = margins
37
+ const { padMargin, traceMargin, board_edge_margin, cutoutMargin } = margins
34
38
 
35
39
  if (
36
40
  boardOutline &&
@@ -120,9 +124,21 @@ export const processObstaclesForPour = (
120
124
  continue
121
125
  }
122
126
 
127
+ const isHoleOrCutout =
128
+ pad.connectivityKey.startsWith("hole:") ||
129
+ pad.connectivityKey.startsWith("cutout:")
130
+
123
131
  if (isCircularPad(pad)) {
124
- const isHole = pad.connectivityKey.startsWith("hole:")
125
- const margin = isHole ? 0 : padMargin
132
+ const margin = isHoleOrCutout ? (cutoutMargin ?? 0) : padMargin
133
+ if (isHoleOrCutout) {
134
+ console.log(
135
+ `Applying cutout margin to circular pad/hole/cutout: ${pad.padId}, margin: ${margin}`,
136
+ )
137
+ } else {
138
+ console.log(
139
+ `Applying pad margin to circular pad: ${pad.padId}, margin: ${margin}`,
140
+ )
141
+ }
126
142
  const circle = new Flatten.Circle(
127
143
  new Flatten.Point(pad.x, pad.y),
128
144
  pad.radius + margin,
@@ -132,17 +148,103 @@ export const processObstaclesForPour = (
132
148
  }
133
149
 
134
150
  if (isRectPad(pad)) {
151
+ const margin = isHoleOrCutout ? (cutoutMargin ?? 0) : padMargin
152
+ if (isHoleOrCutout) {
153
+ console.log(
154
+ `Applying cutout margin to rect pad/cutout: ${pad.padId}, margin: ${margin}`,
155
+ )
156
+ } else {
157
+ console.log(
158
+ `Applying pad margin to rect pad: ${pad.padId}, margin: ${margin}`,
159
+ )
160
+ }
135
161
  const { bounds } = pad
136
162
  const b = new Flatten.Box(
137
- bounds.minX - padMargin,
138
- bounds.minY - padMargin,
139
- bounds.maxX + padMargin,
140
- bounds.maxY + padMargin,
163
+ bounds.minX - margin,
164
+ bounds.minY - margin,
165
+ bounds.maxX + margin,
166
+ bounds.maxY + margin,
141
167
  )
142
168
  polygonsToSubtract.push(new Flatten.Polygon(b.toPoints()))
143
169
  continue
144
170
  }
145
171
 
172
+ if (isPolygonPad(pad)) {
173
+ const margin = isHoleOrCutout ? (cutoutMargin ?? 0) : 0
174
+ if (isHoleOrCutout) {
175
+ console.log(
176
+ `Applying cutout margin to polygon cutout: ${pad.padId}, margin: ${margin}`,
177
+ )
178
+ }
179
+
180
+ const seen = new Set<string>()
181
+ const uniquePoints = pad.points.filter((p) => {
182
+ const key = `${p.x},${p.y}`
183
+ if (seen.has(key)) {
184
+ console.log(
185
+ `Duplicate point detected and removed for ${pad.padId}: (${p.x}, ${p.y})`,
186
+ )
187
+ return false
188
+ }
189
+ seen.add(key)
190
+ return true
191
+ })
192
+
193
+ if (uniquePoints.length < 3) continue
194
+
195
+ const polygon = new Flatten.Polygon(
196
+ uniquePoints.map((p) => Flatten.point(p.x, p.y)),
197
+ )
198
+
199
+ if (Math.abs(polygon.area()) < 1e-9) continue
200
+
201
+ if (margin <= 0) {
202
+ polygonsToSubtract.push(polygon)
203
+ continue
204
+ }
205
+
206
+ // Ensure polygon is CCW for consistent normal direction.
207
+ // In flatten-js, CCW corresponds to a negative area.
208
+ if (polygon.area() > 0) {
209
+ polygon.reverse()
210
+ }
211
+
212
+ const offsetLines: Flatten.Line[] = []
213
+ const polygonVertices = polygon.vertices
214
+ for (let i = 0; i < polygonVertices.length; i++) {
215
+ const p1 = polygonVertices[i]!
216
+ const p2 = polygonVertices[(i + 1) % polygonVertices.length]!
217
+
218
+ const segment = Flatten.segment(p1, p2)
219
+
220
+ if (segment.length === 0) continue
221
+
222
+ const line = Flatten.line(segment.start, segment.end)
223
+
224
+ // For a CCW polygon, the normal (rotated +90deg, i.e. "left") points inward.
225
+ // We must translate outward, so we use a negative margin.
226
+ const norm = line.norm
227
+ const offsetLine = line.translate(norm.multiply(-margin))
228
+ offsetLines.push(offsetLine)
229
+ }
230
+
231
+ const newPolygonPoints: Flatten.Point[] = []
232
+ for (let i = 0; i < offsetLines.length; i++) {
233
+ const line1 = offsetLines[i]!
234
+ const line2 = offsetLines[(i + 1) % offsetLines.length]!
235
+
236
+ const ip = line1.intersect(line2)
237
+ if (ip.length > 0) {
238
+ newPolygonPoints.push(ip[0]!)
239
+ }
240
+ }
241
+
242
+ if (newPolygonPoints.length >= 3) {
243
+ polygonsToSubtract.push(new Flatten.Polygon(newPolygonPoints))
244
+ }
245
+ continue
246
+ }
247
+
146
248
  if (isTracePad(pad)) {
147
249
  // Add circles for each vertex
148
250
  for (const segment of pad.segments) {
package/lib/types.ts CHANGED
@@ -10,6 +10,7 @@ export interface InputPourRegion {
10
10
  padMargin: number
11
11
  traceMargin: number
12
12
  board_edge_margin?: number
13
+ cutout_margin?: number
13
14
  }
14
15
 
15
16
  export interface BaseInputPad {
@@ -36,7 +37,16 @@ export interface InputTracePad extends BaseInputPad {
36
37
  segments: Point[]
37
38
  }
38
39
 
39
- export type InputPad = InputRectPad | InputCircularPad | InputTracePad
40
+ export interface InputPolygonPad extends BaseInputPad {
41
+ shape: "polygon"
42
+ points: Point[]
43
+ }
44
+
45
+ export type InputPad =
46
+ | InputRectPad
47
+ | InputCircularPad
48
+ | InputTracePad
49
+ | InputPolygonPad
40
50
 
41
51
  export interface InputProblem {
42
52
  regionsForPour: InputPourRegion[]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tscircuit/copper-pour-solver",
3
3
  "main": "dist/index.js",
4
- "version": "0.0.9",
4
+ "version": "0.0.11",
5
5
  "scripts": {
6
6
  "format": "biome format . --write",
7
7
  "format:check": "biome format .",