circuit-to-canvas 0.0.30 → 0.0.32

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.
@@ -0,0 +1,266 @@
1
+ import type { Matrix } from "transformation-matrix"
2
+ import type { CanvasContext } from "../types"
3
+ import { applyToPoint } from "transformation-matrix"
4
+
5
+ /**
6
+ * Draws a soldermask ring for rectangular shapes with negative margin
7
+ * (soldermask appears inside the pad boundary)
8
+ */
9
+ export function drawSoldermaskRingForRect(
10
+ ctx: CanvasContext,
11
+ center: { x: number; y: number },
12
+ width: number,
13
+ height: number,
14
+ margin: number,
15
+ borderRadius: number,
16
+ rotation: number,
17
+ realToCanvasMat: Matrix,
18
+ soldermaskColor: string,
19
+ padColor: string,
20
+ ): void {
21
+ const [cx, cy] = applyToPoint(realToCanvasMat, [center.x, center.y])
22
+ const scaledWidth = width * Math.abs(realToCanvasMat.a)
23
+ const scaledHeight = height * Math.abs(realToCanvasMat.a)
24
+ const scaledMargin = Math.abs(margin) * Math.abs(realToCanvasMat.a)
25
+ const scaledRadius = borderRadius * Math.abs(realToCanvasMat.a)
26
+
27
+ ctx.save()
28
+ ctx.translate(cx, cy)
29
+
30
+ if (rotation !== 0) {
31
+ ctx.rotate(-rotation * (Math.PI / 180))
32
+ }
33
+
34
+ // For negative margins, outer is pad boundary, inner is reduced by margin
35
+ // Use source-atop so the ring only appears on the pad
36
+ const prevCompositeOp = ctx.globalCompositeOperation
37
+ if (ctx.globalCompositeOperation !== undefined) {
38
+ ctx.globalCompositeOperation = "source-atop"
39
+ }
40
+
41
+ // Draw outer rectangle filled (at pad boundary)
42
+ const outerWidth = scaledWidth
43
+ const outerHeight = scaledHeight
44
+ const outerRadius = scaledRadius
45
+
46
+ ctx.beginPath()
47
+ if (outerRadius > 0) {
48
+ const x = -outerWidth / 2
49
+ const y = -outerHeight / 2
50
+ const r = Math.min(outerRadius, outerWidth / 2, outerHeight / 2)
51
+
52
+ ctx.moveTo(x + r, y)
53
+ ctx.lineTo(x + outerWidth - r, y)
54
+ ctx.arcTo(x + outerWidth, y, x + outerWidth, y + r, r)
55
+ ctx.lineTo(x + outerWidth, y + outerHeight - r)
56
+ ctx.arcTo(
57
+ x + outerWidth,
58
+ y + outerHeight,
59
+ x + outerWidth - r,
60
+ y + outerHeight,
61
+ r,
62
+ )
63
+ ctx.lineTo(x + r, y + outerHeight)
64
+ ctx.arcTo(x, y + outerHeight, x, y + outerHeight - r, r)
65
+ ctx.lineTo(x, y + r)
66
+ ctx.arcTo(x, y, x + r, y, r)
67
+ } else {
68
+ ctx.rect(-outerWidth / 2, -outerHeight / 2, outerWidth, outerHeight)
69
+ }
70
+
71
+ ctx.fillStyle = soldermaskColor
72
+ ctx.fill()
73
+
74
+ // Reset composite operation and restore pad color in inner area
75
+ if (ctx.globalCompositeOperation !== undefined) {
76
+ ctx.globalCompositeOperation = prevCompositeOp || "source-over"
77
+ }
78
+
79
+ // Restore pad color in inner rectangle (reduced by margin)
80
+ const innerWidth = scaledWidth - scaledMargin * 2
81
+ const innerHeight = scaledHeight - scaledMargin * 2
82
+ const innerRadius = Math.max(0, scaledRadius - scaledMargin)
83
+
84
+ if (innerWidth > 0 && innerHeight > 0) {
85
+ ctx.beginPath()
86
+ if (innerRadius > 0) {
87
+ const x = -innerWidth / 2
88
+ const y = -innerHeight / 2
89
+ const r = Math.min(innerRadius, innerWidth / 2, innerHeight / 2)
90
+
91
+ ctx.moveTo(x + r, y)
92
+ ctx.lineTo(x + innerWidth - r, y)
93
+ ctx.arcTo(x + innerWidth, y, x + innerWidth, y + r, r)
94
+ ctx.lineTo(x + innerWidth, y + innerHeight - r)
95
+ ctx.arcTo(
96
+ x + innerWidth,
97
+ y + innerHeight,
98
+ x + innerWidth - r,
99
+ y + innerHeight,
100
+ r,
101
+ )
102
+ ctx.lineTo(x + r, y + innerHeight)
103
+ ctx.arcTo(x, y + innerHeight, x, y + innerHeight - r, r)
104
+ ctx.lineTo(x, y + r)
105
+ ctx.arcTo(x, y, x + r, y, r)
106
+ } else {
107
+ ctx.rect(-innerWidth / 2, -innerHeight / 2, innerWidth, innerHeight)
108
+ }
109
+
110
+ ctx.fillStyle = padColor
111
+ ctx.fill()
112
+ }
113
+
114
+ ctx.restore()
115
+ }
116
+
117
+ /**
118
+ * Draws a soldermask ring for circular shapes with negative margin
119
+ * (soldermask appears inside the pad boundary)
120
+ */
121
+ export function drawSoldermaskRingForCircle(
122
+ ctx: CanvasContext,
123
+ center: { x: number; y: number },
124
+ radius: number,
125
+ margin: number,
126
+ realToCanvasMat: Matrix,
127
+ soldermaskColor: string,
128
+ padColor: string,
129
+ ): void {
130
+ const [cx, cy] = applyToPoint(realToCanvasMat, [center.x, center.y])
131
+ const scaledRadius = radius * Math.abs(realToCanvasMat.a)
132
+ const scaledMargin = Math.abs(margin) * Math.abs(realToCanvasMat.a)
133
+
134
+ ctx.save()
135
+
136
+ // For negative margins, outer is pad boundary, inner is reduced by margin
137
+ // Use source-atop so the ring only appears on the pad
138
+ const prevCompositeOp = ctx.globalCompositeOperation
139
+ if (ctx.globalCompositeOperation !== undefined) {
140
+ ctx.globalCompositeOperation = "source-atop"
141
+ }
142
+
143
+ // Draw outer circle filled (at pad boundary)
144
+ ctx.beginPath()
145
+ ctx.arc(cx, cy, scaledRadius, 0, Math.PI * 2)
146
+ ctx.fillStyle = soldermaskColor
147
+ ctx.fill()
148
+
149
+ // Reset composite operation and restore pad color in inner area
150
+ if (ctx.globalCompositeOperation !== undefined) {
151
+ ctx.globalCompositeOperation = prevCompositeOp || "source-over"
152
+ }
153
+
154
+ // Restore pad color in inner circle (reduced by margin)
155
+ const innerRadius = Math.max(0, scaledRadius - scaledMargin)
156
+ if (innerRadius > 0) {
157
+ ctx.beginPath()
158
+ ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2)
159
+ ctx.fillStyle = padColor
160
+ ctx.fill()
161
+ }
162
+
163
+ ctx.restore()
164
+ }
165
+
166
+ /**
167
+ * Draws a soldermask ring for pill shapes with negative margin
168
+ * (soldermask appears inside the pad boundary)
169
+ */
170
+ export function drawSoldermaskRingForPill(
171
+ ctx: CanvasContext,
172
+ center: { x: number; y: number },
173
+ width: number,
174
+ height: number,
175
+ margin: number,
176
+ rotation: number,
177
+ realToCanvasMat: Matrix,
178
+ soldermaskColor: string,
179
+ padColor: string,
180
+ ): void {
181
+ const [cx, cy] = applyToPoint(realToCanvasMat, [center.x, center.y])
182
+ const scaledWidth = width * Math.abs(realToCanvasMat.a)
183
+ const scaledHeight = height * Math.abs(realToCanvasMat.a)
184
+ const scaledMargin = Math.abs(margin) * Math.abs(realToCanvasMat.a)
185
+
186
+ ctx.save()
187
+ ctx.translate(cx, cy)
188
+
189
+ if (rotation !== 0) {
190
+ ctx.rotate(-rotation * (Math.PI / 180))
191
+ }
192
+
193
+ // For negative margins, outer is pad boundary, inner is reduced by margin
194
+ // Use source-atop so the ring only appears on the pad
195
+ const prevCompositeOp = ctx.globalCompositeOperation
196
+ if (ctx.globalCompositeOperation !== undefined) {
197
+ ctx.globalCompositeOperation = "source-atop"
198
+ }
199
+
200
+ // Draw outer pill filled (at pad boundary)
201
+ const outerWidth = scaledWidth
202
+ const outerHeight = scaledHeight
203
+
204
+ ctx.beginPath()
205
+
206
+ if (outerWidth > outerHeight) {
207
+ const radius = outerHeight / 2
208
+ const straightLength = outerWidth - outerHeight
209
+ ctx.moveTo(-straightLength / 2, -radius)
210
+ ctx.lineTo(straightLength / 2, -radius)
211
+ ctx.arc(straightLength / 2, 0, radius, -Math.PI / 2, Math.PI / 2)
212
+ ctx.lineTo(-straightLength / 2, radius)
213
+ ctx.arc(-straightLength / 2, 0, radius, Math.PI / 2, -Math.PI / 2)
214
+ } else if (outerHeight > outerWidth) {
215
+ const radius = outerWidth / 2
216
+ const straightLength = outerHeight - outerWidth
217
+ ctx.moveTo(radius, -straightLength / 2)
218
+ ctx.lineTo(radius, straightLength / 2)
219
+ ctx.arc(0, straightLength / 2, radius, 0, Math.PI)
220
+ ctx.lineTo(-radius, -straightLength / 2)
221
+ ctx.arc(0, -straightLength / 2, radius, Math.PI, 0)
222
+ } else {
223
+ ctx.arc(0, 0, outerWidth / 2, 0, Math.PI * 2)
224
+ }
225
+
226
+ ctx.fillStyle = soldermaskColor
227
+ ctx.fill()
228
+
229
+ // Reset composite operation and restore pad color in inner area
230
+ if (ctx.globalCompositeOperation !== undefined) {
231
+ ctx.globalCompositeOperation = prevCompositeOp || "source-over"
232
+ }
233
+
234
+ // Restore pad color in inner pill (reduced by margin)
235
+ const innerWidth = scaledWidth - scaledMargin * 2
236
+ const innerHeight = scaledHeight - scaledMargin * 2
237
+
238
+ if (innerWidth > 0 && innerHeight > 0) {
239
+ ctx.beginPath()
240
+
241
+ if (innerWidth > innerHeight) {
242
+ const radius = innerHeight / 2
243
+ const straightLength = innerWidth - innerHeight
244
+ ctx.moveTo(-straightLength / 2, -radius)
245
+ ctx.lineTo(straightLength / 2, -radius)
246
+ ctx.arc(straightLength / 2, 0, radius, -Math.PI / 2, Math.PI / 2)
247
+ ctx.lineTo(-straightLength / 2, radius)
248
+ ctx.arc(-straightLength / 2, 0, radius, Math.PI / 2, -Math.PI / 2)
249
+ } else if (innerHeight > innerWidth) {
250
+ const radius = innerWidth / 2
251
+ const straightLength = innerHeight - innerWidth
252
+ ctx.moveTo(radius, -straightLength / 2)
253
+ ctx.lineTo(radius, straightLength / 2)
254
+ ctx.arc(0, straightLength / 2, radius, 0, Math.PI)
255
+ ctx.lineTo(-radius, -straightLength / 2)
256
+ ctx.arc(0, -straightLength / 2, radius, Math.PI, 0)
257
+ } else {
258
+ ctx.arc(0, 0, innerWidth / 2, 0, Math.PI * 2)
259
+ }
260
+
261
+ ctx.fillStyle = padColor
262
+ ctx.fill()
263
+ }
264
+
265
+ ctx.restore()
266
+ }
@@ -5,9 +5,11 @@ import type { CanvasContext } from "../types"
5
5
  export interface DrawOvalParams {
6
6
  ctx: CanvasContext
7
7
  center: { x: number; y: number }
8
- width: number
9
- height: number
10
- fill: string
8
+ radius_x: number
9
+ radius_y: number
10
+ fill?: string
11
+ stroke?: string
12
+ strokeWidth?: number
11
13
  realToCanvasMat: Matrix
12
14
  rotation?: number
13
15
  }
@@ -16,16 +18,19 @@ export function drawOval(params: DrawOvalParams): void {
16
18
  const {
17
19
  ctx,
18
20
  center,
19
- width,
20
- height,
21
+ radius_x,
22
+ radius_y,
21
23
  fill,
24
+ stroke,
25
+ strokeWidth = 0.1,
22
26
  realToCanvasMat,
23
27
  rotation = 0,
24
28
  } = params
25
29
 
26
30
  const [cx, cy] = applyToPoint(realToCanvasMat, [center.x, center.y])
27
- const scaledWidth = width * Math.abs(realToCanvasMat.a)
28
- const scaledHeight = height * Math.abs(realToCanvasMat.a)
31
+ const scaledRadiusX = radius_x * Math.abs(realToCanvasMat.a)
32
+ const scaledRadiusY = radius_y * Math.abs(realToCanvasMat.a)
33
+ const scaledStrokeWidth = strokeWidth * Math.abs(realToCanvasMat.a)
29
34
 
30
35
  ctx.save()
31
36
  ctx.translate(cx, cy)
@@ -35,8 +40,18 @@ export function drawOval(params: DrawOvalParams): void {
35
40
  }
36
41
 
37
42
  ctx.beginPath()
38
- ctx.ellipse(0, 0, scaledWidth / 2, scaledHeight / 2, 0, 0, Math.PI * 2)
39
- ctx.fillStyle = fill
40
- ctx.fill()
43
+ ctx.ellipse(0, 0, scaledRadiusX, scaledRadiusY, 0, 0, Math.PI * 2)
44
+
45
+ if (fill) {
46
+ ctx.fillStyle = fill
47
+ ctx.fill()
48
+ }
49
+
50
+ if (stroke) {
51
+ ctx.strokeStyle = stroke
52
+ ctx.lineWidth = scaledStrokeWidth
53
+ ctx.stroke()
54
+ }
55
+
41
56
  ctx.restore()
42
57
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "circuit-to-canvas",
3
3
  "main": "dist/index.js",
4
- "version": "0.0.30",
4
+ "version": "0.0.32",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "tsup-node ./lib/index.ts --format esm --dts",
@@ -17,9 +17,9 @@
17
17
  "@tscircuit/math-utils": "^0.0.29",
18
18
  "@types/bun": "latest",
19
19
  "bun-match-svg": "^0.0.14",
20
- "circuit-json": "^0.0.346",
20
+ "circuit-json": "^0.0.348",
21
21
  "circuit-json-to-connectivity-map": "^0.0.23",
22
- "circuit-to-svg": "^0.0.297",
22
+ "circuit-to-svg": "^0.0.303",
23
23
  "looks-same": "^10.0.1",
24
24
  "schematic-symbols": "^0.0.202",
25
25
  "tsup": "^8.5.1"
@@ -0,0 +1,37 @@
1
+ import { expect, test } from "bun:test"
2
+ import type { AnyCircuitElement, PcbSilkscreenOval } from "circuit-json"
3
+ import { getStackedPngSvgComparison } from "../fixtures/getStackedPngSvgComparison"
4
+
5
+ test("draw silkscreen oval", async () => {
6
+ const oval: PcbSilkscreenOval = {
7
+ type: "pcb_silkscreen_oval",
8
+ layer: "top" as const,
9
+ pcb_component_id: "pcb_component_1",
10
+ pcb_silkscreen_oval_id: "oval_1",
11
+ center: { x: 0, y: 0 },
12
+ radius_x: 2,
13
+ radius_y: 1,
14
+ ccw_rotation: 45,
15
+ }
16
+
17
+ const circuitJson: AnyCircuitElement[] = [
18
+ {
19
+ type: "pcb_board",
20
+ pcb_board_id: "board1",
21
+ center: { x: 0, y: 0 },
22
+ width: 10,
23
+ height: 10,
24
+ thickness: 1.6,
25
+ num_layers: 2,
26
+ material: "fr4",
27
+ },
28
+ oval,
29
+ ]
30
+
31
+ const stackedPng = await getStackedPngSvgComparison(circuitJson, {
32
+ width: 400,
33
+ height: 800,
34
+ })
35
+
36
+ await expect(stackedPng).toMatchPngSnapshot(import.meta.path)
37
+ })
@@ -0,0 +1,163 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "@napi-rs/canvas"
3
+ import { CircuitToCanvasDrawer } from "../../lib/drawer"
4
+
5
+ test("draw smt pads with positive and negative soldermask margins", async () => {
6
+ const canvas = createCanvas(800, 600)
7
+ const ctx = canvas.getContext("2d")
8
+ const drawer = new CircuitToCanvasDrawer(ctx)
9
+
10
+ ctx.fillStyle = "#1a1a1a"
11
+ ctx.fillRect(0, 0, 800, 600)
12
+
13
+ const circuit: any = [
14
+ {
15
+ type: "pcb_board",
16
+ pcb_board_id: "board0",
17
+ center: { x: 0, y: 0 },
18
+ width: 14,
19
+ height: 10,
20
+ },
21
+ // Rectangle with positive margin (mask extends beyond pad)
22
+ {
23
+ type: "pcb_smtpad",
24
+ pcb_smtpad_id: "pad_rect_positive",
25
+ shape: "rect",
26
+ layer: "top",
27
+ x: -4,
28
+ y: 2,
29
+ width: 1.6,
30
+ height: 1.1,
31
+ is_covered_with_solder_mask: true,
32
+ soldermask_margin: 0.2,
33
+ },
34
+ // Rectangle with negative margin (spacing around copper, copper visible)
35
+ {
36
+ type: "pcb_smtpad",
37
+ pcb_smtpad_id: "pad_rect_negative",
38
+ shape: "rect",
39
+ layer: "top",
40
+ x: -4,
41
+ y: -2,
42
+ width: 1.6,
43
+ height: 1.1,
44
+ is_covered_with_solder_mask: true,
45
+ soldermask_margin: -0.15,
46
+ },
47
+ // Circle with positive margin
48
+ {
49
+ type: "pcb_smtpad",
50
+ pcb_smtpad_id: "pad_circle_positive",
51
+ shape: "circle",
52
+ layer: "top",
53
+ x: 0,
54
+ y: 2,
55
+ radius: 0.75,
56
+ is_covered_with_solder_mask: true,
57
+ soldermask_margin: 0.15,
58
+ },
59
+ // Circle with negative margin
60
+ {
61
+ type: "pcb_smtpad",
62
+ pcb_smtpad_id: "pad_circle_negative",
63
+ shape: "circle",
64
+ layer: "top",
65
+ x: 0,
66
+ y: -2,
67
+ radius: 0.75,
68
+ is_covered_with_solder_mask: true,
69
+ soldermask_margin: -0.2,
70
+ },
71
+ // Pill with positive margin
72
+ {
73
+ type: "pcb_smtpad",
74
+ pcb_smtpad_id: "pad_pill_positive",
75
+ shape: "pill",
76
+ layer: "top",
77
+ x: 4,
78
+ y: 2,
79
+ width: 2.4,
80
+ height: 1,
81
+ radius: 0.5,
82
+ is_covered_with_solder_mask: true,
83
+ soldermask_margin: 0.1,
84
+ },
85
+ // Pill with negative margin
86
+ {
87
+ type: "pcb_smtpad",
88
+ pcb_smtpad_id: "pad_pill_negative",
89
+ shape: "pill",
90
+ layer: "top",
91
+ x: 4,
92
+ y: -2,
93
+ width: 2.4,
94
+ height: 1,
95
+ radius: 0.5,
96
+ is_covered_with_solder_mask: true,
97
+ soldermask_margin: -0.12,
98
+ },
99
+ // Silkscreen labels for positive margin pads (top row)
100
+ {
101
+ type: "pcb_silkscreen_text",
102
+ pcb_silkscreen_text_id: "text_rect_pos",
103
+ layer: "top",
104
+ anchor_position: { x: -4, y: 3.2 },
105
+ anchor_alignment: "center",
106
+ text: "+0.2mm",
107
+ font_size: 0.4,
108
+ },
109
+ {
110
+ type: "pcb_silkscreen_text",
111
+ pcb_silkscreen_text_id: "text_circle_pos",
112
+ layer: "top",
113
+ anchor_position: { x: 0, y: 3.2 },
114
+ anchor_alignment: "center",
115
+ text: "+0.15mm",
116
+ font_size: 0.4,
117
+ },
118
+ {
119
+ type: "pcb_silkscreen_text",
120
+ pcb_silkscreen_text_id: "text_pill_pos",
121
+ layer: "top",
122
+ anchor_position: { x: 4, y: 3.2 },
123
+ anchor_alignment: "center",
124
+ text: "+0.1mm",
125
+ font_size: 0.4,
126
+ },
127
+ // Silkscreen labels for negative margin pads (bottom row)
128
+ {
129
+ type: "pcb_silkscreen_text",
130
+ pcb_silkscreen_text_id: "text_rect_neg",
131
+ layer: "top",
132
+ anchor_position: { x: -4, y: -3.2 },
133
+ anchor_alignment: "center",
134
+ text: "-0.15mm",
135
+ font_size: 0.4,
136
+ },
137
+ {
138
+ type: "pcb_silkscreen_text",
139
+ pcb_silkscreen_text_id: "text_circle_neg",
140
+ layer: "top",
141
+ anchor_position: { x: 0, y: -3.2 },
142
+ anchor_alignment: "center",
143
+ text: "-0.2mm",
144
+ font_size: 0.4,
145
+ },
146
+ {
147
+ type: "pcb_silkscreen_text",
148
+ pcb_silkscreen_text_id: "text_pill_neg",
149
+ layer: "top",
150
+ anchor_position: { x: 4, y: -3.2 },
151
+ anchor_alignment: "center",
152
+ text: "-0.12mm",
153
+ font_size: 0.4,
154
+ },
155
+ ]
156
+
157
+ drawer.setCameraBounds({ minX: -7, maxX: 7, minY: -5, maxY: 5 })
158
+ drawer.drawElements(circuit)
159
+
160
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
161
+ import.meta.path,
162
+ )
163
+ })
@@ -13,10 +13,11 @@ test("draw oval", async () => {
13
13
  drawOval({
14
14
  ctx,
15
15
  center: { x: 50, y: 50 },
16
- width: 70,
17
- height: 40,
16
+ radius_x: 50,
17
+ radius_y: 25,
18
18
  fill: "#0000ff",
19
19
  realToCanvasMat: identity(),
20
+ rotation: 45,
20
21
  })
21
22
 
22
23
  await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(