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.
- package/dist/index.d.ts +7 -4
- package/dist/index.js +1425 -1240
- package/lib/drawer/CircuitToCanvasDrawer.ts +262 -312
- package/lib/drawer/elements/helper-functions/draw-pill.ts +39 -0
- package/lib/drawer/elements/helper-functions/draw-polygon.ts +25 -0
- package/lib/drawer/elements/helper-functions/draw-rounded-rect.ts +34 -0
- package/lib/drawer/elements/helper-functions/index.ts +3 -0
- package/lib/drawer/elements/pcb-board.ts +13 -3
- package/lib/drawer/elements/pcb-hole.ts +56 -338
- package/lib/drawer/elements/pcb-plated-hole.ts +154 -442
- package/lib/drawer/elements/pcb-smtpad.ts +3 -292
- package/lib/drawer/elements/pcb-soldermask/board.ts +44 -0
- package/lib/drawer/elements/pcb-soldermask/cutout.ts +74 -0
- package/lib/drawer/elements/pcb-soldermask/hole.ts +288 -0
- package/lib/drawer/elements/pcb-soldermask/index.ts +140 -0
- package/lib/drawer/elements/pcb-soldermask/plated-hole.ts +365 -0
- package/lib/drawer/elements/pcb-soldermask/smt-pad.ts +354 -0
- package/lib/drawer/elements/pcb-soldermask/via.ts +27 -0
- package/package.json +1 -1
- package/tests/board-snapshot/__snapshots__/usb-c-flashlight-board.snap.png +0 -0
- package/tests/board-snapshot/usb-c-flashlight-board.test.ts +1 -0
- package/tests/elements/__snapshots__/board-with-elements.snap.png +0 -0
- package/tests/elements/__snapshots__/brep-copper-pours.snap.png +0 -0
- package/tests/elements/__snapshots__/custom-outline-board.snap.png +0 -0
- package/tests/elements/__snapshots__/oval-plated-hole.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-board.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-comprehensive-soldermask-margin.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-fabrication-note-dimension.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-hole-soldermask-margin.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-keepout-layer-filter.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-keepout-multiple-layers.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-keepout-rect-and-circle.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-keepout-with-group-id.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-no-soldermask.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-plated-hole-soldermask-margin.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-plated-hole.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-silkscreen-on-component.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-silkscreen-oval.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-smtpad-asymmetric-soldermask-margin.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-smtpad-soldermask-coverage.snap.png +0 -0
- package/tests/elements/__snapshots__/pcb-smtpad-soldermask-margin.snap.png +0 -0
- package/tests/elements/__snapshots__/pill-plated-hole.snap.png +0 -0
- package/tests/elements/pcb-comprehensive-soldermask-margin.test.ts +2 -2
- package/tests/elements/pcb-hole-soldermask-margin.test.ts +155 -2
- package/tests/elements/pcb-no-soldermask.test.ts +1281 -0
- package/tests/elements/pcb-plated-hole-soldermask-margin.test.ts +1 -1
- package/tests/elements/pcb-plated-hole.test.ts +40 -4
- package/tests/elements/pcb-smtpad-asymmetric-soldermask-margin.test.ts +1 -1
- package/tests/elements/pcb-smtpad-soldermask-coverage.test.ts +1 -1
- package/tests/elements/pcb-smtpad-soldermask-margin.test.ts +1 -1
- 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
|
+
}
|