circuit-to-canvas 0.0.50 → 0.0.52

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 (53) hide show
  1. package/dist/index.d.ts +10 -4
  2. package/dist/index.js +2911 -2719
  3. package/lib/drawer/CircuitToCanvasDrawer.ts +317 -366
  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 +28 -13
  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-board.test.ts +2 -2
  44. package/tests/elements/pcb-comprehensive-soldermask-margin.test.ts +2 -2
  45. package/tests/elements/pcb-hole-soldermask-margin.test.ts +155 -2
  46. package/tests/elements/pcb-keepout-with-group-id.test.ts +1 -1
  47. package/tests/elements/pcb-no-soldermask.test.ts +1281 -0
  48. package/tests/elements/pcb-plated-hole-soldermask-margin.test.ts +1 -1
  49. package/tests/elements/pcb-plated-hole.test.ts +40 -4
  50. package/tests/elements/pcb-smtpad-asymmetric-soldermask-margin.test.ts +1 -1
  51. package/tests/elements/pcb-smtpad-soldermask-coverage.test.ts +1 -1
  52. package/tests/elements/pcb-smtpad-soldermask-margin.test.ts +1 -1
  53. package/tests/fixtures/getStackedPngSvgComparison.ts +10 -4
@@ -0,0 +1,288 @@
1
+ import type { PcbHole } 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
+ /**
7
+ * Process soldermask for a non-plated hole.
8
+ */
9
+ export function processHoleSoldermask(params: {
10
+ ctx: CanvasContext
11
+ hole: PcbHole
12
+ realToCanvasMat: Matrix
13
+ colorMap: PcbColorMap
14
+ soldermaskOverCopperColor: string
15
+ drawSoldermask: boolean
16
+ }): void {
17
+ const {
18
+ ctx,
19
+ hole,
20
+ realToCanvasMat,
21
+ colorMap,
22
+ soldermaskOverCopperColor,
23
+ drawSoldermask,
24
+ } = params
25
+ // When soldermask is disabled, treat all holes as not covered with soldermask
26
+ // and use zero margin (normal rendering)
27
+ const isCoveredWithSoldermask =
28
+ drawSoldermask && hole.is_covered_with_solder_mask === true
29
+ const margin = drawSoldermask ? (hole.soldermask_margin ?? 0) : 0
30
+
31
+ if (isCoveredWithSoldermask) {
32
+ // Draw light green over the entire hole
33
+ ctx.fillStyle = soldermaskOverCopperColor
34
+ drawHoleShapePath({ ctx, hole, realToCanvasMat, margin: 0 })
35
+ ctx.fill()
36
+ } else if (margin < 0) {
37
+ // Negative margin: draw drill color for hole, then light green ring
38
+ ctx.fillStyle = colorMap.drill
39
+ drawHoleShapePath({ ctx, hole, realToCanvasMat, margin: 0 })
40
+ ctx.fill()
41
+ drawNegativeMarginRingForHole({
42
+ ctx,
43
+ hole,
44
+ realToCanvasMat,
45
+ soldermaskOverCopperColor,
46
+ margin,
47
+ })
48
+ } else if (margin > 0) {
49
+ // Positive margin: draw substrate for larger area, then drill for hole
50
+ ctx.fillStyle = colorMap.substrate
51
+ drawHoleShapePath({ ctx, hole, realToCanvasMat, margin })
52
+ ctx.fill()
53
+ ctx.fillStyle = colorMap.drill
54
+ drawHoleShapePath({ ctx, hole, realToCanvasMat, margin: 0 })
55
+ ctx.fill()
56
+ } else {
57
+ // Zero margin: just draw drill color for the hole
58
+ ctx.fillStyle = colorMap.drill
59
+ drawHoleShapePath({ ctx, hole, realToCanvasMat, margin: 0 })
60
+ ctx.fill()
61
+ }
62
+ }
63
+
64
+ function getHoleRotation(hole: PcbHole): number {
65
+ if ("ccw_rotation" in hole && typeof hole.ccw_rotation === "number") {
66
+ return hole.ccw_rotation
67
+ }
68
+ return 0
69
+ }
70
+
71
+ function drawHoleShapePath(params: {
72
+ ctx: CanvasContext
73
+ hole: PcbHole
74
+ realToCanvasMat: Matrix
75
+ margin: number
76
+ }): void {
77
+ const { ctx, hole, realToCanvasMat, margin } = params
78
+ const rotation = getHoleRotation(hole)
79
+
80
+ if (hole.hole_shape === "circle") {
81
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
82
+ const scaledRadius =
83
+ (hole.hole_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.hole_shape === "square") {
89
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
90
+ const scaledSize =
91
+ (hole.hole_diameter + margin * 2) * Math.abs(realToCanvasMat.a)
92
+
93
+ ctx.save()
94
+ ctx.translate(cx, cy)
95
+ if (rotation !== 0) {
96
+ ctx.rotate((-rotation * Math.PI) / 180)
97
+ }
98
+
99
+ ctx.beginPath()
100
+ ctx.rect(-scaledSize / 2, -scaledSize / 2, scaledSize, scaledSize)
101
+ ctx.closePath()
102
+ ctx.restore()
103
+ } else if (hole.hole_shape === "oval") {
104
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
105
+ const scaledRadiusX =
106
+ (hole.hole_width / 2 + margin) * Math.abs(realToCanvasMat.a)
107
+ const scaledRadiusY =
108
+ (hole.hole_height / 2 + margin) * Math.abs(realToCanvasMat.a)
109
+
110
+ ctx.save()
111
+ ctx.translate(cx, cy)
112
+ if (rotation !== 0) {
113
+ ctx.rotate((-rotation * Math.PI) / 180)
114
+ }
115
+
116
+ ctx.beginPath()
117
+ ctx.ellipse(0, 0, scaledRadiusX, scaledRadiusY, 0, 0, Math.PI * 2)
118
+ ctx.closePath()
119
+ ctx.restore()
120
+ } else if (hole.hole_shape === "rect") {
121
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
122
+ const scaledWidth =
123
+ (hole.hole_width + margin * 2) * Math.abs(realToCanvasMat.a)
124
+ const scaledHeight =
125
+ (hole.hole_height + margin * 2) * Math.abs(realToCanvasMat.a)
126
+
127
+ ctx.save()
128
+ ctx.translate(cx, cy)
129
+ if (rotation !== 0) {
130
+ ctx.rotate((-rotation * Math.PI) / 180)
131
+ }
132
+
133
+ ctx.beginPath()
134
+ ctx.rect(-scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight)
135
+ ctx.closePath()
136
+ ctx.restore()
137
+ } else if (hole.hole_shape === "pill" || hole.hole_shape === "rotated_pill") {
138
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
139
+ const scaledWidth =
140
+ (hole.hole_width + margin * 2) * Math.abs(realToCanvasMat.a)
141
+ const scaledHeight =
142
+ (hole.hole_height + margin * 2) * Math.abs(realToCanvasMat.a)
143
+
144
+ ctx.save()
145
+ ctx.translate(cx, cy)
146
+ if (rotation !== 0) {
147
+ ctx.rotate((-rotation * Math.PI) / 180)
148
+ }
149
+
150
+ ctx.beginPath()
151
+ drawPillPath({
152
+ ctx,
153
+ cx: 0,
154
+ cy: 0,
155
+ width: scaledWidth,
156
+ height: scaledHeight,
157
+ })
158
+ ctx.restore()
159
+ }
160
+ }
161
+
162
+ function drawNegativeMarginRingForHole(params: {
163
+ ctx: CanvasContext
164
+ hole: PcbHole
165
+ realToCanvasMat: Matrix
166
+ soldermaskOverCopperColor: string
167
+ margin: number
168
+ }): void {
169
+ const { ctx, hole, realToCanvasMat, soldermaskOverCopperColor, margin } =
170
+ params
171
+ const thickness = Math.abs(margin)
172
+ const rotation = getHoleRotation(hole)
173
+
174
+ ctx.fillStyle = soldermaskOverCopperColor
175
+
176
+ if (hole.hole_shape === "circle") {
177
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
178
+ const scaledRadius = (hole.hole_diameter / 2) * Math.abs(realToCanvasMat.a)
179
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
180
+ const innerRadius = Math.max(0, scaledRadius - scaledThickness)
181
+
182
+ ctx.save()
183
+ ctx.beginPath()
184
+ ctx.arc(cx, cy, scaledRadius, 0, Math.PI * 2)
185
+ if (innerRadius > 0) {
186
+ ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2, true)
187
+ }
188
+ ctx.fill("evenodd")
189
+ ctx.restore()
190
+ } else if (hole.hole_shape === "square") {
191
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
192
+ const scaledSize = hole.hole_diameter * Math.abs(realToCanvasMat.a)
193
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
194
+ const innerSize = Math.max(0, scaledSize - scaledThickness * 2)
195
+
196
+ ctx.save()
197
+ ctx.translate(cx, cy)
198
+ if (rotation !== 0) {
199
+ ctx.rotate((-rotation * Math.PI) / 180)
200
+ }
201
+
202
+ ctx.beginPath()
203
+ ctx.rect(-scaledSize / 2, -scaledSize / 2, scaledSize, scaledSize)
204
+ if (innerSize > 0) {
205
+ ctx.rect(-innerSize / 2, -innerSize / 2, innerSize, innerSize)
206
+ }
207
+ ctx.fill("evenodd")
208
+ ctx.restore()
209
+ } else if (hole.hole_shape === "oval") {
210
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
211
+ const scaledRadiusX = (hole.hole_width / 2) * Math.abs(realToCanvasMat.a)
212
+ const scaledRadiusY = (hole.hole_height / 2) * Math.abs(realToCanvasMat.a)
213
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
214
+ const innerRadiusX = Math.max(0, scaledRadiusX - scaledThickness)
215
+ const innerRadiusY = Math.max(0, scaledRadiusY - scaledThickness)
216
+
217
+ ctx.save()
218
+ ctx.translate(cx, cy)
219
+ if (rotation !== 0) {
220
+ ctx.rotate((-rotation * Math.PI) / 180)
221
+ }
222
+
223
+ ctx.beginPath()
224
+ ctx.ellipse(0, 0, scaledRadiusX, scaledRadiusY, 0, 0, Math.PI * 2)
225
+ if (innerRadiusX > 0 && innerRadiusY > 0) {
226
+ ctx.moveTo(innerRadiusX, 0)
227
+ ctx.ellipse(0, 0, innerRadiusX, innerRadiusY, 0, 0, Math.PI * 2)
228
+ }
229
+ ctx.fill("evenodd")
230
+ ctx.restore()
231
+ } else if (hole.hole_shape === "rect") {
232
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
233
+ const scaledWidth = hole.hole_width * Math.abs(realToCanvasMat.a)
234
+ const scaledHeight = hole.hole_height * Math.abs(realToCanvasMat.a)
235
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
236
+ const innerWidth = Math.max(0, scaledWidth - scaledThickness * 2)
237
+ const innerHeight = Math.max(0, scaledHeight - scaledThickness * 2)
238
+
239
+ ctx.save()
240
+ ctx.translate(cx, cy)
241
+ if (rotation !== 0) {
242
+ ctx.rotate((-rotation * Math.PI) / 180)
243
+ }
244
+
245
+ ctx.beginPath()
246
+ ctx.rect(-scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight)
247
+ if (innerWidth > 0 && innerHeight > 0) {
248
+ ctx.rect(-innerWidth / 2, -innerHeight / 2, innerWidth, innerHeight)
249
+ }
250
+ ctx.fill("evenodd")
251
+ ctx.restore()
252
+ } else if (hole.hole_shape === "pill" || hole.hole_shape === "rotated_pill") {
253
+ const [cx, cy] = applyToPoint(realToCanvasMat, [hole.x, hole.y])
254
+ const scaledWidth = hole.hole_width * Math.abs(realToCanvasMat.a)
255
+ const scaledHeight = hole.hole_height * Math.abs(realToCanvasMat.a)
256
+ const scaledThickness = thickness * Math.abs(realToCanvasMat.a)
257
+
258
+ ctx.save()
259
+ ctx.translate(cx, cy)
260
+ if (rotation !== 0) {
261
+ ctx.rotate((-rotation * Math.PI) / 180)
262
+ }
263
+
264
+ ctx.beginPath()
265
+ drawPillPath({
266
+ ctx,
267
+ cx: 0,
268
+ cy: 0,
269
+ width: scaledWidth,
270
+ height: scaledHeight,
271
+ })
272
+
273
+ const innerWidth = scaledWidth - scaledThickness * 2
274
+ const innerHeight = scaledHeight - scaledThickness * 2
275
+ if (innerWidth > 0 && innerHeight > 0) {
276
+ drawPillPath({
277
+ ctx,
278
+ cx: 0,
279
+ cy: 0,
280
+ width: innerWidth,
281
+ height: innerHeight,
282
+ })
283
+ }
284
+
285
+ ctx.fill("evenodd")
286
+ ctx.restore()
287
+ }
288
+ }
@@ -0,0 +1,140 @@
1
+ import type { AnyCircuitElement } from "circuit-json"
2
+ import type { Matrix } from "transformation-matrix"
3
+ import type { CanvasContext, PcbColorMap } from "../../types"
4
+ import { drawBoardSoldermask } from "./board"
5
+ import { processCutoutSoldermask } from "./cutout"
6
+ import { processHoleSoldermask } from "./hole"
7
+ import { processPlatedHoleSoldermask } from "./plated-hole"
8
+ import { processSmtPadSoldermask } from "./smt-pad"
9
+ import { processViaSoldermask } from "./via"
10
+
11
+ export interface DrawPcbSoldermaskParams {
12
+ ctx: CanvasContext
13
+ board: import("circuit-json").PcbBoard
14
+ elements: AnyCircuitElement[]
15
+ realToCanvasMat: Matrix
16
+ colorMap: PcbColorMap
17
+ layer: "top" | "bottom"
18
+ drawSoldermask: boolean
19
+ }
20
+
21
+ /**
22
+ * Draws the soldermask layer for the PCB as a unified geometry.
23
+ *
24
+ * The soldermask is drawn as a single unified layer that covers the entire board.
25
+ * Elements "cut through" the soldermask by drawing on top with appropriate colors:
26
+ *
27
+ * 1. Draw full soldermask covering the board (dark green)
28
+ * 2. For each element that needs a soldermask opening:
29
+ * - If positive margin: draw substrate color for the larger area, then copper color for pad
30
+ * - If zero margin: draw copper color for the pad area
31
+ * - If negative margin: draw copper color for the pad, then light green ring for margin
32
+ * 3. For elements with is_covered_with_soldermask: draw light green soldermask over them
33
+ *
34
+ * Note: This approach draws colors ON TOP of the soldermask rather than using
35
+ * destination-out compositing. This is necessary because some elements (plated holes,
36
+ * vias, non-plated holes) are drawn AFTER the soldermask layer, so cutting through
37
+ * the soldermask wouldn't reveal anything useful underneath.
38
+ */
39
+ export function drawPcbSoldermask(params: DrawPcbSoldermaskParams): void {
40
+ const {
41
+ ctx,
42
+ board,
43
+ elements,
44
+ realToCanvasMat,
45
+ colorMap,
46
+ layer,
47
+ drawSoldermask,
48
+ } = params
49
+
50
+ const soldermaskColor = colorMap.soldermask[layer] ?? colorMap.soldermask.top
51
+ const soldermaskOverCopperColor =
52
+ colorMap.soldermaskOverCopper[layer] ?? colorMap.soldermaskOverCopper.top
53
+
54
+ // Step 1: Draw the full soldermask covering the board (only if enabled)
55
+ if (drawSoldermask) {
56
+ drawBoardSoldermask({ ctx, board, realToCanvasMat, soldermaskColor })
57
+ }
58
+
59
+ // Step 2: Process each element - draw cutouts and light green areas as needed
60
+ for (const element of elements) {
61
+ processElementSoldermask({
62
+ ctx,
63
+ element,
64
+ realToCanvasMat,
65
+ colorMap,
66
+ soldermaskOverCopperColor,
67
+ layer,
68
+ drawSoldermask,
69
+ })
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Process soldermask for an element by drawing on top of the soldermask layer.
75
+ * This simulates cutouts by drawing substrate/copper colors over the soldermask.
76
+ */
77
+ function processElementSoldermask(params: {
78
+ ctx: CanvasContext
79
+ element: AnyCircuitElement
80
+ realToCanvasMat: Matrix
81
+ colorMap: PcbColorMap
82
+ soldermaskOverCopperColor: string
83
+ layer: "top" | "bottom"
84
+ drawSoldermask: boolean
85
+ }): void {
86
+ const {
87
+ ctx,
88
+ element,
89
+ realToCanvasMat,
90
+ colorMap,
91
+ soldermaskOverCopperColor,
92
+ layer,
93
+ drawSoldermask,
94
+ } = params
95
+
96
+ if (element.type === "pcb_smtpad") {
97
+ processSmtPadSoldermask({
98
+ ctx,
99
+ pad: element,
100
+ realToCanvasMat,
101
+ colorMap,
102
+ soldermaskOverCopperColor,
103
+ layer,
104
+ drawSoldermask,
105
+ })
106
+ } else if (element.type === "pcb_plated_hole") {
107
+ processPlatedHoleSoldermask({
108
+ ctx,
109
+ hole: element,
110
+ realToCanvasMat,
111
+ colorMap,
112
+ soldermaskOverCopperColor,
113
+ layer,
114
+ drawSoldermask,
115
+ })
116
+ } else if (element.type === "pcb_hole") {
117
+ processHoleSoldermask({
118
+ ctx,
119
+ hole: element,
120
+ realToCanvasMat,
121
+ colorMap,
122
+ soldermaskOverCopperColor,
123
+ drawSoldermask,
124
+ })
125
+ } else if (element.type === "pcb_via") {
126
+ processViaSoldermask({
127
+ ctx,
128
+ via: element,
129
+ realToCanvasMat,
130
+ colorMap,
131
+ })
132
+ } else if (element.type === "pcb_cutout") {
133
+ processCutoutSoldermask({
134
+ ctx,
135
+ cutout: element,
136
+ realToCanvasMat,
137
+ colorMap,
138
+ })
139
+ }
140
+ }