circuit-to-canvas 0.0.50 → 0.0.51

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.
Files changed (51) hide show
  1. package/dist/index.d.ts +7 -4
  2. package/dist/index.js +1425 -1240
  3. package/lib/drawer/CircuitToCanvasDrawer.ts +262 -312
  4. package/lib/drawer/elements/helper-functions/draw-pill.ts +39 -0
  5. package/lib/drawer/elements/helper-functions/draw-polygon.ts +25 -0
  6. package/lib/drawer/elements/helper-functions/draw-rounded-rect.ts +34 -0
  7. package/lib/drawer/elements/helper-functions/index.ts +3 -0
  8. package/lib/drawer/elements/pcb-board.ts +13 -3
  9. package/lib/drawer/elements/pcb-hole.ts +56 -338
  10. package/lib/drawer/elements/pcb-plated-hole.ts +154 -442
  11. package/lib/drawer/elements/pcb-smtpad.ts +3 -292
  12. package/lib/drawer/elements/pcb-soldermask/board.ts +44 -0
  13. package/lib/drawer/elements/pcb-soldermask/cutout.ts +74 -0
  14. package/lib/drawer/elements/pcb-soldermask/hole.ts +288 -0
  15. package/lib/drawer/elements/pcb-soldermask/index.ts +140 -0
  16. package/lib/drawer/elements/pcb-soldermask/plated-hole.ts +365 -0
  17. package/lib/drawer/elements/pcb-soldermask/smt-pad.ts +354 -0
  18. package/lib/drawer/elements/pcb-soldermask/via.ts +27 -0
  19. package/package.json +1 -1
  20. package/tests/board-snapshot/__snapshots__/usb-c-flashlight-board.snap.png +0 -0
  21. package/tests/board-snapshot/usb-c-flashlight-board.test.ts +1 -0
  22. package/tests/elements/__snapshots__/board-with-elements.snap.png +0 -0
  23. package/tests/elements/__snapshots__/brep-copper-pours.snap.png +0 -0
  24. package/tests/elements/__snapshots__/custom-outline-board.snap.png +0 -0
  25. package/tests/elements/__snapshots__/oval-plated-hole.snap.png +0 -0
  26. package/tests/elements/__snapshots__/pcb-board.snap.png +0 -0
  27. package/tests/elements/__snapshots__/pcb-comprehensive-soldermask-margin.snap.png +0 -0
  28. package/tests/elements/__snapshots__/pcb-fabrication-note-dimension.snap.png +0 -0
  29. package/tests/elements/__snapshots__/pcb-hole-soldermask-margin.snap.png +0 -0
  30. package/tests/elements/__snapshots__/pcb-keepout-layer-filter.snap.png +0 -0
  31. package/tests/elements/__snapshots__/pcb-keepout-multiple-layers.snap.png +0 -0
  32. package/tests/elements/__snapshots__/pcb-keepout-rect-and-circle.snap.png +0 -0
  33. package/tests/elements/__snapshots__/pcb-keepout-with-group-id.snap.png +0 -0
  34. package/tests/elements/__snapshots__/pcb-no-soldermask.snap.png +0 -0
  35. package/tests/elements/__snapshots__/pcb-plated-hole-soldermask-margin.snap.png +0 -0
  36. package/tests/elements/__snapshots__/pcb-plated-hole.snap.png +0 -0
  37. package/tests/elements/__snapshots__/pcb-silkscreen-on-component.snap.png +0 -0
  38. package/tests/elements/__snapshots__/pcb-silkscreen-oval.snap.png +0 -0
  39. package/tests/elements/__snapshots__/pcb-smtpad-asymmetric-soldermask-margin.snap.png +0 -0
  40. package/tests/elements/__snapshots__/pcb-smtpad-soldermask-coverage.snap.png +0 -0
  41. package/tests/elements/__snapshots__/pcb-smtpad-soldermask-margin.snap.png +0 -0
  42. package/tests/elements/__snapshots__/pill-plated-hole.snap.png +0 -0
  43. package/tests/elements/pcb-comprehensive-soldermask-margin.test.ts +2 -2
  44. package/tests/elements/pcb-hole-soldermask-margin.test.ts +155 -2
  45. package/tests/elements/pcb-no-soldermask.test.ts +1281 -0
  46. package/tests/elements/pcb-plated-hole-soldermask-margin.test.ts +1 -1
  47. package/tests/elements/pcb-plated-hole.test.ts +40 -4
  48. package/tests/elements/pcb-smtpad-asymmetric-soldermask-margin.test.ts +1 -1
  49. package/tests/elements/pcb-smtpad-soldermask-coverage.test.ts +1 -1
  50. package/tests/elements/pcb-smtpad-soldermask-margin.test.ts +1 -1
  51. package/tests/fixtures/getStackedPngSvgComparison.ts +8 -2
@@ -0,0 +1,365 @@
1
+ import type { PcbPlatedHole } from "circuit-json"
2
+ import type { Matrix } from "transformation-matrix"
3
+ import { applyToPoint } from "transformation-matrix"
4
+ import type { CanvasContext, PcbColorMap } from "../../types"
5
+ import { drawPillPath } from "../helper-functions/draw-pill"
6
+ import { drawPolygonPath } from "../helper-functions/draw-polygon"
7
+ import { drawRoundedRectPath } from "../helper-functions/draw-rounded-rect"
8
+ import { offsetPolygonPoints } from "../soldermask-margin"
9
+ /**
10
+ * Process soldermask for a plated hole.
11
+ */
12
+ export function processPlatedHoleSoldermask(params: {
13
+ ctx: CanvasContext
14
+ hole: PcbPlatedHole
15
+ realToCanvasMat: Matrix
16
+ colorMap: PcbColorMap
17
+ soldermaskOverCopperColor: string
18
+ layer: "top" | "bottom"
19
+ showSoldermask: boolean
20
+ }): void {
21
+ const {
22
+ ctx,
23
+ hole,
24
+ realToCanvasMat,
25
+ colorMap,
26
+ soldermaskOverCopperColor,
27
+ layer,
28
+ showSoldermask,
29
+ } = params
30
+ // Check if this hole is on the current layer
31
+ if (hole.layers && !hole.layers.includes(layer)) return
32
+
33
+ // When soldermask is disabled, treat all holes as not covered with soldermask
34
+ // and use zero margin (normal rendering)
35
+ const isCoveredWithSoldermask =
36
+ showSoldermask && hole.is_covered_with_solder_mask === true
37
+ const margin = showSoldermask ? (hole.soldermask_margin ?? 0) : 0
38
+ const copperColor = colorMap.copper.top
39
+
40
+ if (isCoveredWithSoldermask) {
41
+ // Draw light green over the entire hole copper ring
42
+ ctx.fillStyle = soldermaskOverCopperColor
43
+ drawPlatedHoleShapePath({ ctx, hole, realToCanvasMat, margin: 0 })
44
+ ctx.fill()
45
+ } else if (margin < 0) {
46
+ // Negative margin: draw full copper, then light green ring
47
+ ctx.fillStyle = copperColor
48
+ drawPlatedHoleShapePath({ ctx, hole, realToCanvasMat, margin: 0 })
49
+ ctx.fill()
50
+ drawNegativeMarginRingForPlatedHole({
51
+ ctx,
52
+ hole,
53
+ realToCanvasMat,
54
+ soldermaskOverCopperColor,
55
+ margin,
56
+ })
57
+ } else if (margin > 0) {
58
+ // Positive margin: draw substrate for larger area, then copper for hole
59
+ ctx.fillStyle = colorMap.substrate
60
+ drawPlatedHoleShapePath({ ctx, hole, realToCanvasMat, margin })
61
+ ctx.fill()
62
+ ctx.fillStyle = copperColor
63
+ drawPlatedHoleShapePath({ ctx, hole, realToCanvasMat, margin: 0 })
64
+ ctx.fill()
65
+ } else {
66
+ // Zero margin: just draw copper for the hole
67
+ ctx.fillStyle = copperColor
68
+ drawPlatedHoleShapePath({ ctx, hole, realToCanvasMat, margin: 0 })
69
+ ctx.fill()
70
+ }
71
+ }
72
+
73
+ function drawPlatedHoleShapePath(params: {
74
+ ctx: CanvasContext
75
+ hole: PcbPlatedHole
76
+ realToCanvasMat: Matrix
77
+ margin: number
78
+ }): void {
79
+ const { ctx, hole, realToCanvasMat, margin } = params
80
+ if (hole.shape === "circle") {
81
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
82
+ const scaledRadius =
83
+ (hole.outer_diameter / 2 + margin) * Math.abs(realToCanvasMat.a)
84
+
85
+ ctx.beginPath()
86
+ ctx.arc(cx, cy, scaledRadius, 0, Math.PI * 2)
87
+ ctx.closePath()
88
+ } else if (hole.shape === "oval") {
89
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
90
+ const scaledRadiusX =
91
+ (hole.outer_width / 2 + margin) * Math.abs(realToCanvasMat.a)
92
+ const scaledRadiusY =
93
+ (hole.outer_height / 2 + margin) * Math.abs(realToCanvasMat.a)
94
+
95
+ ctx.save()
96
+ ctx.translate(cx, cy)
97
+ if (hole.ccw_rotation && hole.ccw_rotation !== 0) {
98
+ ctx.rotate(-(hole.ccw_rotation * Math.PI) / 180)
99
+ }
100
+
101
+ ctx.beginPath()
102
+ ctx.ellipse(0, 0, scaledRadiusX, scaledRadiusY, 0, 0, Math.PI * 2)
103
+ ctx.closePath()
104
+ ctx.restore()
105
+ } else if (hole.shape === "pill") {
106
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
107
+ const scaledWidth =
108
+ (hole.outer_width + margin * 2) * Math.abs(realToCanvasMat.a)
109
+ const scaledHeight =
110
+ (hole.outer_height + margin * 2) * Math.abs(realToCanvasMat.a)
111
+
112
+ ctx.save()
113
+ ctx.translate(cx, cy)
114
+ if (hole.ccw_rotation && hole.ccw_rotation !== 0) {
115
+ ctx.rotate(-(hole.ccw_rotation * Math.PI) / 180)
116
+ }
117
+
118
+ ctx.beginPath()
119
+ drawPillPath({
120
+ ctx,
121
+ cx: 0,
122
+ cy: 0,
123
+ width: scaledWidth,
124
+ height: scaledHeight,
125
+ })
126
+ ctx.restore()
127
+ } else if (
128
+ hole.shape === "circular_hole_with_rect_pad" ||
129
+ hole.shape === "pill_hole_with_rect_pad"
130
+ ) {
131
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
132
+ const scaledWidth =
133
+ (hole.rect_pad_width + margin * 2) * Math.abs(realToCanvasMat.a)
134
+ const scaledHeight =
135
+ (hole.rect_pad_height + margin * 2) * Math.abs(realToCanvasMat.a)
136
+ const scaledRadius =
137
+ (hole.rect_border_radius ?? 0) * Math.abs(realToCanvasMat.a)
138
+
139
+ ctx.beginPath()
140
+ drawRoundedRectPath({
141
+ ctx,
142
+ cx,
143
+ cy,
144
+ width: scaledWidth,
145
+ height: scaledHeight,
146
+ radius: scaledRadius,
147
+ })
148
+ } else if (hole.shape === "rotated_pill_hole_with_rect_pad") {
149
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
150
+ const scaledWidth =
151
+ (hole.rect_pad_width + margin * 2) * Math.abs(realToCanvasMat.a)
152
+ const scaledHeight =
153
+ (hole.rect_pad_height + margin * 2) * Math.abs(realToCanvasMat.a)
154
+ const scaledRadius =
155
+ (hole.rect_border_radius ?? 0) * Math.abs(realToCanvasMat.a)
156
+
157
+ ctx.save()
158
+ ctx.translate(cx, cy)
159
+ if (hole.rect_ccw_rotation) {
160
+ ctx.rotate((-hole.rect_ccw_rotation * Math.PI) / 180)
161
+ }
162
+
163
+ ctx.beginPath()
164
+ drawRoundedRectPath({
165
+ ctx,
166
+ cx: 0,
167
+ cy: 0,
168
+ width: scaledWidth,
169
+ height: scaledHeight,
170
+ radius: scaledRadius,
171
+ })
172
+ ctx.restore()
173
+ } else if (
174
+ hole.shape === "hole_with_polygon_pad" &&
175
+ hole.pad_outline &&
176
+ hole.pad_outline.length >= 3
177
+ ) {
178
+ const padPoints = hole.pad_outline.map(
179
+ (point: { x: number; y: number }) => ({
180
+ x: hole.x + point.x,
181
+ y: hole.y + point.y,
182
+ }),
183
+ )
184
+ const points =
185
+ margin !== 0 ? offsetPolygonPoints(padPoints, margin) : padPoints
186
+ const canvasPoints = points.map((p) => {
187
+ const [x, y] = applyToPoint(realToCanvasMat, [p.x, p.y])
188
+ return { x, y }
189
+ })
190
+
191
+ ctx.beginPath()
192
+ drawPolygonPath({ ctx, points: canvasPoints })
193
+ }
194
+ }
195
+
196
+ function drawNegativeMarginRingForPlatedHole(params: {
197
+ ctx: CanvasContext
198
+ hole: PcbPlatedHole
199
+ realToCanvasMat: Matrix
200
+ soldermaskOverCopperColor: string
201
+ margin: number
202
+ }): void {
203
+ const { ctx, hole, realToCanvasMat, soldermaskOverCopperColor, margin } =
204
+ params
205
+ const thickness = Math.abs(margin)
206
+
207
+ ctx.fillStyle = soldermaskOverCopperColor
208
+
209
+ if (hole.shape === "circle") {
210
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
211
+ const scaledOuterRadius =
212
+ (hole.outer_diameter / 2) * Math.abs(realToCanvasMat.a)
213
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
214
+ const innerRadius = Math.max(0, scaledOuterRadius - scaledThickness)
215
+
216
+ ctx.save()
217
+ ctx.beginPath()
218
+ ctx.arc(cx, cy, scaledOuterRadius, 0, Math.PI * 2)
219
+ if (innerRadius > 0) {
220
+ ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2, true)
221
+ }
222
+ ctx.fill("evenodd")
223
+ ctx.restore()
224
+ } else if (hole.shape === "oval") {
225
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
226
+ const scaledRadiusX = (hole.outer_width / 2) * Math.abs(realToCanvasMat.a)
227
+ const scaledRadiusY = (hole.outer_height / 2) * Math.abs(realToCanvasMat.a)
228
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
229
+ const innerRadiusX = Math.max(0, scaledRadiusX - scaledThickness)
230
+ const innerRadiusY = Math.max(0, scaledRadiusY - scaledThickness)
231
+
232
+ ctx.save()
233
+ ctx.translate(cx, cy)
234
+ if (hole.ccw_rotation && hole.ccw_rotation !== 0) {
235
+ ctx.rotate(-(hole.ccw_rotation * Math.PI) / 180)
236
+ }
237
+
238
+ ctx.beginPath()
239
+ ctx.ellipse(0, 0, scaledRadiusX, scaledRadiusY, 0, 0, Math.PI * 2)
240
+ if (innerRadiusX > 0 && innerRadiusY > 0) {
241
+ ctx.moveTo(innerRadiusX, 0)
242
+ ctx.ellipse(0, 0, innerRadiusX, innerRadiusY, 0, 0, Math.PI * 2)
243
+ }
244
+ ctx.fill("evenodd")
245
+ ctx.restore()
246
+ } else if (hole.shape === "pill") {
247
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
248
+ const scaledWidth = hole.outer_width * Math.abs(realToCanvasMat.a)
249
+ const scaledHeight = hole.outer_height * Math.abs(realToCanvasMat.a)
250
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
251
+
252
+ ctx.save()
253
+ ctx.translate(cx, cy)
254
+ if (hole.ccw_rotation && hole.ccw_rotation !== 0) {
255
+ ctx.rotate(-(hole.ccw_rotation * Math.PI) / 180)
256
+ }
257
+
258
+ ctx.beginPath()
259
+ drawPillPath({
260
+ ctx,
261
+ cx: 0,
262
+ cy: 0,
263
+ width: scaledWidth,
264
+ height: scaledHeight,
265
+ })
266
+
267
+ const innerWidth = scaledWidth - scaledThickness * 2
268
+ const innerHeight = scaledHeight - scaledThickness * 2
269
+ if (innerWidth > 0 && innerHeight > 0) {
270
+ drawPillPath({
271
+ ctx,
272
+ cx: 0,
273
+ cy: 0,
274
+ width: innerWidth,
275
+ height: innerHeight,
276
+ })
277
+ }
278
+
279
+ ctx.fill("evenodd")
280
+ ctx.restore()
281
+ } else if (
282
+ hole.shape === "circular_hole_with_rect_pad" ||
283
+ hole.shape === "pill_hole_with_rect_pad" ||
284
+ hole.shape === "rotated_pill_hole_with_rect_pad"
285
+ ) {
286
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
287
+ const scaledWidth = hole.rect_pad_width * Math.abs(realToCanvasMat.a)
288
+ const scaledHeight = hole.rect_pad_height * Math.abs(realToCanvasMat.a)
289
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
290
+
291
+ ctx.save()
292
+ ctx.translate(cx, cy)
293
+ if (
294
+ hole.shape === "rotated_pill_hole_with_rect_pad" &&
295
+ hole.rect_ccw_rotation
296
+ ) {
297
+ ctx.rotate((-hole.rect_ccw_rotation * Math.PI) / 180)
298
+ }
299
+
300
+ const outerRadius = hole.rect_border_radius
301
+ ? hole.rect_border_radius * Math.abs(realToCanvasMat.a)
302
+ : 0
303
+
304
+ ctx.beginPath()
305
+ drawRoundedRectPath({
306
+ ctx,
307
+ cx: 0,
308
+ cy: 0,
309
+ width: scaledWidth,
310
+ height: scaledHeight,
311
+ radius: outerRadius,
312
+ })
313
+
314
+ const innerWidth = scaledWidth - scaledThickness * 2
315
+ const innerHeight = scaledHeight - scaledThickness * 2
316
+ if (innerWidth > 0 && innerHeight > 0) {
317
+ const innerRadius = Math.max(0, outerRadius - scaledThickness)
318
+ drawRoundedRectPath({
319
+ ctx,
320
+ cx: 0,
321
+ cy: 0,
322
+ width: innerWidth,
323
+ height: innerHeight,
324
+ radius: innerRadius,
325
+ })
326
+ }
327
+
328
+ ctx.fill("evenodd")
329
+ ctx.restore()
330
+ } else if (
331
+ hole.shape === "hole_with_polygon_pad" &&
332
+ hole.pad_outline &&
333
+ hole.pad_outline.length >= 3
334
+ ) {
335
+ const padPoints = hole.pad_outline.map(
336
+ (point: { x: number; y: number }) => ({
337
+ x: hole.x + point.x,
338
+ y: hole.y + point.y,
339
+ }),
340
+ )
341
+
342
+ ctx.save()
343
+ ctx.beginPath()
344
+
345
+ // Draw outer polygon
346
+ const canvasPoints = padPoints.map((p) => {
347
+ const [x, y] = applyToPoint(realToCanvasMat, [p.x, p.y])
348
+ return { x, y }
349
+ })
350
+ drawPolygonPath({ ctx, points: canvasPoints })
351
+
352
+ // Draw inner polygon cutout
353
+ const innerPoints = offsetPolygonPoints(padPoints, -thickness)
354
+ if (innerPoints.length >= 3) {
355
+ const innerCanvasPoints = innerPoints.map((p) => {
356
+ const [x, y] = applyToPoint(realToCanvasMat, [p.x, p.y])
357
+ return { x, y }
358
+ })
359
+ drawPolygonPath({ ctx, points: innerCanvasPoints })
360
+ }
361
+
362
+ ctx.fill("evenodd")
363
+ ctx.restore()
364
+ }
365
+ }
@@ -0,0 +1,354 @@
1
+ import type { PcbSmtPad } from "circuit-json"
2
+ import type { Matrix } from "transformation-matrix"
3
+ import { applyToPoint } from "transformation-matrix"
4
+ import type { CanvasContext, PcbColorMap } from "../../types"
5
+ import { drawPillPath } from "../helper-functions/draw-pill"
6
+ import { drawPolygonPath } from "../helper-functions/draw-polygon"
7
+ import { drawRoundedRectPath } from "../helper-functions/draw-rounded-rect"
8
+ import { offsetPolygonPoints } from "../soldermask-margin"
9
+ /**
10
+ * Process soldermask for an SMT pad.
11
+ */
12
+ export function processSmtPadSoldermask(params: {
13
+ ctx: CanvasContext
14
+ pad: PcbSmtPad
15
+ realToCanvasMat: Matrix
16
+ colorMap: PcbColorMap
17
+ soldermaskOverCopperColor: string
18
+ layer: "top" | "bottom"
19
+ showSoldermask: boolean
20
+ }): void {
21
+ const {
22
+ ctx,
23
+ pad,
24
+ realToCanvasMat,
25
+ colorMap,
26
+ soldermaskOverCopperColor,
27
+ layer,
28
+ showSoldermask,
29
+ } = params
30
+ // Only process pads on the current layer
31
+ if (pad.layer !== layer) return
32
+
33
+ // When soldermask is disabled, treat all pads as not covered with soldermask
34
+ // and use zero margin (normal rendering)
35
+ const isCoveredWithSoldermask =
36
+ showSoldermask && pad.is_covered_with_solder_mask === true
37
+ const margin = showSoldermask ? (pad.soldermask_margin ?? 0) : 0
38
+
39
+ // Get asymmetric margins for rect shapes
40
+ let ml = margin
41
+ let mr = margin
42
+ let mt = margin
43
+ let mb = margin
44
+
45
+ if (pad.shape === "rect" || pad.shape === "rotated_rect") {
46
+ ml = pad.soldermask_margin_left ?? margin
47
+ mr = pad.soldermask_margin_right ?? margin
48
+ mt = pad.soldermask_margin_top ?? margin
49
+ mb = pad.soldermask_margin_bottom ?? margin
50
+ }
51
+
52
+ const copperColor =
53
+ colorMap.copper[pad.layer as keyof typeof colorMap.copper] ||
54
+ colorMap.copper.top
55
+
56
+ if (isCoveredWithSoldermask) {
57
+ // Draw light green over the entire pad
58
+ ctx.fillStyle = soldermaskOverCopperColor
59
+ drawPadShapePath({ ctx, pad, realToCanvasMat, ml: 0, mr: 0, mt: 0, mb: 0 })
60
+ ctx.fill()
61
+ } else if (ml < 0 || mr < 0 || mt < 0 || mb < 0) {
62
+ // Negative margin: draw full copper pad, then light green ring
63
+ ctx.fillStyle = copperColor
64
+ drawPadShapePath({ ctx, pad, realToCanvasMat, ml: 0, mr: 0, mt: 0, mb: 0 })
65
+ ctx.fill()
66
+ // Draw light green ring for negative margin
67
+ drawNegativeMarginRingForPad({
68
+ ctx,
69
+ pad,
70
+ realToCanvasMat,
71
+ soldermaskOverCopperColor,
72
+ ml,
73
+ mr,
74
+ mt,
75
+ mb,
76
+ })
77
+ } else if (ml > 0 || mr > 0 || mt > 0 || mb > 0) {
78
+ // Positive margin: draw substrate for larger area, then copper for pad
79
+ ctx.fillStyle = colorMap.substrate
80
+ drawPadShapePath({ ctx, pad, realToCanvasMat, ml, mr, mt, mb })
81
+ ctx.fill()
82
+ ctx.fillStyle = copperColor
83
+ drawPadShapePath({ ctx, pad, realToCanvasMat, ml: 0, mr: 0, mt: 0, mb: 0 })
84
+ ctx.fill()
85
+ } else {
86
+ // Zero margin: just draw copper for the pad
87
+ ctx.fillStyle = copperColor
88
+ drawPadShapePath({ ctx, pad, realToCanvasMat, ml: 0, mr: 0, mt: 0, mb: 0 })
89
+ ctx.fill()
90
+ }
91
+ }
92
+
93
+ function drawPadShapePath(params: {
94
+ ctx: CanvasContext
95
+ pad: PcbSmtPad
96
+ realToCanvasMat: Matrix
97
+ ml: number
98
+ mr: number
99
+ mt: number
100
+ mb: number
101
+ }): void {
102
+ const { ctx, pad, realToCanvasMat, ml, mr, mt, mb } = params
103
+ const rotation =
104
+ pad.shape === "rotated_rect" || pad.shape === "rotated_pill"
105
+ ? (pad.ccw_rotation ?? 0)
106
+ : 0
107
+
108
+ if (pad.shape === "rect" || pad.shape === "rotated_rect") {
109
+ const borderRadius = pad.corner_radius ?? pad.rect_border_radius ?? 0
110
+ const radians = (rotation * Math.PI) / 180
111
+ const dxLocal = (mr - ml) / 2
112
+ const dyLocal = (mt - mb) / 2
113
+ const dxGlobal = dxLocal * Math.cos(radians) - dyLocal * Math.sin(radians)
114
+ const dyGlobal = dxLocal * Math.sin(radians) + dyLocal * Math.cos(radians)
115
+
116
+ const [cx, cy] = applyToPoint(realToCanvasMat, [
117
+ pad.x + dxGlobal,
118
+ pad.y + dyGlobal,
119
+ ])
120
+ const scaledWidth = (pad.width + ml + mr) * Math.abs(realToCanvasMat.a)
121
+ const scaledHeight = (pad.height + mt + mb) * Math.abs(realToCanvasMat.a)
122
+ const scaledRadius = borderRadius * Math.abs(realToCanvasMat.a)
123
+
124
+ ctx.save()
125
+ ctx.translate(cx, cy)
126
+ if (rotation !== 0) {
127
+ ctx.rotate((-rotation * Math.PI) / 180)
128
+ }
129
+
130
+ ctx.beginPath()
131
+ drawRoundedRectPath({
132
+ ctx,
133
+ cx: 0,
134
+ cy: 0,
135
+ width: scaledWidth,
136
+ height: scaledHeight,
137
+ radius: scaledRadius,
138
+ })
139
+ ctx.restore()
140
+ } else if (pad.shape === "circle") {
141
+ const avgMargin = (ml + mr + mt + mb) / 4
142
+ const [cx, cy] = applyToPoint(realToCanvasMat, [pad.x, pad.y])
143
+ const scaledRadius = (pad.radius + avgMargin) * Math.abs(realToCanvasMat.a)
144
+
145
+ ctx.beginPath()
146
+ ctx.arc(cx, cy, scaledRadius, 0, Math.PI * 2)
147
+ ctx.closePath()
148
+ } else if (pad.shape === "pill" || pad.shape === "rotated_pill") {
149
+ const avgMargin = (ml + mr) / 2
150
+ const [cx, cy] = applyToPoint(realToCanvasMat, [pad.x, pad.y])
151
+ const scaledWidth =
152
+ (pad.width + avgMargin * 2) * Math.abs(realToCanvasMat.a)
153
+ const scaledHeight =
154
+ (pad.height + avgMargin * 2) * Math.abs(realToCanvasMat.a)
155
+
156
+ ctx.save()
157
+ ctx.translate(cx, cy)
158
+ if (rotation !== 0) {
159
+ ctx.rotate((-rotation * Math.PI) / 180)
160
+ }
161
+
162
+ ctx.beginPath()
163
+ drawPillPath({
164
+ ctx,
165
+ cx: 0,
166
+ cy: 0,
167
+ width: scaledWidth,
168
+ height: scaledHeight,
169
+ })
170
+ ctx.restore()
171
+ } else if (pad.shape === "polygon" && pad.points && pad.points.length >= 3) {
172
+ const avgMargin = (ml + mr + mt + mb) / 4
173
+ const points =
174
+ avgMargin !== 0 ? offsetPolygonPoints(pad.points, avgMargin) : pad.points
175
+ const canvasPoints = points.map((p) => {
176
+ const [x, y] = applyToPoint(realToCanvasMat, [p.x, p.y])
177
+ return { x, y }
178
+ })
179
+
180
+ ctx.beginPath()
181
+ drawPolygonPath({ ctx, points: canvasPoints })
182
+ }
183
+ }
184
+
185
+ function drawNegativeMarginRingForPad(params: {
186
+ ctx: CanvasContext
187
+ pad: PcbSmtPad
188
+ realToCanvasMat: Matrix
189
+ soldermaskOverCopperColor: string
190
+ ml: number
191
+ mr: number
192
+ mt: number
193
+ mb: number
194
+ }): void {
195
+ const {
196
+ ctx,
197
+ pad,
198
+ realToCanvasMat,
199
+ soldermaskOverCopperColor,
200
+ ml,
201
+ mr,
202
+ mt,
203
+ mb,
204
+ } = params
205
+ const rotation =
206
+ pad.shape === "rotated_rect" || pad.shape === "rotated_pill"
207
+ ? (pad.ccw_rotation ?? 0)
208
+ : 0
209
+
210
+ // Calculate the inner dimensions (where copper is exposed)
211
+ const thicknessL = Math.max(0, -ml)
212
+ const thicknessR = Math.max(0, -mr)
213
+ const thicknessT = Math.max(0, -mt)
214
+ const thicknessB = Math.max(0, -mb)
215
+
216
+ ctx.fillStyle = soldermaskOverCopperColor
217
+
218
+ if (pad.shape === "rect" || pad.shape === "rotated_rect") {
219
+ const borderRadius = pad.corner_radius ?? pad.rect_border_radius ?? 0
220
+ const [cx, cy] = applyToPoint(realToCanvasMat, [pad.x, pad.y])
221
+ const scaledWidth = pad.width * Math.abs(realToCanvasMat.a)
222
+ const scaledHeight = pad.height * Math.abs(realToCanvasMat.a)
223
+ const scaledRadius = borderRadius * Math.abs(realToCanvasMat.a)
224
+ const scaledThicknessL = thicknessL * Math.abs(realToCanvasMat.a)
225
+ const scaledThicknessR = thicknessR * Math.abs(realToCanvasMat.a)
226
+ const scaledThicknessT = thicknessT * Math.abs(realToCanvasMat.a)
227
+ const scaledThicknessB = thicknessB * Math.abs(realToCanvasMat.a)
228
+
229
+ ctx.save()
230
+ ctx.translate(cx, cy)
231
+ if (rotation !== 0) {
232
+ ctx.rotate((-rotation * Math.PI) / 180)
233
+ }
234
+
235
+ // Draw outer rectangle (full pad size)
236
+ ctx.beginPath()
237
+ drawRoundedRectPath({
238
+ ctx,
239
+ cx: 0,
240
+ cy: 0,
241
+ width: scaledWidth,
242
+ height: scaledHeight,
243
+ radius: scaledRadius,
244
+ })
245
+
246
+ // Create inner cutout
247
+ const innerWidth = scaledWidth - scaledThicknessL - scaledThicknessR
248
+ const innerHeight = scaledHeight - scaledThicknessT - scaledThicknessB
249
+ const innerOffsetX = (scaledThicknessL - scaledThicknessR) / 2
250
+ const innerOffsetY = (scaledThicknessT - scaledThicknessB) / 2
251
+ const innerRadius = Math.max(
252
+ 0,
253
+ scaledRadius -
254
+ (scaledThicknessL +
255
+ scaledThicknessR +
256
+ scaledThicknessT +
257
+ scaledThicknessB) /
258
+ 4,
259
+ )
260
+
261
+ if (innerWidth > 0 && innerHeight > 0) {
262
+ drawRoundedRectPath({
263
+ ctx,
264
+ cx: innerOffsetX,
265
+ cy: innerOffsetY,
266
+ width: innerWidth,
267
+ height: innerHeight,
268
+ radius: innerRadius,
269
+ })
270
+ }
271
+
272
+ ctx.fill("evenodd")
273
+ ctx.restore()
274
+ } else if (pad.shape === "circle") {
275
+ const thickness = Math.max(thicknessL, thicknessR, thicknessT, thicknessB)
276
+ const [cx, cy] = applyToPoint(realToCanvasMat, [pad.x, pad.y])
277
+ const scaledRadius = pad.radius * Math.abs(realToCanvasMat.a)
278
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
279
+ const innerRadius = Math.max(0, scaledRadius - scaledThickness)
280
+
281
+ ctx.save()
282
+ ctx.beginPath()
283
+ ctx.arc(cx, cy, scaledRadius, 0, Math.PI * 2)
284
+ if (innerRadius > 0) {
285
+ ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2, true)
286
+ }
287
+ ctx.fill("evenodd")
288
+ ctx.restore()
289
+ } else if (pad.shape === "pill" || pad.shape === "rotated_pill") {
290
+ const thickness = Math.max(thicknessL, thicknessR, thicknessT, thicknessB)
291
+ const [cx, cy] = applyToPoint(realToCanvasMat, [pad.x, pad.y])
292
+ const scaledWidth = pad.width * Math.abs(realToCanvasMat.a)
293
+ const scaledHeight = pad.height * Math.abs(realToCanvasMat.a)
294
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
295
+
296
+ ctx.save()
297
+ ctx.translate(cx, cy)
298
+ if (rotation !== 0) {
299
+ ctx.rotate((-rotation * Math.PI) / 180)
300
+ }
301
+
302
+ // Draw outer pill
303
+ ctx.beginPath()
304
+ drawPillPath({
305
+ ctx,
306
+ cx: 0,
307
+ cy: 0,
308
+ width: scaledWidth,
309
+ height: scaledHeight,
310
+ })
311
+
312
+ // Draw inner pill cutout
313
+ const innerWidth = scaledWidth - scaledThickness * 2
314
+ const innerHeight = scaledHeight - scaledThickness * 2
315
+ if (innerWidth > 0 && innerHeight > 0) {
316
+ drawPillPath({
317
+ ctx,
318
+ cx: 0,
319
+ cy: 0,
320
+ width: innerWidth,
321
+ height: innerHeight,
322
+ })
323
+ }
324
+
325
+ ctx.fill("evenodd")
326
+ ctx.restore()
327
+ } else if (pad.shape === "polygon" && pad.points && pad.points.length >= 3) {
328
+ // For polygon, use average thickness from all sides
329
+ const thickness = Math.max(thicknessL, thicknessR, thicknessT, thicknessB)
330
+
331
+ ctx.save()
332
+ ctx.beginPath()
333
+
334
+ // Draw outer polygon (full pad size)
335
+ const canvasPoints = pad.points.map((p) => {
336
+ const [x, y] = applyToPoint(realToCanvasMat, [p.x, p.y])
337
+ return { x, y }
338
+ })
339
+ drawPolygonPath({ ctx, points: canvasPoints })
340
+
341
+ // Draw inner polygon cutout
342
+ const innerPoints = offsetPolygonPoints(pad.points, -thickness)
343
+ if (innerPoints.length >= 3) {
344
+ const innerCanvasPoints = innerPoints.map((p) => {
345
+ const [x, y] = applyToPoint(realToCanvasMat, [p.x, p.y])
346
+ return { x, y }
347
+ })
348
+ drawPolygonPath({ ctx, points: innerCanvasPoints })
349
+ }
350
+
351
+ ctx.fill("evenodd")
352
+ ctx.restore()
353
+ }
354
+ }